From 5ba016a5c522fae5c867b2fd81a0437d7d4cc3bc Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 20:40:44 -0700 Subject: [PATCH 001/148] Add the initiatives and initiative_projects tables Initiatives are one layer above projects: a name, a goal, an owner and a rank of their own, and one home per project (an initiative, Keep the lights on, Not tracked, or no row for Unsorted). The project slug is the primary key of initiative_projects, so a project can't sit in two. set_at and baseline_prs are where the not-tracked growth check starts counting from. 0039 makes both tables, so each has its own MIGRATIONS entry, as 0028's do; schema.sql and bin/dump-schema list them too. Nothing reads them yet. Co-Authored-By: Claude Opus 5.5 --- bin/dump-schema | 2 ++ lib/schema-check.js | 3 ++ migrations/0039-initiatives--add-tables.sql | 27 ++++++++++++++++ migrations/schema.sql | 36 +++++++++++++++++++++ test/schema-check.test.js | 6 ++++ 5 files changed, 74 insertions(+) create mode 100644 migrations/0039-initiatives--add-tables.sql 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/lib/schema-check.js b/lib/schema-check.js index 9fda8d6b..6a7d7599 100644 --- a/lib/schema-check.js +++ b/lib/schema-check.js @@ -55,6 +55,9 @@ 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' }, ]; /** The migration files this database hasn't had yet, oldest first. */ 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/schema.sql b/migrations/schema.sql index 4623be96..4545f946 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -294,6 +294,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/test/schema-check.test.js b/test/schema-check.test.js index 453bd929..33e5a4d1 100644 --- a/test/schema-check.test.js +++ b/test/schema-check.test.js @@ -128,3 +128,9 @@ 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(); +}); From 3e786482b765a73ed53c8e20ef3c3c73b93317ee Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 20:45:10 -0700 Subject: [PATCH 002/148] Teach the shared model initiatives and not tracked The server and the board both need the same rules for the new layer, so they live in shared/: an initiative's shape, the checks on what a person sends, its span read off its projects' plans (parked and dropped plans don't count; done ones count toward the start), the growth check (30 PRs and at least as many as it had), and each project's home. decideQueue takes the initiatives and the grown not-tracked projects and asks four new things: unfiled, untracked_grew, goal_reached and reopened_in_closed. Nothing asks where a project belongs while no initiative exists, so the empty tables change nothing on day one. A not-tracked plan owes no update: healthStanding and planWarnings stay quiet for it. The board's Decide gets plain placeholder words for the new reasons so its switches stay whole; no board caller passes initiatives yet. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/decide.test.ts | 135 ++++++++++++ frontend-v2/src/model/initiatives.test.ts | 102 +++++++++ frontend-v2/src/model/portfolio.ts | 10 + frontend-v2/src/views/projects/Decide.tsx | 19 ++ .../src/views/projects/roadmapHealth.test.ts | 10 + .../src/views/projects/roadmapHealth.tsx | 4 + shared/index.ts | 20 ++ shared/model/decide.ts | 115 ++++++++++- shared/model/initiatives.ts | 194 ++++++++++++++++++ shared/model/roadmap.ts | 10 +- 10 files changed, 613 insertions(+), 6 deletions(-) create mode 100644 frontend-v2/src/model/initiatives.test.ts create mode 100644 shared/model/initiatives.ts diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts index af85eb36..73ad7f43 100644 --- a/frontend-v2/src/model/decide.test.ts +++ b/frontend-v2/src/model/decide.test.ts @@ -850,3 +850,138 @@ 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 + const kept = [initiative(7, ['a', 'b', 'c'], { status_at: ago(10) })]; + expect(ask({ ...base, live: [], initiatives: kept })).toEqual([]); + const refiled = [ + { + ...kept[0], + projects: [...kept[0].projects, { project: 'd', set_by: null, set_at: ago(1), baseline_prs: 0 }], + }, + ]; + expect(ask({ ...base, live: [], initiatives: refiled })).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('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' }, + ]); + }); +}); 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/portfolio.ts b/frontend-v2/src/model/portfolio.ts index 375ae342..637b995f 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -264,6 +264,16 @@ 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): words until the + // board's Decide answers them + case 'unfiled': + return { kind: 'no_plan', text: 'In no initiative' }; + case 'untracked_grew': + return { kind: 'no_plan', text: 'Still not tracked?' }; + case 'reopened_in_closed': + return { kind: 'reopened', text: 'Its initiative is closed, still worked on' }; + case 'goal_reached': + return { kind: 'issues_done', text: 'Did it reach its goal?' }; } } diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 366b8d2f..ef3d1639 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -323,6 +323,17 @@ export function askOf(reason: DecideReason): { return reason.done ? { question: 'Is it done?', call: 'done' } : { question: 'Drop it?', call: 'drop' }; + // the initiative calls file or close, not plan; their answers come + // with the initiatives board work, and until then no caller passes + // decideQueue the initiatives that raise them + case 'unfiled': + return { question: 'Which initiative is it part of?', call: 'commit' }; + case 'untracked_grew': + return { question: 'Still not tracked?', call: 'commit' }; + case 'goal_reached': + return { question: 'Did it reach its goal?', call: 'done' }; + case 'reopened_in_closed': + return { question: 'Reopen its initiative?', call: 'commit' }; } } @@ -512,6 +523,14 @@ function reasonFacts(reason: DecideReason, item: RoadmapItem | null, bare = fals ? `${upperFirst(tally)}${open}` : `All its issues are closed (${tally})${open}`; } + case 'unfiled': + return 'It’s in no initiative'; + case 'untracked_grew': + return `${n(reason.baseline + reason.since, 'PR')}, ${reason.since} since it was set not tracked`; + case 'goal_reached': + return 'Every project in it is finished or quiet'; + case 'reopened_in_closed': + return `Its initiative is ${reason.as}, but it’s being worked on again`; } } diff --git a/frontend-v2/src/views/projects/roadmapHealth.test.ts b/frontend-v2/src/views/projects/roadmapHealth.test.ts index f5a7ee39..d2005e5f 100644 --- a/frontend-v2/src/views/projects/roadmapHealth.test.ts +++ b/frontend-v2/src/views/projects/roadmapHealth.test.ts @@ -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') }, diff --git a/frontend-v2/src/views/projects/roadmapHealth.tsx b/frontend-v2/src/views/projects/roadmapHealth.tsx index 507544d3..cd4057d7 100644 --- a/frontend-v2/src/views/projects/roadmapHealth.tsx +++ b/frontend-v2/src/views/projects/roadmapHealth.tsx @@ -380,6 +380,10 @@ 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'; diff --git a/shared/index.ts b/shared/index.ts index f0e7b3c7..cd2a2d5b 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -117,11 +117,31 @@ export { } from './model/decide'; export type { ClosedIssue, + GrownProject, DecideProject, DecideReason, DecideRow, PlanCounts, } from './model/decide'; +export { + checkInitiativeFields, + 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..f14bf752 100644 --- a/shared/model/decide.ts +++ b/shared/model/decide.ts @@ -1,4 +1,5 @@ import { dayStart, firstOpenDay, targetOf, utcDay, type Project, type Today } from './projects'; +import { homesOf, type Initiative } from './initiatives'; import { healthStanding, isUnderWay, planEnd, type RoadmapItem } from './roadmap'; import type { IssueCounts } from './work'; @@ -26,6 +27,14 @@ 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), four 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 + * - `untracked_grew`: a not-tracked project that grew (initiatives.ts grew) + * - `goal_reached`: every project of an open initiative finished, parked or + * quiet for GOAL_QUIET_DAYS, and nothing changed since Keep open + * - `reopened_in_closed`: a project in a closed initiative has open PRs or + * a plan under way again * 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 +47,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 +72,21 @@ 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' } + /** `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 +169,10 @@ export const RANK: Record = { stalled: 8, at_risk: 9, new: 10, + reopened_in_closed: 0, + goal_reached: 7, + unfiled: 10, + untracked_grew: 10, }; /** A plan's PRs, by the dates (model/work.ts PlanWork), as Decide counts them. */ @@ -191,6 +221,8 @@ export function decideQueue({ planCounts = new Map(), issues = new Map(), ongoing = new Set(), + initiatives = [], + grown = [], today, now, }: { @@ -205,6 +237,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,8 +254,12 @@ 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); @@ -342,7 +384,74 @@ 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 going = (slug: string) => (plansOf.get(slug) ?? []).filter(p => isUnderWay(p.status)); + // a project's own row, when it has one already + const rowOf = (slug: string) => [...rows.values()].find(r => r.slug === slug); + const unfiled = new Set([ + ...live.filter(p => p.prs >= DECIDE_MIN_PRS).map(p => p.slug), + ...[...plansOf.keys()].filter(slug => going(slug).length), + ]); + for (const slug of unfiled) { + if (homes.has(slug)) continue; + const row = rowOf(slug); + // the first call files it too + if (row?.reasons.some(r => r.kind === 'new')) continue; + if (row) row.reasons.push({ kind: 'unfiled' }); + else add(slug, going(slug)[0] ?? null, { kind: 'unfiled' }); + } + for (const initiative of initiatives) { + if (initiative.kind !== 'initiative') continue; + const slugs = initiative.projects.map(f => f.project); + if (initiative.status !== 'open') { + const as = initiative.status; + for (const slug of slugs) { + if ((liveBySlug.get(slug)?.open ?? 0) > 0 || going(slug).length) { + add(slug, going(slug)[0] ?? null, { + kind: 'reopened_in_closed', + initiative: initiative.id, + as, + }); + } + } + continue; + } + if (!slugs.length) continue; + // finished, parked or dropped plans, or no plan and nothing in + // flight (Today's live holds the last LIVE_DAYS); when it settled, + // and when anything in it last changed + let settled = 0; + let changed = 0; + let open = false; + for (const f of initiative.projects) { + const plans = plansOf.get(f.project) ?? []; + const project = liveBySlug.get(f.project); + if (plans.some(p => isUnderWay(p.status)) || (!plans.length && project)) open = true; + const times = plans.map(p => Math.max(p.status_at ?? 0, decidedAt(p))); + settled = Math.max(settled, ...times); + changed = Math.max(changed, settled, f.set_at, project?.lastActivity ?? 0); + } + if (open || now - settled < GOAL_QUIET_DAYS * DAY) continue; + // Keep open: asked again only once something in it changes later + 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..37b3d08b --- /dev/null +++ b/shared/model/initiatives.ts @@ -0,0 +1,194 @@ +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); +} + +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/roadmap.ts b/shared/model/roadmap.ts index 39d32271..faa5d4d5 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 */ @@ -615,16 +619,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) { From 7f63ebc7af6eaac4da980376909368969b164786 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 20:52:13 -0700 Subject: [PATCH 003/148] Carry a project's filing through a label rename A renamed P- label moves what Pulldasher keeps by slug, and an initiative filing is now one of those things. It moves exactly when the roadmap item does, so a plan left on the old slug for a person to merge keeps its home. When both slugs are filed, the new one's home wins and the old row goes, the merge rule everywhere. Co-Authored-By: Claude Opus 5.5 --- lib/work.js | 12 ++++++++++-- test/hooks-label-rename.test.js | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/work.js b/lib/work.js index 50ae88c2..d7a0e40a 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,20 @@ 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, + ]); + await db.query('DELETE FROM `initiative_projects` WHERE `project` = ?', [from]); } const ongoing = savedSetting('ongoing_projects'); if (ongoing?.includes(from)) { diff --git a/test/hooks-label-rename.test.js b/test/hooks-label-rename.test.js index 62bf2ee0..97c4b7d2 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,17 @@ 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`'))); }); From d0957eac08614ed475edab44ab5bce62500368eb Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 20:52:34 -0700 Subject: [PATCH 004/148] Add initiatives storage and their write routes lib/initiatives.js reads and writes the two tables: list in rank order with each initiative's projects, create (ranked last), edit, delete when empty (the undo of a create, freeing the slug), move one above another, and file a project in one home or none. Filing takes the home the person saw (`expect`) and answers 409 with the current one when it changed, and it hands back the home it replaced so an undo can put it back with its set_at and baseline_prs. Filing into Not tracked starts the growth check: the PR count sent, else the old baseline plus the PRs since (Keep), else the count now, counting only labels that sit on a pull, since pull_labels holds issues' labels too. The same handlers serve the board under /initiatives and the API under /api/v1/initiatives, each write behind canWrite. Closing an initiative is refused while a project in it has a plan under way or open PRs. PROJECT_WRITES moves into api-routes.js beside the table it guards, so a test can check every API write is one open boards hear about. Co-Authored-By: Claude Opus 5.5 --- app.js | 13 +- controllers/api-routes.js | 77 ++++++++ controllers/initiatives.js | 219 ++++++++++++++++++++ controllers/roadmap.js | 2 +- lib/initiatives.js | 339 +++++++++++++++++++++++++++++++ test/initiatives.test.js | 395 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1041 insertions(+), 4 deletions(-) create mode 100644 controllers/initiatives.js create mode 100644 lib/initiatives.js create mode 100644 test/initiatives.test.js diff --git a/app.js b/app.js index 00077a42..8f79bc7e 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'; @@ -109,8 +110,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 () { @@ -132,6 +131,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/controllers/api-routes.js b/controllers/api-routes.js index 90e8b774..b81846dd 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,28 @@ import { ROADMAP_ORIGINS, ROADMAP_STATUSES, DECIDE_MIN_PRS, + 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 @@ -216,6 +234,57 @@ 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`: slug, 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', + }, + { + method: 'put', + path: '/api/v1/initiatives/file', + handlers: [canWrite, initiativesController.file], + does: + 'File a project: {project, initiative: or null for none, expect?, baseline_prs?}. `expect` is the ' + + 'home you saw (a slug or null); 409 with the current `home` when it changed. Answers `before` and `home` ' + + '(initiative, set_by, set_at, baseline_prs); undo by sending `before` back with its set_at and baseline_prs. ' + + '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). 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 +336,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', diff --git a/controllers/initiatives.js b/controllers/initiatives.js new file mode 100644 index 00000000..58a38c6c --- /dev/null +++ b/controllers/initiatives.js @@ -0,0 +1,219 @@ +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, + updateInitiative, +} from '../lib/initiatives.js'; +import { undoTimes } from './roadmap.js'; +import { + checkInitiativeFields, + initiativeSpan, + isUnderWay, + 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 with work going: a plan under way, or PRs + * open on the board. An initiative closes only once none is left. + */ +async function liveProjects(initiative) { + const slugs = initiative.projects.map(f => f.project); + const going = new Set( + (await listItems()).filter(i => i.project && isUnderWay(i.status)).map(i => i.project) + ); + const settings = projectSettings(); + 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) if (g.open.length) going.add(g.slug); + } + return slugs.filter(slug => going.has(slug)); +} + +/** 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. */ + create: function (req, res) { + const checked = checkInitiativeFields(req.body, { partial: false }); + if (checked.error) return res.status(400).json({ error: checked.error }); + createInitiative(checked.fields, req.roadmapLogin) + .then(async made => { + 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. 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 ?? {}; + const checked = checkInitiativeFields(body, { partial: true }); + if (checked.error) return res.status(400).json({ error: checked.error }); + (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'; + if (closing && was.status === 'open') { + 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: undoTimes(undo), + }); + 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' }); + 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`. + */ + 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' }); + } + const count = v => v == null || (Number.isInteger(v) && v >= 0); + 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(); + fileProject(project, initiative, req.roadmapLogin, { + prefix: settings?.prefix ?? DEFAULT_PROJECT_PREFIX, + expect, + baseline_prs: body.baseline_prs ?? null, + set_at: body.set_at ?? null, + now, + }) + .then(result => { + if (result.missing) return res.status(404).json({ error: 'no initiative has that slug' }); + if (result.conflict) { + return res + .status(409) + .json({ error: 'its home changed; here it is now', home: result.home }); + } + res.json({ project, before: result.before, home: result.home }); + }) + .catch(failed(res, 'filing')); + }, +}; diff --git a/controllers/roadmap.js b/controllers/roadmap.js index de427ccd..e5cef99a 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -178,7 +178,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; diff --git a/lib/initiatives.js b/lib/initiatives.js new file mode 100644 index 00000000..6409afb2 --- /dev/null +++ b/lib/initiatives.js @@ -0,0 +1,339 @@ +import db from './db.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); + byInitiative.set(id, [...(byInitiative.get(id) ?? []), 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 }; +} + +/** + * 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. + */ +export async function prCount(prefix, project, since = 0) { + const [row] = await db.query( + 'SELECT COUNT(DISTINCT p.`repo`, p.`number`) AS `n` FROM `pull_labels` l ' + + 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` ' + + 'WHERE l.`title` = ? AND p.`date` > ?', + [prefix + project, since] + ); + return Number(row?.n ?? 0); +} + +/** + * 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. + * + * Resolves `{ 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( + project, + to, + login, + { prefix, expect, baseline_prs = null, set_at = null, now = Math.floor(Date.now() / 1000) } +) { + 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; + if (expect !== undefined && (current?.initiative ?? null) !== expect) { + return { conflict: true, home: home(current) }; + } + const before = home(current) ?? { initiative: null, set_by: null, set_at: null, baseline_prs: 0 }; + if (to == null) { + await db.query('DELETE FROM `initiative_projects` WHERE `project` = ?', [project]); + return { before, home: null }; + } + const [target] = await db.query('SELECT `id`, `kind` FROM `initiatives` WHERE `slug` = ?', [to]); + if (!target) return { missing: 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 { before, home: { initiative: to, 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 l.`title`, p.`date` FROM `pull_labels` l ' + + 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` WHERE l.`title` IN (?)', + [[...untracked].map(slug => settings.prefix + slug)] + ); + const dates = new Map(); + for (const { title, date } of rows) { + const slug = title.slice(settings.prefix.length); + dates.set(slug, [...(dates.get(slug) ?? []), Number(date)]); + } + for (const slug of untracked) { + const home = homes.get(slug); + const filing = home?.initiative.kind === 'untracked' ? home.filing : null; + const setAt = filing?.set_at ?? 0; + const baseline = filing?.baseline_prs ?? 0; + const since = (dates.get(slug) ?? []).filter(d => d > setAt).length; + 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/test/initiatives.test.js b/test/initiatives.test.js new file mode 100644 index 00000000..4f402587 --- /dev/null +++ b/test/initiatives.test.js @@ -0,0 +1,395 @@ +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 db from '../lib/db.js'; +import { trackedOnly } from '../lib/initiatives.js'; +import initiativesController from '../controllers/initiatives.js'; +import { 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 } +let prs = []; +let nextId = 1; + +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.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 => 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 COUNT(DISTINCT')) { + const [title, since] = params; + return [{ n: prs.filter(p => p.title === title && p.date > since).length }]; + } + if (sql.startsWith('SELECT `id`, `kind` FROM `initiatives`')) { + return initiatives.filter(i => i.slug === params[0]); + } + if (sql.startsWith('DELETE FROM `initiative_projects`')) { + filings = filings.filter(f => f.project !== params[0]); + return { affectedRows: 1 }; + } + if (sql.startsWith('REPLACE INTO `initiative_projects` SET ?')) { + filings = [...filings.filter(f => f.project !== params[0].project), { ...params[0] }]; + return { affectedRows: 1 }; + } + 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); + return app; +} + +let server; +let base; +before(async () => { + mock.method(db, 'query', async (sql, params) => 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; +}); + +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('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('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); + } +}); From 04a19f02f9a4e43112099771057ae87412af4629 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 20:53:11 -0700 Subject: [PATCH 005/148] Leave not-tracked projects out of what counts A project filed in Not tracked, or whose every labeled PR sits in a repo listed in the new projects.untrackedRepos (left unset), stops counting. The three endpoints that count run one filter, trackedOnly: Decide drops those projects, the load drops them past and ahead, and updates-owed drops their plans. Each roadmap item now carries `untracked`, set in `sent` before its standing is read, so a not-tracked plan's standing is quiet everywhere the item goes. A database without the new tables still reads its roadmap. The growth check counts PRs opened since a project was set not tracked against its baseline. Decide asks about the ones that grew, and asks where a tracked project with no home goes once any initiative exists, so the empty tables change nothing on day one. Each decision names its initiative. /projects-data gains the untracked and grew slug lists (with each grown project's counts), and /api/v1/projects gains initiative, tracked and grew per project. Co-Authored-By: Claude Opus 5.5 --- config.example.js | 5 ++ controllers/api-routes.js | 27 +++++-- controllers/projects.js | 81 ++++++++++++++----- controllers/roadmap.js | 61 ++++++++++---- lib/projects.js | 3 + test/projects.test.js | 164 +++++++++++++++++++++++++++++++++++++- test/roadmap.test.js | 61 ++++++++++++++ 7 files changed, 358 insertions(+), 44 deletions(-) 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 b81846dd..e6138b3c 100644 --- a/controllers/api-routes.js +++ b/controllers/api-routes.js @@ -14,6 +14,7 @@ import { ROADMAP_ORIGINS, ROADMAP_STATUSES, DECIDE_MIN_PRS, + GROWTH_FLOOR, INITIATIVE_GOAL_MAX, INITIATIVE_KINDS, INITIATIVE_NAME_MAX, @@ -65,7 +66,8 @@ 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', }, { method: 'get', @@ -140,15 +142,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', @@ -157,7 +160,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', @@ -170,7 +173,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', @@ -365,13 +369,22 @@ 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)`, + untracked_grew: `not tracked, and opened \`since\` PRs since it was set so: at least its count then (\`baseline\`) and ${GROWTH_FLOOR}`, + goal_reached: + 'every project of the open `initiative` is done, dropped, parked, or quiet with no plan, for 14 days; Keep open quiets it until one changes', + reopened_in_closed: + 'its `initiative` is closed (`as` done or dropped), but it has open PRs or a plan under way again', }, 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/projects.js b/controllers/projects.js index a8c89e2a..19467fa7 100644 --- a/controllers/projects.js +++ b/controllers/projects.js @@ -10,6 +10,7 @@ import { todayFromBoard, } from '../lib/projects.js'; import { listItems } from '../lib/roadmap.js'; +import { tracking, trackedOnly } from '../lib/initiatives.js'; import { attachIssue, detachIssue, @@ -83,13 +84,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), tracking(settings)]).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 +105,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 +145,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, @@ -182,13 +189,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 +208,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 +236,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,7 +245,7 @@ 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, @@ -479,8 +492,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 +502,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), + tracking(settings), + ]).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 +555,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 +629,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, tracking(settings)]).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 e5cef99a..2f92b375 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -15,6 +15,7 @@ import { restoreItem, updateItem, } from '../lib/roadmap.js'; +import { listInitiatives, trackedOnly, untrackedSlugs } from '../lib/initiatives.js'; import { sharedValue } from '../lib/ttl-cache.js'; import { loadPullLinks, loadWork } from '../lib/work.js'; import { @@ -62,6 +63,24 @@ const latelyNow = sharedValue(LATELY_MS, async () => { return latelyBySlug(today, work?.pace ?? {}, ongoing, now); }); +/** Which projects are Not tracked (lib/initiatives.js untrackedSlugs), kept + * as long as what they did lately and forgotten with it. */ +const untrackedNow = sharedValue(LATELY_MS, async () => { + const settings = projectSettings(); + return settings ? untrackedSlugs(settings, await listInitiatives()) : new Set(); +}); + +/** The not-tracked projects (untrackedNow); none when the read fails, so a + * database without the initiatives tables still reads its roadmap. */ +export async function untrackedProjects() { + try { + return await untrackedNow.get(); + } catch (err) { + console.error('roadmap: reading which projects are not tracked failed:', err); + return new Set(); + } +} + /** 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() { @@ -79,14 +98,19 @@ async function lately() { * 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(), untrackedProjects()]); 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 +156,7 @@ const standingNow = sharedValue(STANDING_MS, async () => { * issues and their links, changed it (app.js). */ export function forgetProjectCaches() { latelyNow.forget(); + untrackedNow.forget(); standingNow.forget(); } @@ -228,20 +253,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), untrackedProjects()]).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' ); }, diff --git a/lib/projects.js b/lib/projects.js index 0d47a71d..867e440b 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, }; } diff --git a/test/projects.test.js b/test/projects.test.js index 4f758a37..a98508cf 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -326,6 +326,12 @@ 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 = []; const label = (title, number) => new Label({ name: title }, number, 'test/repo-a', 'job-bot'); @@ -364,7 +370,7 @@ before(() => { 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' }), @@ -418,6 +424,12 @@ before(() => { { reviewer: 'alice', repo: 'test/repo-a', number: 11, author: 'alice', at: now - DAY }, ]; } + if (sql.includes('FROM `initiatives`')) return initiativeRows; + if (sql.includes('FROM `initiative_projects`')) return filingRows; + if (sql.startsWith('SELECT l.`title`, p.`repo`')) return labelRepos; + if (sql.startsWith('SELECT l.`title`, p.`date`')) { + return labelDates.filter(r => params[0].includes(r.title)); + } 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 @@ -705,3 +717,153 @@ 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 })); + +function withInitiatives(rows, filings, run) { + initiativeRows = rows; + filingRows = filings; + return run().finally(() => { + initiativeRows = []; + filingRows = []; + labelDates = []; + labelRepos = []; + }); +} + +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('/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)); + 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); + 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 = []; + const tracked = await get('/api/v1/load'); + assert.equal(tracked.body.this_week.on_plan, 1); + roadmapRows = []; + } + ); +}); diff --git a/test/roadmap.test.js b/test/roadmap.test.js index b349352d..68be100d 100644 --- a/test/roadmap.test.js +++ b/test/roadmap.test.js @@ -830,3 +830,64 @@ 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] + ); + }); +}); + +test('the roadmap still reads when the initiatives tables can’t be', async () => { + rows.push({ id: 1, name: 'Plan', project: 'alpha', status: 'active', start: '2026-01-05', weeks: 4, priority: 0 }); + await withProjects([], async () => { + const base = projectQuery; + projectQuery = sql => { + if (sql.includes('`initiative')) throw new Error("Table 'initiatives' doesn't exist"); + return base(sql); + }; + forgetProjectCaches(); + const errors = mock.method(console, 'error', () => {}); + const { status, body } = await call('GET', '/roadmap', undefined, {}); + errors.mock.restore(); + assert.equal(status, 200); + assert.equal(body.items[0].untracked, false); + }); +}); From 2fab6c331ab8131f84932d0c49ab3cb4240b056f Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 21:37:30 -0700 Subject: [PATCH 006/148] Read initiatives on the board, tracked by default The board loads the initiatives from /initiatives on first use, like the roadmap, and changes them in place with the server's answer to follow: filing a project, moving an initiative, closing or keeping one open. Not tracked projects leave the board's data where it comes in, so every list, count and question reads tracked data unless it asks for the full set. useRoadmap() and readRoadmap() drop a plan the server flagged untracked, or one a filing made here since puts in Not tracked; useProjectsData() drops their issues and numbers unless it's asked for all of them or one project. A reorder of the tracked plans is merged into the whole order PUT /roadmap/order needs, each Not tracked plan keeping its place. The one-slug readers (a plan's facts, a row's details, the backlog chart, People's charts, the review board's project standing) read the full set. The dummy board gets initiatives for every standing a lane can take, a Not tracked project with a plan that grew, and one with none, so frontend:dummy shows them. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/backend/dummy.ts | 5 + frontend-v2/src/backend/dummyProjects.ts | 144 +++++++ frontend-v2/src/model/initiativeBoard.test.ts | 268 +++++++++++++ frontend-v2/src/model/initiativeBoard.ts | 129 ++++++ frontend-v2/src/model/initiativesData.test.ts | 167 ++++++++ frontend-v2/src/model/initiativesData.ts | 371 ++++++++++++++++++ frontend-v2/src/model/portfolio.test.ts | 68 ++++ frontend-v2/src/model/portfolio.ts | 93 ++++- frontend-v2/src/model/projectData.ts | 77 +++- frontend-v2/src/model/roadmapData.test.ts | 56 +++ frontend-v2/src/model/roadmapData.ts | 69 +++- frontend-v2/src/model/standing.ts | 3 +- .../src/views/projects/BacklogSection.tsx | 3 +- frontend-v2/src/views/projects/People.tsx | 3 +- .../src/views/projects/roadmapHealth.tsx | 3 +- frontend-v2/vite.config.ts | 1 + 16 files changed, 1435 insertions(+), 25 deletions(-) create mode 100644 frontend-v2/src/model/initiativeBoard.test.ts create mode 100644 frontend-v2/src/model/initiativeBoard.ts create mode 100644 frontend-v2/src/model/initiativesData.test.ts create mode 100644 frontend-v2/src/model/initiativesData.ts diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 4a53b539..ebad2401 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' }; diff --git a/frontend-v2/src/backend/dummyProjects.ts b/frontend-v2/src/backend/dummyProjects.ts index 4e67b114..9c6b1423 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,14 @@ 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', + }), ]; // about one in eight scaled projects is planned: active since its first // PR, in the lane of its lead's team @@ -580,3 +589,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/model/initiativeBoard.test.ts b/frontend-v2/src/model/initiativeBoard.test.ts new file mode 100644 index 00000000..c05eede9 --- /dev/null +++ b/frontend-v2/src/model/initiativeBoard.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; +import type { Initiative } from '../../../shared/model/initiatives'; +import type { Project, ProjectGroup, Today } from '../../../shared/model/projects'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { + bandGloss, + grownProjects, + guessHome, + homeChoices, + homeWords, + spanWords, + trackedToday, +} from './initiativeBoard'; +import { groupItems, portfolioItems, type PortfolioItem } from './portfolio'; +import { trackedData, untrackedSlugs, 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: [], + 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: [], + }; + 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('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('in no initiative'); + }); + + 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 with an end · 1 ongoing' + ); + expect(spanWords(['b'], plans, NOW)).toBe('Ended Jun 14'); + 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. Owner: dana.' + ); + }); +}); + +/** + * 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: [], + }; + // 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 = untrackedSlugs(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(untrackedSlugs(data, filed)).toEqual(new Set()); + expect(trackedPlans(allPlans, filed).map(p => p.id)).toEqual([1, 2, 3]); + }); +}); diff --git a/frontend-v2/src/model/initiativeBoard.ts b/frontend-v2/src/model/initiativeBoard.ts new file mode 100644 index 00000000..0cf0ef4c --- /dev/null +++ b/frontend-v2/src/model/initiativeBoard.ts @@ -0,0 +1,129 @@ +import { n } from '../../../shared/format'; +import type { GrownProject } from '../../../shared/model/decide'; +import { homesOf, initiativeSpan, type Initiative } from '../../../shared/model/initiatives'; +import type { Today } from '../../../shared/model/projects'; +import type { RoadmapItem } from '../../../shared/model/roadmap'; +import type { PortfolioItem } from './portfolio'; +import { dayWords, type ProjectsData } from './projectData'; +import { byRank, untrackedNow } from './initiativesData'; + +/** + * 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 slug of the home Unsorted stands for: a project with no filing. */ +export const UNSORTED = 'unsorted'; + +/** Today without the Not tracked projects: what every count and question + * reads. PRs outside any project stay, since they're in no project. */ +export function trackedToday(today: Today, untracked: ReadonlySet): Today { + if (!untracked.size) return today; + return { + ...today, + live: today.live.filter(g => !untracked.has(g.slug)), + quiet: today.quiet.filter(g => !untracked.has(g.slug)), + }; +} + +/** 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 && untrackedNow(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", "in no + * initiative": where a project lives, mid-sentence. */ +export function homeWords(home: Pick | null | undefined): string { + if (!home) return 'in no initiative'; + 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 with an end · 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() +): string { + const span = initiativeSpan(projects, plans); + if (!span.start) return ''; + const day = (d: string) => dayWords(d, { now }); + if (span.end_kind === 'ended') return span.ended ? `Ended ${day(span.ended)}` : ''; + const parts = [ + span.end ? `${day(span.start)} to ${day(span.end)}` : `Since ${day(span.start)}`, + span.with_end ? `${span.with_end} with an end` : '', + span.ongoing ? `${span.ongoing} ongoing` : '', + ]; + return parts.filter(Boolean).join(' · '); +} + +/** What an initiative's band says on hover: its goal, its owner, and how + * much it holds ("10 projects, 7 being worked on"). */ +export function bandGloss( + initiative: Pick, + projects: number, + working: number +): string { + const held = `${n(projects, 'project')}, ${working} being worked on`; + const owner = initiative.owner ? ` Owner: ${initiative.owner}.` : ''; + const goal = initiative.goal ? `${initiative.goal.replace(/\.?$/, '.')} ` : ''; + return `${goal}${held}.${owner}`; +} diff --git a/frontend-v2/src/model/initiativesData.test.ts b/frontend-v2/src/model/initiativesData.test.ts new file mode 100644 index 00000000..dc5b91b2 --- /dev/null +++ b/frontend-v2/src/model/initiativesData.test.ts @@ -0,0 +1,167 @@ +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 move 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.' }); + 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('untrackedNow', () => { + it('takes a filing’s word over the server’s, and the server’s with none', async () => { + const { untrackedNow } = await load(); + const list = [ + initiative(1, 'a', ['tracked']), + initiative(2, 'nt', ['shelved'], { kind: 'untracked' }), + ] as Initiative[]; + expect(untrackedNow('tracked', true, list)).toBe(false); + expect(untrackedNow('shelved', false, list)).toBe(true); + expect(untrackedNow('repo-default', true, list)).toBe(true); + expect(untrackedNow('shelved', false, null)).toBe(false); + }); +}); diff --git a/frontend-v2/src/model/initiativesData.ts b/frontend-v2/src/model/initiativesData.ts new file mode 100644 index 00000000..b2731df8 --- /dev/null +++ b/frontend-v2/src/model/initiativesData.ts @@ -0,0 +1,371 @@ +import { useEffect } from 'react'; +import { isDummy } from '../backend/dummy'; +import { DUMMY_GROWN, DUMMY_INITIATIVES } from '../backend/dummyProjects'; +import { createMemoryStore } from '../storage'; +import { + 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; +} + +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 }) => { + 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); + 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); + 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; hand back a way to put them back. */ +function optimistic(change: (list: Initiative[]) => Initiative[]): () => void { + writes++; + const before = store.get().initiatives; + store.set({ ...store.get(), initiatives: change(before ?? []) }); + return () => store.set({ ...store.get(), initiatives: before }); +} + +/** 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, + }: { expect?: string | null; set_at?: number | null; baseline_prs?: number } = {} +): Promise<{ before: Home } | { error: string }> { + const takeBack = 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 } : {}), + }) + .catch((): Reply => ({ status: 0, json: {} })); + if (reply.status !== 200) { + takeBack(); + return { error: problemOf(reply, 'move the project') }; + } + void loadInitiatives(); + 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 { + const takeBack = 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) { + takeBack(); + 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); + const takeBack = 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) { + takeBack(); + 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 untrackedNow( + slug: string | null, + serverSays: boolean, + initiatives: readonly Initiative[] | null = store.get().initiatives +): boolean { + if (!slug || !initiatives) return serverSays; + const home = initiatives.find(i => i.projects.some(f => f.project === slug)); + return home ? home.kind === 'untracked' : serverSays; +} diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts index de8e2411..0e3e49cf 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, @@ -712,6 +713,73 @@ 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', + ]); + }); + + 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 637b995f..93c050b6 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -29,6 +29,7 @@ import { 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'; @@ -654,14 +655,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); } @@ -672,8 +684,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); } @@ -770,11 +786,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'; + /** * 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 @@ -808,8 +827,16 @@ 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[] = [] +): { + title: string; + items: PortfolioItem[]; + repeats: ReadonlySet; + /** by initiative: the one the group is, absent for Unsorted */ + initiative?: Initiative; +}[] { + if (by === 'initiative') return byInitiative(items, initiatives); 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]; @@ -843,6 +870,56 @@ 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[] +): { + 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; + groups.set(key, [...(groups.get(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) + ? [ + { + 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..e01fa4be 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, untrackedNow, 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 untrackedNow), and every slug a filing + * puts in Not tracked. + */ +export function untrackedSlugs( + data: Pick | null | undefined, + initiatives: readonly Initiative[] | null +): Set { + const out = new Set( + (data?.untracked ?? []).filter(slug => untrackedNow(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 = untrackedSlugs(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 { @@ -221,6 +272,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 +337,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/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..1399bc0c 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, untrackedNow, 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 untrackedNow). 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 => !untrackedNow(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/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/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/People.tsx b/frontend-v2/src/views/projects/People.tsx index 63a1760b..7e799a1c 100644 --- a/frontend-v2/src/views/projects/People.tsx +++ b/frontend-v2/src/views/projects/People.tsx @@ -1190,7 +1190,8 @@ export function People({ // before it paler, so a short range still shows its trend; they keep // their last weeks up the same way const chartDays = chartWindow(range); - const flow = useProjectsData(chartDays); + // per person: every project, Not tracked ones too + const flow = useProjectsData(chartDays, null, { all: true }); const freshFlow = flow !== undefined ? { flow, shown: chartDays, picked: range } : null; const keptFlow = useRef(freshFlow); if (freshFlow) keptFlow.current = freshFlow; diff --git a/frontend-v2/src/views/projects/roadmapHealth.tsx b/frontend-v2/src/views/projects/roadmapHealth.tsx index cd4057d7..d739465f 100644 --- a/frontend-v2/src/views/projects/roadmapHealth.tsx +++ b/frontend-v2/src/views/projects/roadmapHealth.tsx @@ -840,7 +840,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', }, }, From 5a20ee935bcf79ab8d64ac70cdefca4dac01f67a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 21:37:50 -0700 Subject: [PATCH 007/148] Ask on Decide where each project belongs Decide answers the four initiative questions in its own shape: the suggested answer filled, the rest a click away. In no initiative: File in the chooser's pick. Still not tracked: Keep not tracked, which starts its count again, or Track it. Did it reach its goal: Close as done, Close as dropped, or Keep open. Its initiative closed: Reopen it, or move the project. Each leaves a receipt with Undo, which puts back the home with its time and count, or the initiative's status with its times. A new or unfiled row carries an Initiative chooser, on the project's home so taking an answer blind moves nothing, or on a guess (where its people work most) only in Unsorted. A plan call with a new pick files first and plans second, one receipt for both; Undo removes the plan, then the home. Rows about an initiative name its owner and open its page, and an Initiative switch beside the team switch gives an owner their slice. The initiative page shows its projects, span, owner, goal, what its plans owe and a Done or dropped fold, with the question Decide asks of it. A project's page says its home with Move. Look back splits days by initiative. Group by Initiative is the default. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/app.tsx | 28 +- frontend-v2/src/lens.ts | 5 + frontend-v2/src/views/projects/Decide.tsx | 706 ++++++++++++++++-- .../src/views/projects/HomeCalls.test.tsx | 333 +++++++++ .../src/views/projects/Initiatives.test.ts | 85 +++ .../src/views/projects/Initiatives.tsx | 561 ++++++++++++++ .../src/views/projects/ProjectPage.tsx | 60 +- frontend-v2/src/views/projects/Retro.tsx | 47 +- frontend-v2/src/views/projects/parts.tsx | 14 +- 9 files changed, 1772 insertions(+), 67 deletions(-) create mode 100644 frontend-v2/src/views/projects/HomeCalls.test.tsx create mode 100644 frontend-v2/src/views/projects/Initiatives.test.ts create mode 100644 frontend-v2/src/views/projects/Initiatives.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 37e4fd0c..5e3b8dcb 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -12,7 +12,15 @@ 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, + ORIGIN_KEYS, + ZOOM_KEY, + type Lens, +} from './lens'; import type { DerivedPull } from '../../shared/model/status'; import { matchesWeightFilter } from '../../shared/model/status'; import { buildParentLookup } from './model/stack'; @@ -147,9 +155,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: p.get('group') || DEFAULT_GROUP, sort: p.get('sort') || DEFAULT_SORT, find: p.get('find') ?? '', scale: p.get('scale') === 'month' ? 'month' : p.get('scale') === 'now' ? 'now' : 'quarter', @@ -165,7 +175,9 @@ function readHash(): HashState { zoom: ZOOM_KEY.test(p.get('zoom') ?? '') ? p.get('zoom') : null, team: p.get('team') || null, origin: ORIGIN_KEYS.find(o => o === p.get('origin')) ?? null, - by: (['origin', 'author', 'repo'] as const).find(b => b === p.get('by')) ?? 'team', + 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 +200,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); @@ -440,6 +454,8 @@ 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, @@ -872,14 +888,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/lens.ts b/frontend-v2/src/lens.ts index 82a58a36..80cd290c 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -11,6 +11,11 @@ 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'; + /** 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]))$/; diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index ef3d1639..6c0ab41f 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -1,5 +1,7 @@ import { + createContext, useCallback, + useContext, useEffect, useId, useMemo, @@ -18,7 +20,9 @@ import { type ClosedIssue, type DecideReason, type DecideRow, + type GrownProject, } from '../../../../shared/model/decide'; +import type { Initiative } from '../../../../shared/model/initiatives'; import { firstOpenDay, projectSlugs, type Today } from '../../../../shared/model/projects'; import { decideTurn, type DecideRotation } from '../../../../shared/model/settings'; import { @@ -63,6 +67,15 @@ 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, + useInitiativesValue, + type Home, +} from '../../model/initiativesData'; +import { guessHome, homeChoices, UNSORTED } from '../../model/initiativeBoard'; +import { homesOf } from '../../../../shared/model/initiatives'; import { ALL_ISSUES_CLOSED, andList, @@ -104,15 +117,23 @@ import { import { PLAN_STATUS_WORD, vouchWords, when } from './roadmapHealth'; /** The decisions owed now, worst first, for this view, the tab's label and - * the Overview's tile. */ + * 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, @@ -156,6 +177,13 @@ const SECTIONS: { ONGOING_HELP, ], }, + { + 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 move the project to another.', + ], + }, { kinds: ['issue_closed'], title: 'Issue closed, plan still going', @@ -196,6 +224,13 @@ const SECTIONS: { 'Every issue in the project is closed, and the plan hasn’t changed since the last one closed.', ], }, + { + kinds: ['goal_reached'], + title: 'Did it reach its goal?', + 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', @@ -218,6 +253,20 @@ const SECTIONS: { ONGOING_HELP, ], }, + { + kinds: ['unfiled'], + title: 'In no initiative', + more: [ + `In no initiative 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: ['untracked_grew'], + title: 'Still not tracked?', + more: [ + 'Set 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.', + ], + }, ]; const sectionOf = (row: DecideRow) => @@ -269,10 +318,41 @@ export function sharedAsk( return keys.size === 1 && questions.size === 1 ? [...questions][0] : null; } -const rowKey = (row: DecideRow) => `${row.slug ?? ''}:${row.item?.id ?? ''}`; +/** 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; +}; +const rowKey = (row: DecideRow) => + !row.slug && !row.item && askedInitiative(row) != null + ? `initiative:${askedInitiative(row)}` + : `${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'; + row.item?.name ?? + project?.name ?? + row.slug ?? + initiativeById(askedInitiative(row))?.name ?? + 'A plan'; +/** An initiative by id, as the board has them now. */ +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. */ +const HOME_KINDS: readonly DecideReason['kind'][] = [ + 'unfiled', + '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([]); const closedAs = (as: 'done' | 'dropped') => (as === 'done' ? 'completed' : 'not planned'); const upperFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); @@ -285,7 +365,36 @@ export type Call = | { kind: 'park' } | { kind: 'done' } | { kind: 'drop' } - | { kind: 'ongoing' }; + | { kind: 'ongoing' } + /** file the project in the initiative with slug `to`, named `name` */ + | { kind: 'file'; to: string; name: string } + /** keep it Not tracked, starting its count again */ + | { kind: 'keep_untracked' } + /** 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. */ +type PlanCallKind = Extract; +/** The calls that write a filing or an initiative's status. */ +type HomeCall = Extract< + Call, + { kind: 'file' | 'keep_untracked' | 'close' | 'keep_open' | 'reopen' } +>; +const isHomeCall = (call: Call): call is HomeCall => + ['file', 'keep_untracked', '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; +} /** The question that asks a plan to end later: its answers are only ends * after the one it has. */ @@ -323,17 +432,15 @@ export function askOf(reason: DecideReason): { return reason.done ? { question: 'Is it done?', call: 'done' } : { question: 'Drop it?', call: 'drop' }; - // the initiative calls file or close, not plan; their answers come - // with the initiatives board work, and until then no caller passes - // decideQueue the initiatives that raise them + // where it belongs: answered with a filing or the initiative's status case 'unfiled': - return { question: 'Which initiative is it part of?', call: 'commit' }; + return { question: 'Which initiative is it in?', call: 'file' }; case 'untracked_grew': - return { question: 'Still not tracked?', call: 'commit' }; + return { question: 'Still not tracked?', call: 'keep_untracked' }; case 'goal_reached': - return { question: 'Did it reach its goal?', call: 'done' }; + return { question: 'Did it reach its goal?', call: 'close' }; case 'reopened_in_closed': - return { question: 'Reopen its initiative?', call: 'commit' }; + return { question: 'Reopen its initiative?', call: 'reopen' }; } } @@ -375,7 +482,9 @@ export function answerOf( today: string ): Call | null { if (!row.reasons.length) return null; - const asked = askOf(primaryOf(row)).call; + // a filing or an initiative's status: its own strip suggests it (HomeStrip) + if (isHomeReason(primaryOf(row))) return null; + const asked = askOf(primaryOf(row)).call as Exclude; if (asked !== 'commit') return { kind: asked }; const plan = row.item; const end = endsFor(row, project, today).find( @@ -390,6 +499,11 @@ const VERBS: Record = { done: ['Mark', 'Marked'], drop: ['Drop', 'Dropped'], ongoing: ['Mark', 'Marked'], + file: ['File', 'Filed'], + keep_untracked: ['Keep', 'Kept'], + close: ['Close', 'Closed'], + keep_open: ['Keep', 'Kept'], + reopen: ['Reopen', 'Reopened'], }; /** @@ -449,6 +563,16 @@ export function answerWords(call: Call, plan: RoadmapItem | null): string { return now === 'dropped' ? 'Keep it dropped' : 'Drop'; case 'ongoing': return 'Mark ongoing'; + case 'file': + return call.to === 'not-tracked' ? 'Set not tracked' : `File in ${call.name}`; + case 'keep_untracked': + return 'Keep not tracked'; + case 'close': + return call.as === 'done' ? 'Close as done' : 'Close as dropped'; + case 'keep_open': + return 'Keep open'; + case 'reopen': + return 'Reopen it'; } } @@ -524,13 +648,17 @@ function reasonFacts(reason: DecideReason, item: RoadmapItem | null, bare = fals : `All its issues are closed (${tally})${open}`; } case 'unfiled': - return 'It’s in no initiative'; + return bare ? 'It isn’t filed anywhere yet' : 'It’s in no initiative'; case 'untracked_grew': - return `${n(reason.baseline + reason.since, 'PR')}, ${reason.since} since it was set not tracked`; + return `${n(reason.baseline + reason.since, 'PR')}, ${ + reason.since + } since it was set not tracked`; case 'goal_reached': - return 'Every project in it is finished or quiet'; - case 'reopened_in_closed': - return `Its initiative is ${reason.as}, but it’s being worked on again`; + 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`; + } } } @@ -589,7 +717,7 @@ const STATUS = { park: 'parked', done: 'done', drop: 'dropped' } as const; * hard, so Decide asks about it once it passes. */ export function writeFor( - call: Exclude, + call: PlanCallKind, row: DecideRow, project: PortfolioItem | undefined, today: string @@ -644,11 +772,32 @@ export function callWords( call: Call, row: DecideRow, project: Pick | undefined, - today: string + today: string, + filed?: Pick ): string { + // a plan call that files the project too says where, once + const home = filed && (filed.to === 'not-tracked' ? 'Not tracked' : `in ${filed.name}`); + 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': - return `Promised to finish by ${call.through}. Decide asks again if it runs past that.`; + return `Promised to finish by ${call.through}${ + home ? `, ${home}` : '' + }. Decide asks again if it runs past that.`; + case 'file': + return call.to === 'not-tracked' + ? 'Set not tracked, so it counts nowhere. Decide asks again if it grows.' + : `Filed in ${call.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': @@ -686,6 +835,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 @@ -829,9 +982,55 @@ 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; before: Home }; + 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 === 'file' || call.kind === 'keep_untracked') { + if (!slug) return { why: '' }; + const to = call.kind === 'file' ? call.to : 'not-tracked'; + const r = await fileProject(slug, to, { expect: homeSlugOf(slug) }); + return 'error' in r + ? { why: whyNot(r.error) } + : { home: { project: slug, 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 @@ -855,7 +1054,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; @@ -864,19 +1065,37 @@ 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, }); 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, 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, 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); @@ -887,22 +1106,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(); @@ -916,7 +1137,9 @@ function undoCall(key: string, batch?: { after: Promise }): Promise }): Promise - Didn’t save.{shown.why} + + {shown.filed && shown.filedBefore + ? `${upperFirst( + shown.filed.to === 'not-tracked' + ? 'set 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 @@ -1124,7 +1391,7 @@ export function DecideCall({ 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} + /> + ) : ( + <> + + act(() => makeCall(row, call, project, undefined, chooser.filed))() + } + onCancel={row.reasons.length || opened ? undefined : act(() => setOpen(false))} + full={opened} + quiet={compact} + /> + {chooser.shown && } + + )} + + ); +} + +/** + * The Initiative 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. + */ +function useHomeChooser(row: DecideRow, { away = false }: { away?: boolean } = {}) { + const { initiatives } = useInitiativesValue(); + const items = useContext(AllItemsContext); + const asks = row.reasons.some(r => r.kind === 'new' || r.kind === 'unfiled'); + const list = initiatives ?? []; + const current = row.slug ? homesOf(list).get(row.slug)?.initiative ?? null : null; + // `away`: moving it out of the home it has (Track it, Move the project), + // so it starts on the guess + const guess = + (!current || away) && row.slug && list.length ? guessHome(row.slug, items, list) : null; + const [pick, setPick] = useState(null); + const choices = homeChoices(list); + const chosen = pick ?? (away ? guess?.slug : current?.slug ?? guess?.slug) ?? ''; + const to = choices.find(i => i.slug === chosen); + return { + shown: asks && !!row.slug && choices.length > 0, + choices, + chosen, + current, + guessed: (!current || away) && !!guess && chosen === guess.slug, + setPick, + filed: + to && to.slug !== current?.slug + ? ({ to: to.slug, name: to.name, from: current?.slug ?? null } as Filed) + : undefined, + }; +} + +/** The chooser's line: "Initiative [Release safety]", with "guessed" while + * it shows a guess. A select, so the keyboard and a screen reader get the + * platform's own list. */ +function HomeChooser({ + name, + chooser, + label = 'Initiative', +}: { + name: string; + chooser: ReturnType; + label?: string; +}) { + const id = useId(); + return ( +
+ + + {chooser.guessed && ( + + guessed + + )} +
+ ); +} + +/** + * The answers to a row that asks where a project belongs, or about an + * initiative, in the same shape as CallStrip: the suggested answer filled, + * then Other answers. In no initiative: File in the chooser's pick. Still + * not tracked: Keep not tracked, or Track it, which opens the chooser. Did + * it reach its goal: Close as done, or Close as dropped or Keep open. Its + * initiative closed: Reopen it, or move the project with the chooser. + */ +function HomeStrip({ + row, + name, + describedBy, + onCall, +}: { + row: DecideRow; + name: string; + describedBy?: string; + onCall: (call: Call) => void; +}) { + const [more, setMore] = useState(false); + const primary = primaryOf(row); + const chooser = useHomeChooser(row, { away: primary.kind !== 'unfiled' }); + const initiative = initiativeById(askedInitiative(row)); + const fileTo = (): Call | null => { + const to = chooser.choices.find(i => i.slug === chooser.chosen); + return to ? { kind: 'file', to: to.slug, name: to.name } : null; + }; + const filing = fileTo(); + const options: { call: Call | null; words: string }[] = + primary.kind === 'unfiled' + ? [{ call: filing, words: filing ? answerWords(filing, null) : 'File it' }] + : primary.kind === 'untracked_grew' + ? [ + { call: { kind: 'keep_untracked' }, words: 'Keep not tracked' }, + ...(more && filing?.kind === 'file' && filing.to !== 'not-tracked' + ? [{ call: filing, words: answerWords(filing, null) }] + : []), + ] + : primary.kind === 'goal_reached' + ? [ + { call: { kind: 'close', as: 'done' }, words: 'Close as done' }, + ...(more + ? [ + { call: { kind: 'close', as: 'dropped' } as Call, words: 'Close as dropped' }, + { call: { kind: 'keep_open' } as Call, words: 'Keep open' }, + ] + : []), + ] + : [ + { + call: { kind: 'reopen' }, + words: initiative ? `Reopen ${initiative.name}` : 'Reopen it', + }, + ...(more && filing?.kind === 'file' && filing.to !== initiative?.slug + ? [{ call: filing, words: `Move it to ${filing.name}` }] + : []), + ]; + const moreWords = + primary.kind === 'untracked_grew' + ? 'Track it' + : primary.kind === 'reopened_in_closed' + ? 'Move the project' + : 'Other answers'; + // Track it and Move the project open the chooser; it starts on a guess, + // never on the home being left + const chooses = + primary.kind === 'unfiled' || + (more && (primary.kind === 'untracked_grew' || primary.kind === 'reopened_in_closed')); + const pickable = + primary.kind === 'unfiled' + ? chooser.choices + : chooser.choices.filter( + i => i.kind !== 'untracked' && i.slug !== (initiative?.slug ?? null) + ); + return ( +
+
+ {options.map((o, k) => ( + + ))} + {primary.kind !== 'unfiled' && !more && ( + + )} +
+ {chooses && pickable.length > 0 && ( + i.slug === chooser.chosen) ? chooser.chosen : '', + }} + label={primary.kind === 'unfiled' ? 'Initiative' : 'Move it to'} /> )}
@@ -1441,6 +1933,7 @@ function useBackToRow(ready: boolean) { function DecideRowView({ row, decided, + owner, project, team, nav, @@ -1453,6 +1946,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; @@ -1475,8 +1971,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)); @@ -1501,10 +2003,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} + + )} number; + value: string | null; + onChange: (slug: string | null) => void; +}) { + const id = useId(); + const counted = (label: string, count: number) => (count ? `${label} · ${count}` : label); + const offered = [ + ...homeChoices(initiatives).map(i => [i.slug, i.name] as const), + [UNSORTED, 'In no initiative'] as const, + ].filter(([slug]) => owedIn(slug) > 0 || slug === value); + if (!offered.length) return null; + return ( +
+ + +
+ ); +} + export function teamOrder(configured: readonly string[], named: readonly (string | null)[]) { const others = named.filter((t): t is string => !!t && !configured.includes(t)); return [...configured, ...[...new Set(others)].sort()]; @@ -2388,6 +2950,9 @@ export function teamOrder(configured: readonly string[], named: readonly (string export function Decide({ today, items, + allItems = items, + initiatives = null, + grown = [], closed, teamOf, teamMembers, @@ -2405,6 +2970,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; @@ -2439,21 +3011,41 @@ 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 + ); + const inTeam = (row: DecideRow) => + (!nav.team || (teamOfRow(row) ?? '(none)') === nav.team) && + (!nav.within || (initiativeOfRow(row)?.slug ?? UNSORTED) === 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 withinName = nav.within + ? nav.within === UNSORTED + ? 'work in no initiative' + : (initiatives ?? []).find(i => i.slug === nav.within)?.name ?? nav.within + : null; const whose = !nav.team - ? '' + ? withinName + ? ` for ${withinName}` + : '' : nav.team === '(none)' ? ' for work with no team' : ` for ${nav.team}`; @@ -2571,6 +3163,17 @@ export function Decide({ {copied ?? ''} + {(initiatives ?? []).length > 0 && ( + + kept.owed.filter(row => (initiativeOfRow(row)?.slug ?? UNSORTED) === slug) + .length + } + value={nav.within} + onChange={within => navigate({ within })} + /> + )} {teams.length > 0 && (
= {} +): 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']), + initiative(9, 'not-tracked', '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, +}; + +/** The server, by route: each write answers as the real one would. */ +const calls: { method: string; path: string; body: unknown }[] = []; +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') { + const before = + body.project === 'ops' + ? { initiative: 'not-tracked', set_at: 50, baseline_prs: 2 } + : body.project === 'filed' + ? { initiative: 'site-speed', 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/')) { + return reply({ initiative: { ...INITIATIVES[3], ...body } }); + } + if (path === '/roadmap' && method === 'POST') return reply({ item: created }, 201); + if (path === '/roadmap' && method === 'GET') return reply({ items: [] }); + if (path.startsWith('/roadmap/') && method === 'DELETE') return reply({ ok: true }); + return reply({}, 404); + }) + ); + await loadInitiatives(); +}); + +let host: HTMLDivElement | null = null; +afterEach(() => { + host?.remove(); + host = null; + 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']), +]; + +function render(row: DecideRow): HTMLDivElement { + host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + + + + ) + ); + 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() + ); +const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); + +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'); + expect(buttons(el)).toEqual(['Close as doneSuggested', 'Close as dropped', 'Keep open']); + }); + + 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 }, + }); + 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', 'Move the project']); + click(el, 'Move the project'); + // the chooser leaves out the closed home and Not tracked + const options = [...el.querySelectorAll('option')].map(o => o.textContent); + expect(options).not.toContain('Catalog'); + expect(options).not.toContain('Not tracked'); + }); +}); + +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: 'not-tracked', + expect: 'not-tracked', + }); + // 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: 'not-tracked', + set_at: 50, + baseline_prs: 2, + }); + }); + + it('tracks it in the chooser’s pick from Track it', () => { + const el = render(grew); + click(el, 'Track it'); + expect(el.querySelector('select')).not.toBeNull(); + }); +}); + +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^="Initiative for"]'); + + it('starts on the home a filed project has, so taking the answer blind moves nothing', async () => { + const el = render(fresh('filed')); + expect(select(el)?.value).toBe('site-speed'); + expect(el.textContent).not.toContain('guessed'); + 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, + }); + }); +}); + +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' + ); + expect(answerWords({ kind: 'file', to: 'not-tracked', name: 'Not tracked' }, null)).toBe( + 'Set not tracked' + ); + 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', { + to: 'fixbot', + 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); + }); +}); 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..3a2a1586 --- /dev/null +++ b/frontend-v2/src/views/projects/Initiatives.test.ts @@ -0,0 +1,85 @@ +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 } 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(''); + }); +}); + +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..533cbe80 --- /dev/null +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -0,0 +1,561 @@ +import { 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, + homesOf, + initiativeSpan, + type Initiative, + type InitiativeFields, +} from '../../../../shared/model/initiatives'; +import { + healthStanding, + isUnderWay, + planFor, + type RoadmapItem, +} from '../../../../shared/model/roadmap'; +import { EmptyState, FactLink, QuietButton, textInputClass } from '../../components/bits'; +import { Fold, Rows } from '../../components/Lane'; +import { Icon } from '../../components/Icon'; +import { fileProject, updateInitiative, type Home } from '../../model/initiativesData'; +import { homeChoices } from '../../model/initiativeBoard'; +import { dayWords } from '../../model/projectData'; +import { stageWord, type PortfolioItem } from '../../model/portfolio'; +import { andList } from '../../model/words'; +import { askedInitiative, askOf, DecideCall, reasonParts } from './Decide'; +import { openPlan, type Navigate, type ProjectsNav } from './parts'; +import { PLAN_STATUS_WORD, planWords } from './roadmapHealth'; + +/** "Release safety", "Keep the lights on", "Not tracked", "No initiative": + * a home by name, standing alone. */ +export const homeName = (home: Pick | null | undefined) => + home ? home.name : 'No initiative'; + +/** A receipt for a move of home: what it did, with Undo, or why not. */ +interface MoveReceipt { + words: string; + failed?: boolean; + undo?: () => void; +} + +/** + * Where a project lives, in its page's head: "in Release safety · Move", + * "Not tracked · Move", or "In no initiative · Move". The initiative's name + * opens its page. Move opens a chooser in place (never a popover): the homes + * in rank order, Not tracked last, and No initiative; Move files it there + * and leaves a receipt with Undo, which puts back the home it had with its + * time and count. + */ +export function HomeLine({ + slug, + initiatives, + navigate, + onSaid, +}: { + slug: string; + initiatives: readonly Initiative[]; + navigate: Navigate; + /** say a change aloud, for a screen reader */ + onSaid?: (words: string) => void; +}) { + const home = homesOf(initiatives).get(slug)?.initiative ?? null; + const [moving, setMoving] = useState(false); + const [pick, setPick] = useState(home?.slug ?? ''); + const [receipt, setReceipt] = useState(null); + const selectId = useId(); + const moveId = useId(); + const doneRef = useRef(null); + useEffect(() => { + if (receipt) doneRef.current?.focus(); + }, [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; + const toName = homeName(choices.find(i => i.slug === to)); + const saved = await fileProject(slug, to, { expect: home?.slug ?? null }); + if ('error' in saved) return say({ words: saved.error, failed: true }); + const before: Home = saved.before; + say({ + words: `Moved to ${toName}`, + undo: async () => { + const back = await fileProject(slug, before.initiative, { + set_at: before.set_at, + baseline_prs: before.baseline_prs, + }); + if ('error' in back) say({ words: back.error, failed: true }); + else { + setReceipt(null); + setPick(before.initiative ?? ''); + onSaid?.(`Put it back in ${homeName(home)}`); + requestAnimationFrame(() => document.getElementById(moveId)?.focus()); + } + }, + }); + }; + return ( + + {home ? ( + home.kind === 'untracked' ? ( + Not tracked + ) : ( + + in{' '} + navigate({ initiative: home.slug, project: null })} + title={`Open ${home.name}: its projects, owner and goal`} + > + {home.name} + + + ) + ) : ( + In no initiative + )} + {!moving && !receipt && ( + { + setPick(home?.slug ?? ''); + setMoving(true); + }} + aria-label="Move it to another initiative" + > + Move + + )} + {moving && ( + + + + void move()}>Move + setMoving(false)}>Cancel + + )} + {receipt && ( + + {!receipt.failed && ( + + )} + + {receipt.words}. + + {receipt.undo ? ( + Undo + ) : ( + setReceipt(null)}>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('; '); +} + +/** "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` : ''}`; +} + +/** "soft, from its projects", "ongoing", "ended Sep 30": how firm its end + * is, derived from its plans, never stored. */ +function endWords(span: ReturnType): string { + switch (span.end_kind) { + case 'soft': + return 'soft, from its projects'; + case 'ongoing': + return 'ongoing, no plan in it has an end'; + case 'ended': + return '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 ?? ''); + 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 !== (initiative 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; + onDone({ + words: `Saved ${andList( + Object.keys(changed).map(k => (k === 'owner' ? 'its owner' : `its ${k}`)) + )}`, + 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(() => onDone(null)), + }); + }; + 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 question when it asks one, 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 a "Done or dropped" fold for its finished, dropped and + * parked work. + */ +export function InitiativePage({ + slug, + initiatives, + items, + plans, + calls, + 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[]; + nav: ProjectsNav; + navigate: Navigate; + onPerson: (login: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [receipt, setReceipt] = useState(null); + 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

Loading the initiative…

; + if (!initiative) { + return ( + + ); + } + 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])); + // 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) + ); + const finished = (r: typeof rows[number]) => + r.plan ? !isUnderWay(r.plan.status) : r.item ? r.item.stage === 'closed' : false; + const going = rows.filter(r => !finished(r)); + const done = rows.filter(finished); + const asked = calls.filter(r => askedInitiative(r) === initiative.id && !r.slug); + const kindWord = + initiative.kind === 'upkeep' + ? 'Upkeep' + : initiative.kind === 'untracked' + ? 'Not tracked' + : null; + 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')} + )} +
+ ); + }; + const facts: [string, string][] = [ + ['Span', spanLine(span, live.length)], + ['Ends', endWords(span)], + ['Health', healthWords(live)], + ['People', leadWords(mine) || 'No lead named on its plans'], + ]; + return ( + <> +
+
+

{initiative.name}

+ {kindWord && {kindWord}} + {initiative.status !== 'open' && ( + Closed as {initiative.status} + )} + + {initiative.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); + }} + /> + )} + {receipt && ( +

+ + {receipt.words}. + {receipt.undo && Undo} +

+ )} + {asked.map(r => { + const parts = r.reasons.length ? reasonParts(r.reasons[0], null) : null; + return ( +
x.kind).join()} className="mt-3 text-[13px]"> + {parts && ( +

+ {parts.facts}{' '} + {askOf(r.reasons[0]).question} +

+ )} + +
+ ); + })} +
+
+ {facts.map(([term, words]) => ( +
+
{term}
+
{words}
+
+ ))} +
+
+

+ Projects in it +

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

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

+ )} +
+ + ); +} diff --git a/frontend-v2/src/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx index edc23c88..b9386e98 100644 --- a/frontend-v2/src/views/projects/ProjectPage.tsx +++ b/frontend-v2/src/views/projects/ProjectPage.tsx @@ -57,7 +57,17 @@ import { NO_UPDATE_YET, UPDATE_DUE, } from '../../model/words'; -import { answerWords, planRow, reasonParts, usePlanCalls, type Call } from './Decide'; +import { + answerWords, + DecideCall, + isHomeReason, + planRow, + reasonParts, + usePlanCalls, + type Call, +} from './Decide'; +import { HomeLine } from './Initiatives'; +import type { Initiative } from '../../../../shared/model/initiatives'; import { BacklogSection } from './BacklogSection'; import { flagText, @@ -469,6 +479,8 @@ export function ProjectPage({ navigate, item, plans, + initiatives = null, + untracked = false, ongoingSaved, opts, onPerson, @@ -491,6 +503,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 */ @@ -652,9 +668,14 @@ 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 primaryOf = (row: DecideRow) => + row.reasons.reduce((a, r) => (RANK[r.kind] < RANK[a.kind] ? r : a)); + 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 asked = planAsks.map(row => { const primary = row.reasons.reduce((a, r) => (RANK[r.kind] < RANK[a.kind] ? r : a)); const sentences = [primary, ...row.reasons.filter(r => r !== primary)].map(r => // the page's Status already says there's no plan @@ -662,7 +683,7 @@ export function ProjectPage({ ); 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 +698,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'; @@ -820,6 +841,37 @@ export function ProjectPage({ )}
+ {initiatives && initiatives.length > 0 && ( +
+ + {/* 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: it counts in no tile, load or question + {plans && planFor(slug, plans) ? ', and its plan owes no update' : ''}. +

+ )} +
+ )}
diff --git a/frontend-v2/src/views/projects/Retro.tsx b/frontend-v2/src/views/projects/Retro.tsx index 6b8da8ca..2d0dc41f 100644 --- a/frontend-v2/src/views/projects/Retro.tsx +++ b/frontend-v2/src/views/projects/Retro.tsx @@ -10,6 +10,8 @@ import { import { ArrowDown, ArrowUp, ChevronRight } from 'lucide-react'; import { n, pullKey, shortRepo } from '../../../../shared/format'; import { MISC_SLUG } from '../../../../shared/model/projects'; +import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; +import { byRank } from '../../model/initiativesData'; import { ORIGIN_WORD, planFor, type RoadmapItem } from '../../../../shared/model/roadmap'; import type { DerivedPull } from '../../../../shared/model/status'; import type { PullData } from '../../../../shared/types'; @@ -87,6 +89,7 @@ type Split = ProjectsNav['by']; const SPLIT_OPTIONS: [Split, string][] = [ ['team', 'Team'], + ['initiative', 'Initiative'], ['origin', 'Where it came from'], ['author', 'Whose PR'], ['repo', 'Repo'], @@ -94,6 +97,7 @@ const SPLIT_OPTIONS: [Split, string][] = [ /** what the last list splits the days by, in words */ const SPLIT_WORDS: Record = { 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', @@ -110,6 +114,9 @@ const UNPLANNED = 'no-plan'; const UNSAID = 'unsaid'; /** the team key for people on no developer team, as the URL spells it */ const NO_TEAM = '(none)'; +/** the initiative split's group for days on no project, a one-off, or a + * project in no initiative */ +const NO_INITIATIVE = '(none)'; const AUTHOR_WORDS: Record = { own: 'Their own PRs', @@ -210,7 +217,14 @@ 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') { + if (key === NO_INITIATIVE) return 'No initiative'; + return ( + initiatives.find(i => i.slug === key)?.name ?? + (key === 'not-tracked' ? 'Not tracked' : key) + ); + } if (split === 'origin') { if (key === NOT_FILED) return NOT_IN_A_PROJECT; if (key === UNPLANNED) return NO_PLAN; @@ -944,6 +958,8 @@ export function Retro({ range, plans, items, + initiatives = null, + untracked = new Set(), teams, teamOf, nameOf, @@ -956,6 +972,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 +1229,18 @@ 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, or none + const homes = homesOf(initiatives ?? []); + const initiativeKey = (r: RetroRow) => { + const project = r.pr.project; + if (!project || project === MISC_SLUG) return NO_INITIATIVE; + const home = homes.get(project)?.initiative; + if (home) return home.slug; + return untracked.has(project) ? 'not-tracked' : NO_INITIATIVE; + }; 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 +1256,17 @@ 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, Not tracked among them, none last + const order = byRank(initiatives ?? []).map(i => i.slug); + const rank = (key: string) => + key === NO_INITIATIVE + ? order.length + 1 + : order.indexOf(key) < 0 + ? order.length + : order.indexOf(key); + 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 +1368,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 +1789,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 ( { 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 +115,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 +165,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, From aaba84de706f3d6c258de4fc8828a071f065ca98 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 21:37:50 -0700 Subject: [PATCH 008/148] Group the Overview by initiative The project list groups by initiative, in their rank, each band saying its owner and how many of its plans have an end or run on; Unsorted comes last only when it holds a project, and the find box takes "initiative:". Not tracked projects close the list in a ledger that rests closed and counts in no tab, while the tiles, charts and tab counts read only tracked projects. Yours and Who is on what read every project, so your Not tracked work stays yours and per-person counts agree with People. My work asks Decide's questions of tracked work only, and keeps every project of yours on its list. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/MyWork.tsx | 21 +++-- frontend-v2/src/views/projects/Overview.tsx | 47 ++++++++-- frontend-v2/src/views/projects/Portfolio.tsx | 97 +++++++++++++++++--- 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index e34da65d..75a35835 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -16,12 +16,15 @@ import { import { DEFAULT_RANGE, ongoingSlugs, + untrackedSlugs, resolveRange, teamLookup, useProjectsData, type Range, } from '../model/projectData'; -import { useRoadmap } from '../model/roadmapData'; +import { trackedPlans, useRoadmap } from '../model/roadmapData'; +import { useInitiatives } from '../model/initiativesData'; +import { grownProjects, trackedToday } from '../model/initiativeBoard'; import { useWorkData } from '../model/workData'; import { LAST_14_DAYS } from '../model/words'; import { claimFor } from '../model/reviewers'; @@ -162,12 +165,16 @@ function useYourProjects( allClosed: PullData[] ): YourProject[] | null { // the window the Projects tab opens on, so one fetch serves both - const data = useProjectsData(resolveRange(DEFAULT_RANGE) as Range); - const { items: plans } = useRoadmap(); + // two reads: tracked for Decide's calls, the full set for your list, so + // a project of yours that's Not tracked stays yours and asks nothing + const data = useProjectsData(resolveRange(DEFAULT_RANGE) as Range, null, { all: true }); + const { items: plans } = useRoadmap({ all: true }); + const { initiatives } = useInitiatives(); const work = useWorkData(plans); return useMemo(() => { if (!data || !plans) return null; const now = Date.now(); + const untracked = untrackedSlugs(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/Overview.tsx b/frontend-v2/src/views/projects/Overview.tsx index 32e0f2ec..b18042b3 100644 --- a/frontend-v2/src/views/projects/Overview.tsx +++ b/frontend-v2/src/views/projects/Overview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, type ReactNode } from 'react'; import { n } from '../../../../shared/format'; import { STALL_DAYS, type DecideRow } from '../../../../shared/model/decide'; import type { Today } from '../../../../shared/model/projects'; +import type { Initiative } from '../../../../shared/model/initiatives'; import { FactLink, LoadFailed } from '../../components/bits'; import { Fold, FoldRows, laneShown, RestGroup, SubDoor } from '../../components/Lane'; import type { RowOptions } from '../../components/Row'; @@ -46,6 +47,7 @@ import { AS_OPENED, Portfolio, Yours, type Workers } from './Portfolio'; import { useWhoIsOnWhat, type WhoRow } from './WhoIsOnWhat'; const NO_ROWS: DecideRow[] = []; +const NO_SLUGS: ReadonlySet = new Set(); /** Scroll a section of the page into view, under the sticky header. */ const scrollTo = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); @@ -465,9 +467,13 @@ function AgeCharts({ */ export function Overview({ today, + fullToday = today, data, prefix, items, + allItems = items, + initiatives = null, + untracked = NO_SLUGS, teamOf, nameOf, nav, @@ -477,10 +483,19 @@ export function Overview({ me, onPerson, }: { + /** the tracked Today: every count on the page */ today: Today; + /** Today with Not tracked projects too: who is on what, per person */ + fullToday?: Today; data: ProjectsData | null | undefined; prefix: string; + /** the tracked projects: the tiles, the list and its charts */ items: PortfolioItem[]; + /** every project: yours, and the Not tracked group */ + allItems?: PortfolioItem[]; + initiatives?: Initiative[] | null; + /** the Not tracked slugs */ + untracked?: ReadonlySet; teamOf: (login: string) => string | null; nameOf: (slug: string) => string; nav: ProjectsNav; @@ -490,13 +505,18 @@ export function Overview({ me: string; onPerson: (login: string) => void; }) { - const { rows, who, line, over, tooMany } = useWhoIsOnWhat(data?.teams, today, teamOf); + // per person, so it counts every project they're on, tracked or not + const { rows, who, line, over, tooMany } = useWhoIsOnWhat(data?.teams, fullToday, teamOf); // who worked on each lately, and the calls Decide asks of each, which - // give every row's Plan cell its words and its one amber mark - const listed = useMemo( - () => withCalls(rows ? withWorkers(items, peopleByProject(rows)) : items, decisions), - [items, rows, decisions] + // give every row's Plan cell its words and its one amber mark; from the + // full set, so your Not tracked projects stay yours, and the list, tiles + // and charts take the tracked ones from it + const all = useMemo( + () => withCalls(rows ? withWorkers(allItems, peopleByProject(rows)) : allItems, decisions), + [allItems, rows, decisions] ); + const listed = useMemo(() => all.filter(i => !untracked.has(i.slug)), [all, untracked]); + const notTracked = useMemo(() => all.filter(i => untracked.has(i.slug)), [all, untracked]); // the calls owed, and the ones made in the list's rows since it opened const calls = useCallsMadeHere(decisions ?? NO_ROWS); // a row answered in place keeps its place, its tab and its receipt, as a @@ -505,13 +525,13 @@ export function Overview({ const answered = new Set([...calls.settled.values()].map(m => m.row.slug)); const stood = useRef(new Map()); useEffect(() => { - for (const item of listed) if (!answered.has(item.slug)) stood.current.set(item.slug, item); + for (const item of all) if (!answered.has(item.slug)) stood.current.set(item.slug, item); }); const placeOf = (item: PortfolioItem) => (answered.has(item.slug) && stood.current.get(item.slug)) || item; - // your projects, first, as the list places them + // your projects, first, as the list places them, Not tracked ones too const yours = new Set( - listed + all .map(placeOf) .filter(i => isYours(i, me)) .map(i => i.slug) @@ -556,7 +576,7 @@ export function Overview({ onRetry={retryRetroData} /> )} - + - + {outside + today.doubleLabeled.length > 0 && ( ; + /** 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, + 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 +1108,39 @@ 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; + const groups = groupItems(shown, by, nameOf, teams, initiatives); + // 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, + span.with_end ? `${span.with_end} with an end` : '', + span.ongoing ? `${span.ongoing} ongoing` : '', + initiative.status === 'open' ? '' : `closed as ${initiative.status}`, + ].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 +1178,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 })} /> @@ -1243,10 +1298,13 @@ export function Portfolio( key={g.title} count={g.items.length} label={g.title} - gloss={bandGloss(nav.group, g.title)} + gloss={bandGloss(by, g.title, g.initiative)} + detail={bandDetail(g.initiative)} // logins keep their case - caps={nav.group !== 'lead'} - id={`portfolio:${nav.group}:${g.title.replace(/\s+/g, '-')}`} + caps={by !== 'lead'} + id={`portfolio:${by}:${ + g.initiative?.slug ?? g.title.replace(/\s+/g, '-') + }`} defaultOpen > 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 ? ( <> From b8b176fa2c95d3c98f5e51314db06eb02cd32c03 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 21:37:50 -0700 Subject: [PATCH 009/148] Lay the Roadmap out in initiative lanes Grouped by initiative, the Roadmap opens on one band per initiative in its rank: the rank, the name, the rows it holds and one ink phrase of its dates and counts, read off its projects' plans. A lane holds its plans under way, its live projects with no plan, and any plan Decide asks about, and rests closed unless it holds a call, so the bands read as a table of contents. Its grip sits in the gutter, outside the band's click target: drag it onto another band, or use the arrow keys, to rank it. Dropping a plan on another initiative files its project there and leaves its priority alone, with Undo; Move in the plan's details is the keyboard's way. A link that opens a plan opens its lane first. Not tracked plans and projects close every grouping in their own lane, with no rank and no drag; the load chart, the capacity line and Now, next, later count tracked plans only. Now, next, later splits by initiative too. The Projects tab now builds the tracked and the full sources once and hands each view the set it reads. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/components/Lane.tsx | 33 +- frontend-v2/src/views/Projects.tsx | 135 +++-- .../src/views/projects/NowNextLater.tsx | 34 +- frontend-v2/src/views/projects/Roadmap.tsx | 469 ++++++++++++++++-- 4 files changed, 578 insertions(+), 93 deletions(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index eed26c64..1e511aa1 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -370,6 +370,8 @@ export function Fold({ detail, id, defaultOpen = false, + gutter, + keepEmpty = false, children, }: { count: number; @@ -391,12 +393,18 @@ 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; 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 @@ -417,15 +425,17 @@ export function Fold({ const labelInner = ( {label} - {showCount && · {count}} + {showCount && count > 0 && · {count}} ); - return ( + const fold = (
@@ -433,9 +443,9 @@ 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-2 bg-muted/40 py-[6px] pr-3.5 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'}`} > {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/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index aa5f1077..136ed032 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -15,18 +15,23 @@ import { refreshProjectsData, resolveRange, teamLookup, + trackedData, + untrackedSlugs, 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, Decide, decideRows, useKeptCalls } from './projects/Decide'; +import { InitiativePage } from './projects/Initiatives'; import { Overview } from './projects/Overview'; import { People } from './projects/People'; import { ProjectPage } from './projects/ProjectPage'; @@ -94,6 +99,7 @@ export function Projects({ refreshProjectsData(); workVersion.set({ n: workVersion.get().n + 1 }); void loadRoadmap(); + void loadInitiatives(); }, CHANGE_SETTLE_MS); }); return () => { @@ -108,52 +114,73 @@ 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 } = useInitiatives(); + const untracked = useMemo(() => untrackedSlugs(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), [fullToday, untracked]); + const teamOf = useMemo(() => teamLookup(fullData?.teams ?? {}), [fullData]); + const { items: allPlans } = 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 +193,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 +220,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,14 +294,14 @@ export function Projects({ /> ) : nav.view === 'people' ? ( @@ -264,6 +309,9 @@ export function Projects({ )} - + ); } diff --git a/frontend-v2/src/views/projects/NowNextLater.tsx b/frontend-v2/src/views/projects/NowNextLater.tsx index bbbabbc8..616bdcaa 100644 --- a/frontend-v2/src/views/projects/NowNextLater.tsx +++ b/frontend-v2/src/views/projects/NowNextLater.tsx @@ -121,7 +121,8 @@ function Card({ /** * The roadmap without dates, for readers who want the order and not the * weeks: what's happening now, what's next, and what's later, each in - * priority order and each split by team when the roadmap is. Opening an item + * priority order and each split by team or initiative when the roadmap + * is. Opening an item * switches to the timeline with it open, since that's where its plan * changes. */ @@ -129,7 +130,8 @@ export function NowNextLater({ items, all, bySlug, - laneTitles, + lanes, + laneOf = item => item.team, today, calls, unplanned, @@ -142,8 +144,11 @@ export function NowNextLater({ /** every plan, for what one waits on */ all: RoadmapItem[]; bySlug: Map; - /** team names when the roadmap is split into team lanes, else null */ - laneTitles: string[] | null; + /** the lanes when the roadmap is split (teams, or initiatives in rank), + * each by its key and title, the no-value lane last; null when not */ + lanes: { key: string | null; title: string }[] | null; + /** which lane's key a plan is in */ + laneOf?: (item: RoadmapItem) => string | null; today: string; /** the calls Decide asks, by plan */ calls: ReadonlyMap; @@ -176,23 +181,26 @@ export function NowNextLater({
{BUCKETS.map(([bucket, title, sub]) => { const list = items.filter(i => bucketOf(i, today) === bucket); - const lanes = - laneTitles && - [...laneTitles, null] - .map(team => ({ team, list: list.filter(i => (i.team ?? null) === team) })) + const split = + lanes && + lanes + .map(lane => ({ + ...lane, + list: list.filter(i => (laneOf(i) ?? null) === lane.key), + })) .filter(lane => lane.list.length); return (
{list.length ? ( - {lanes - ? lanes.map(lane => ( + {split + ? split.map(lane => ( {cards(lane.list)} diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 1698a62c..5a3682fb 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -58,7 +58,21 @@ import { textInputClass, } from '../../components/bits'; import { Icon } from '../../components/Icon'; -import { eyebrowText, Fold, GroupHeader, Rows, SubDoor, useFoldState } from '../../components/Lane'; +import { + eyebrowText, + Fold, + GroupHeader, + openFold, + Rows, + SubDoor, + useFoldState, +} from '../../components/Lane'; +import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; +import { byRank, fileProject, moveInitiative, readInitiatives } from '../../model/initiativesData'; + +/** The initiatives as the board has them, just after a write. */ +const readInitiativesNow = () => readInitiatives().initiatives ?? []; +import { bandGloss as initiativeGloss, spanWords, UNSORTED } from '../../model/initiativeBoard'; import { useRowKeys } from '../../components/useRowKeys'; import { dateOf, dayOf, dayWords, useProjectsData, type Range } from '../../model/projectData'; import { findFilter, lowerFirst, planCell, type PortfolioItem } from '../../model/portfolio'; @@ -117,7 +131,8 @@ import { NO_UPDATE_YET, UPDATE_DUE, } from '../../model/words'; -import { askOf, DecideCall, reasonWords, useCallsMadeHere } from './Decide'; +import { askedInitiative, askOf, DecideCall, reasonWords, useCallsMadeHere } from './Decide'; +import { HomeLine } from './Initiatives'; import { LoadChart } from './LoadChart'; import { NowNextLater } from './NowNextLater'; import { @@ -214,6 +229,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 +869,8 @@ function PlanPanel({ onPerson, onOpenItem, onOpenProject, + initiatives = null, + navigate, }: { item: RoadmapItem; all: RoadmapItem[]; @@ -874,6 +894,10 @@ function PlanPanel({ onPerson: (login: string) => void; onOpenItem: (id: number) => void; onOpenProject: (slug: string) => void; + /** the initiatives, for its project's home line and Move: the keyboard's + * way to file what a drag onto another lane files; none hides it */ + initiatives?: readonly Initiative[] | null; + navigate?: Navigate; }) { const ref = useRef(null); const [removing, setRemoving] = useState(false); @@ -1000,6 +1024,14 @@ function PlanPanel({ )} )} + {project && navigate && !!initiatives?.length && ( + <> +
Initiative
+
+ +
+ + )}
Team
{teamWords(item.team, lead)} @@ -1399,24 +1431,29 @@ function PlanRow({ >
- - - + {/* a Not tracked plan has no place in the order: no grip, no rank */} + {rank === 0 ? ( + + ) : ( + + + + )} - {rank} + {rank || ''} @@ -2037,8 +2074,17 @@ function TimelineLane({ 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; @@ -2063,11 +2109,15 @@ function TimelineLane({ count={count} showCount={showCount} gloss={gloss} + gutter={gutter} + keepEmpty={keepEmpty} detail={ - words && ( + words ? ( + ) : ( + phrase && {phrase} ) } > @@ -2194,13 +2244,24 @@ function AxisHeader({ */ export function Roadmap({ items, + allItems = items, + initiatives = null, + untracked = NO_SLUGS, teamMembers, nav, navigate, decisions, }: { - /** the portfolio, for linking plans to projects and their PRs */ + /** the portfolio, for linking plans to projects and their PRs: the + * tracked projects, which every row, count and line reads */ items: PortfolioItem[]; + /** every project, Not tracked too: one-slug lookups, the plan form's + * choices, and the Not tracked lane */ + allItems?: PortfolioItem[]; + /** the initiatives, for their lanes; null while they load */ + initiatives?: Initiative[] | null; + /** the Not tracked slugs */ + untracked?: ReadonlySet; /** developer teams: name to logins */ teamMembers: Record; nav: ProjectsNav; @@ -2210,6 +2271,8 @@ export function Roadmap({ decisions?: DecideRow[] | null; }) { const { items: plan, loadFailed, problem } = useRoadmap(); + // every plan, for the Not tracked lane and an initiative's dates + const { items: everyPlan } = useRoadmap({ all: true }); // Decide's rows with the calls made here kept, so a call made in a plan's // details keeps its receipt and Undo there const callsHere = useCallsMadeHere(decisions ?? NO_ROWS); @@ -2229,6 +2292,9 @@ export function Roadmap({ const [adding, setAdding] = useState(false); const [dragging, setDragging] = useState(null); const [dropTarget, setDropTarget] = useState(null); + // an initiative's grip being dragged, and the lane a drag is over + const [draggingLane, setDraggingLane] = useState(null); + const [overLane, setOverLane] = useState(null); const [choosing, setChoosing] = useState(null); // what the last click saved, under its row, with the way back const [receipt, setReceipt] = useState(null); @@ -2329,7 +2395,8 @@ export function Roadmap({ end: today < horizon.end ? today : horizon.end, }; const history = useProjectsData(pastRange)?.window.projects ?? {}; - const bySlug = new Map(items.map(i => [i.slug, i])); + // one slug each: the full set, so a Not tracked plan's row has its project + const bySlug = new Map(allItems.map(i => [i.slug, i])); // the calls Decide asks, by the plan they're about, and by project for // work with no plan, each in the words the Overview's Plan cell uses: a // row says its call where its one amber mark goes @@ -2360,6 +2427,16 @@ export function Roadmap({ } } const lanes = nav.group === 'team'; + const byInitiative = nav.group === 'initiative' && !!initiatives?.length; + const homes = homesOf(initiatives ?? []); + const homeOf = (slug: string | null) => (slug ? homes.get(slug)?.initiative : undefined); + // the lane a plan or project sits in by initiative: its home's slug, or + // Unsorted + const laneKey = (slug: string | null) => { + const home = homeOf(slug); + return home && home.kind !== 'untracked' ? home.slug : UNSORTED; + }; + const laneId = (key: string) => `roadmap:initiative:${key}`; // Show: every row, only the plans, or only the projects with no plan const showPlans = nav.show !== 'unplanned'; const everything = nav.show !== 'plan'; @@ -2489,12 +2566,13 @@ export function Roadmap({ return isUnderWay(i.status) && within(end) ? [{ name: i.name, day: end }] : []; }), }; - const projectOptions = [...items] + // a list for choosing: every project, Not tracked ones too + const projectOptions = [...allItems] .sort((a, b) => a.name.localeCompare(b.name)) .map(i => ({ slug: i.slug, name: i.name, target: i.target?.due_on?.slice(0, 10) ?? null })); const people = [ ...new Set([ - ...items.flatMap(i => [...i.developers, ...i.nonDevelopers, i.lead ?? '']), + ...allItems.flatMap(i => [...i.developers, ...i.nonDevelopers, i.lead ?? '']), ...teams, ]), ].filter(Boolean); @@ -2617,7 +2695,8 @@ export function Roadmap({ undo: () => { run.current = null; refocus.current = [`roadmap-grip-${item.id}`]; - const now = readRoadmap().items?.find(i => i.id === item.id)?.team ?? null; + const now = + readRoadmap({ all: true }).items?.find(i => i.id === item.id)?.team ?? null; void Promise.all([ reorderRoadmap(from), now === fromTeam ? true : updateRoadmapItem(item.id, { team: fromTeam }), @@ -2689,6 +2768,71 @@ export function Roadmap({ }); }; + // 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) => { + const key = `plan:${item.id}`; + const slug = item.project; + if (!slug) { + return setAnnounced( + `${item.name} has no project, so it can’t be filed. Give it a project in Edit details first.` + ); + } + const from = homeOf(slug)?.slug ?? null; + const target = to === UNSORTED ? null : to; + const toName = target + ? byRank(initiatives ?? []).find(i => i.slug === target)?.name ?? target + : 'Unsorted'; + run.current = null; + void fileProject(slug, target, { expect: from }).then(r => { + if ('error' in r) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); + openFold(laneId(to)); + refocus.current = [`roadmap-grip-${item.id}`]; + say({ + key, + words: `Moved ${item.name} to ${toName}`, + undo: () => { + refocus.current = [`roadmap-grip-${item.id}`]; + void fileProject(slug, r.before.initiative, { + set_at: r.before.set_at, + baseline_prs: r.before.baseline_prs, + }).then(back => { + if ('error' in back) return fail(key); + openFold(laneId(r.before.initiative ?? UNSORTED)); + say(null, `Put ${item.name} back`); + }); + }, + }); + }); + }; + // an initiative moved just above `before` (null: the bottom), with Undo + // back to the one it sat above + const rankMove = (moving: Initiative, before: number | null) => { + const all = byRank(initiatives ?? []); + const was = all[all.findIndex(i => i.id === moving.id) + 1]?.id ?? null; + if (was === before) return; + const key = `initiative:${moving.id}`; + run.current = null; + refocus.current = [`roadmap-lane-grip-${moving.id}`]; + void moveInitiative(moving.id, before).then(r => { + if (r !== true) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); + const ranked = byRank(readInitiativesNow()).filter( + i => i.status === 'open' && i.kind !== 'untracked' + ); + const at = ranked.findIndex(i => i.id === moving.id) + 1; + say({ + key, + words: `Moved ${moving.name} to rank ${at}`, + undo: () => { + refocus.current = [`roadmap-lane-grip-${moving.id}`]; + void moveInitiative(moving.id, was).then(back => + back === true ? say(null, `Put ${moving.name} back`) : fail(key) + ); + }, + }); + }); + }; + const handlersFor = (item: RoadmapItem, visible: number[]) => ({ grip: { onDragStart: (e: DragEvent) => { @@ -2720,10 +2864,17 @@ export function Roadmap({ }, onDrop: (e: DragEvent) => { e.preventDefault(); + e.stopPropagation(); const dropped = ordered.find(i => i.id === dragging); setDragging(null); setDropTarget(null); + setOverLane(null); if (!dropped || dropped.id === item.id) return; + // by initiative, a drop in another initiative's lane files it + // there and leaves its priority alone + if (byInitiative && laneKey(dropped.project) !== laneKey(item.project)) { + return fileByDrag(dropped, laneKey(item.project)); + } // in team lanes, dropping into another team's lane moves it there reorder( dropped, @@ -2819,8 +2970,21 @@ export function Roadmap({ // first loads; and a new plan's form, which Add can open from far below const loaded = plan != null; useEffect(() => { - if (loaded && nav.item != null) - scrollClear(document.getElementById(`roadmap-item-${nav.item}`)); + if (!loaded || nav.item == null) return; + // a link to a plan opens the lane that holds it first, as a click + // on its band would, so the scroll finds the row + const row = document.getElementById(`roadmap-item-${nav.item}`); + const lane = row?.closest('[data-lane-id]'); + const closed = !!lane?.querySelector('details:not([open])')?.contains(row); + if (!closed) return scrollClear(row); + openFold(lane?.dataset.laneId as string); + // once the lane has opened and laid out + const id = nav.item; + const wait = window.setTimeout( + () => scrollClear(document.getElementById(`roadmap-item-${id}`)), + 50 + ); + return () => window.clearTimeout(wait); }, [loaded, nav.item, panel]); useEffect(() => { if (adding) scrollClear(document.getElementById('roadmap-new')); @@ -2853,7 +3017,11 @@ export function Roadmap({ el?.focus(); }); - const planRows = (list: RoadmapItem[], visible: RoadmapItem[] = list) => { + const planRows = ( + list: RoadmapItem[], + visible: RoadmapItem[] = list, + { ranked = true }: { ranked?: boolean } = {} + ) => { const visibleIds = visible.map(i => i.id); return list.map(item => { const key = `plan:${item.id}`; @@ -2877,7 +3045,7 @@ export function Roadmap({ {receipt?.key === key && } {isOpen && @@ -2943,6 +3111,8 @@ export function Roadmap({ onPerson={person} onOpenItem={openFromReceipt} onOpenProject={slug => navigate({ project: slug })} + initiatives={initiatives} + navigate={navigate} /> ))}
@@ -3009,6 +3179,213 @@ export function Roadmap({ .filter((t): t is string => !!t && !teams.includes(t)) .sort((a, b) => a.localeCompare(b)), ]; + // ----- 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; + const heldPlan = (i: RoadmapItem) => i === gone || 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; + e.preventDefault(); + setOverLane(key); + }, + onDragLeave: () => setOverLane(o => (o === key ? null : o)), + onDrop: (e: DragEvent) => { + e.preventDefault(); + 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]; + 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) : []; + const asked = initiative ? 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}` : 'Unsorted'; + return ( +
+ + {initiative && ( +
+ navigate({ initiative: initiative.slug, project: null })} + title={`Open ${initiative.name}: its projects, owner and goal`} + > + Initiative page + + {initiative.owner && ( + person(initiative.owner as string)} + title={`${initiative.owner} owns ${initiative.name}`} + > + {initiative.owner} + + )} + {initiative.goal && {initiative.goal}} +
+ )} + {initiative && asked && ( +
+ {asked.reasons.length > 0 && ( +

+ {askOf(asked.reasons[0]).question} +

+ )} + +
+ )} + {receipt?.key === `initiative:${initiative?.id}` && ( + + )} + {planRows(planned, planned)} + {inFlightRows(loose)} +
+ {/* a rank move's receipt stays in view with the lane closed */} + {initiative && receipt?.key === `initiative:${initiative.id}` && !calls && ( + + )} +
+ ); + }); + })(); + // ----- 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 ?? []) + .filter(i => !trackedIds.has(i.id) && isUnderWay(i.status) && 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 && ( +
+ + {showPlans && planRows(notTrackedPlans, notTrackedPlans, { ranked: false })} + {inFlightRows(notTrackedLoose)} + +
+ ); + const body = lanes ? ( [...laneTitles, null].map(team => { const inLane = (t: string | null | undefined) => (t ?? null) === team; @@ -3089,6 +3466,8 @@ export function Roadmap({ ); }) + ) : byInitiative ? ( + initiativeBody ) : ( <> {shownPlans.length > 0 && ( @@ -3331,10 +3710,13 @@ export function Roadmap({ Group by navigate({ group })} /> @@ -3470,7 +3852,10 @@ export function Roadmap({ roadmap to start a plan.
) : ( -
{body}
+
+ {body} + {notTrackedLane} +
)} {narrowed && !shownPlans.length && @@ -3494,7 +3879,23 @@ export function Roadmap({ items={nnlPlans} all={ordered} bySlug={bySlug} - laneTitles={lanes ? laneTitles : null} + lanes={ + lanes + ? [ + ...laneTitles.map(t => ({ key: t as string | null, title: t })), + { key: null, title: 'No team' }, + ] + : byInitiative + ? [ + ...laneInitiatives.map(i => ({ + key: i.slug as string | null, + title: i.name, + })), + { key: UNSORTED, title: 'Unsorted' }, + ] + : null + } + laneOf={byInitiative && !lanes ? i => laneKey(i.project) : i => i.team} today={today} calls={planCalls} unplanned={unplanned.length} From 7c2a6dbb3889a17bd278a67dc03e1efff7c0d869 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Sun, 11 Oct 2026 21:37:51 -0700 Subject: [PATCH 010/148] Record the initiative lane amendments DESIGN.md says why the Roadmap and the Overview open by initiative, and the three exceptions initiative lanes make to the Fold and lane rules: a lane holds only what's under way, its grip sits in the gutter left of the band, and it rests closed unless it holds a Decide call. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 49c4d6e6..ebc2eca3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -817,6 +817,37 @@ 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 with an end · 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 Move, in the + plan's details and on the project's page. + 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. - **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 From 44f42bc655039d3f0e23acdf7f26c1bf95f01874 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:00:18 -0700 Subject: [PATCH 011/148] Share one going rule and one tracking read The close guard counted a project with PRs open as still going, but Decide's goal_reached didn't, so Decide could suggest a close the server refused with a 409. goingProjects (a plan under way, or PRs open) is now the one rule; the guard calls it, and Decide does in the next commit. Which projects are Not tracked was worked out twice on the server, once cached and once on every /projects-data, /api/v1/projects, /decide and /load request. trackedNow keeps one tracking() computation for every controller, forgotten with the other project caches on each write. The roadmap's fallback for missing tables goes with it: schema-check already stops a server without them. Co-Authored-By: Claude Opus 5.5 --- controllers/initiatives.js | 20 +++++++++++--------- controllers/projects.js | 9 +++++---- controllers/roadmap.js | 32 +++++++++++++++----------------- shared/index.ts | 1 + shared/model/initiatives.ts | 17 +++++++++++++++++ test/projects.test.js | 31 ++++++++++++++++++++++++++++++- test/roadmap.test.js | 17 ----------------- 7 files changed, 79 insertions(+), 48 deletions(-) diff --git a/controllers/initiatives.js b/controllers/initiatives.js index 58a38c6c..bb6a9a19 100644 --- a/controllers/initiatives.js +++ b/controllers/initiatives.js @@ -15,8 +15,8 @@ import { import { undoTimes } from './roadmap.js'; import { checkInitiativeFields, + goingProjects, initiativeSpan, - isUnderWay, DEFAULT_PROJECT_PREFIX, PROJECT_SLUG, } from '../shared/dist/index.js'; @@ -41,21 +41,23 @@ async function sent(initiatives, items) { } /** - * The projects in an initiative with work going: a plan under way, or PRs - * open on the board. An initiative closes only once none is left. + * 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 slugs = initiative.projects.map(f => f.project); - const going = new Set( - (await listItems()).filter(i => i.project && isUnderWay(i.status)).map(i => i.project) - ); 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) if (g.open.length) going.add(g.slug); + for (const g of today.live) open.set(g.slug, g.open.length); } - return slugs.filter(slug => going.has(slug)); + return goingProjects( + initiative.projects.map(f => f.project), + await listItems(), + slug => open.get(slug) ?? 0 + ); } /** A failed write's 500, logged. */ diff --git a/controllers/projects.js b/controllers/projects.js index 19467fa7..3b2633e0 100644 --- a/controllers/projects.js +++ b/controllers/projects.js @@ -10,7 +10,8 @@ import { todayFromBoard, } from '../lib/projects.js'; import { listItems } from '../lib/roadmap.js'; -import { tracking, trackedOnly } from '../lib/initiatives.js'; +import { trackedOnly } from '../lib/initiatives.js'; +import { trackedNow } from './roadmap.js'; import { attachIssue, detachIssue, @@ -84,7 +85,7 @@ 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), tracking(settings)]).then( + return Promise.all([loadProjects(settings), loadPullLinks(settings), trackedNow()]).then( ([projects, linked, tracked]) => loadWindow(settings, window.start, window.end, only, linked).then( ({ spans, reviews }) => ({ @@ -506,7 +507,7 @@ export default { loadProjects(settings), listItems(), loadPullLinks(settings), - tracking(settings), + trackedNow(), ]).then(([projects, plans, linked, tracked]) => loadWork(settings, { plans, projects }).then(work => [projects, plans, work, linked, tracked]) ); @@ -629,7 +630,7 @@ export default { : Promise.resolve({}); respondOrError( res, - Promise.all([loadProjects(settings), listItems(), history, links, tracking(settings)]).then( + Promise.all([loadProjects(settings), listItems(), history, links, trackedNow()]).then( ([projects, allItems, allPast, linked, tracked]) => { const pulls = pullManager.getPulls(); // not-tracked projects leave the load, past and ahead diff --git a/controllers/roadmap.js b/controllers/roadmap.js index 2f92b375..37c6b104 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -15,7 +15,7 @@ import { restoreItem, updateItem, } from '../lib/roadmap.js'; -import { listInitiatives, trackedOnly, untrackedSlugs } from '../lib/initiatives.js'; +import { tracking, trackedOnly } from '../lib/initiatives.js'; import { sharedValue } from '../lib/ttl-cache.js'; import { loadPullLinks, loadWork } from '../lib/work.js'; import { @@ -63,22 +63,20 @@ const latelyNow = sharedValue(LATELY_MS, async () => { return latelyBySlug(today, work?.pace ?? {}, ongoing, now); }); -/** Which projects are Not tracked (lib/initiatives.js untrackedSlugs), kept - * as long as what they did lately and forgotten with it. */ -const untrackedNow = sharedValue(LATELY_MS, async () => { +/** 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 ? untrackedSlugs(settings, await listInitiatives()) : new Set(); + return settings + ? tracking(settings) + : { initiatives: [], homes: new Map(), untracked: new Set(), grown: new Map() }; }); -/** The not-tracked projects (untrackedNow); none when the read fails, so a - * database without the initiatives tables still reads its roadmap. */ -export async function untrackedProjects() { - try { - return await untrackedNow.get(); - } catch (err) { - console.error('roadmap: reading which projects are not tracked failed:', err); - return new Set(); - } +/** What trackingNow keeps. */ +export function trackedNow() { + return trackingNow.get(); } /** What each project did lately (latelyNow); empty when the reads fail: @@ -102,7 +100,7 @@ async function lately() { * the standing is read, so a not-tracked plan owes nothing. */ async function sent(items) { - const [bySlug, untracked] = await Promise.all([lately(), untrackedProjects()]); + const [bySlug, { untracked }] = await Promise.all([lately(), trackedNow()]); const now = Date.now() / 1000; const today = teamDay(now); return items.map(item => { @@ -156,7 +154,7 @@ const standingNow = sharedValue(STANDING_MS, async () => { * issues and their links, changed it (app.js). */ export function forgetProjectCaches() { latelyNow.forget(); - untrackedNow.forget(); + trackingNow.forget(); standingNow.forget(); } @@ -253,7 +251,7 @@ export default { const now = Math.floor(Date.now() / 1000); respondOrError( res, - Promise.all([listItems().then(sent), untrackedProjects()]).then(([all, untracked]) => { + Promise.all([listItems().then(sent), trackedNow()]).then(([all, { untracked }]) => { // not-tracked plans owe nothing const { items } = trackedOnly(untracked, { items: all }); return { diff --git a/shared/index.ts b/shared/index.ts index cd2a2d5b..dc9e1300 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -125,6 +125,7 @@ export type { } from './model/decide'; export { checkInitiativeFields, + goingProjects, grew, homesOf, initiativeSpan, diff --git a/shared/model/initiatives.ts b/shared/model/initiatives.ts index 37b3d08b..c3be6f8a 100644 --- a/shared/model/initiatives.ts +++ b/shared/model/initiatives.ts @@ -126,6 +126,23 @@ 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})$/; diff --git a/test/projects.test.js b/test/projects.test.js index a98508cf..c28f7c97 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -19,6 +19,7 @@ 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'; const DAY = 86400; @@ -332,6 +333,8 @@ 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'); @@ -424,7 +427,10 @@ before(() => { { reviewer: 'alice', repo: 'test/repo-a', number: 11, author: 'alice', at: now - DAY }, ]; } - if (sql.includes('FROM `initiatives`')) return initiativeRows; + 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 l.`title`, p.`date`')) { @@ -746,14 +752,18 @@ const filingRow = (project, initiative_id, 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(); }); } @@ -765,6 +775,21 @@ test('with no initiatives, nothing is not tracked and nothing changes', async () 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( @@ -776,6 +801,8 @@ test('/projects-data and /api/v1/projects say each project’s home, tracking an 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 } }); @@ -825,6 +852,7 @@ test('/api/v1/decide leaves not-tracked projects out, but asks about the ones th 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'); @@ -861,6 +889,7 @@ test('/api/v1/load leaves not-tracked projects out, past and ahead', async () => 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 = []; diff --git a/test/roadmap.test.js b/test/roadmap.test.js index 68be100d..bf604ad7 100644 --- a/test/roadmap.test.js +++ b/test/roadmap.test.js @@ -874,20 +874,3 @@ test('a not-tracked plan says so, owes no update, and leaves updates-owed', asyn ); }); }); - -test('the roadmap still reads when the initiatives tables can’t be', async () => { - rows.push({ id: 1, name: 'Plan', project: 'alpha', status: 'active', start: '2026-01-05', weeks: 4, priority: 0 }); - await withProjects([], async () => { - const base = projectQuery; - projectQuery = sql => { - if (sql.includes('`initiative')) throw new Error("Table 'initiatives' doesn't exist"); - return base(sql); - }; - forgetProjectCaches(); - const errors = mock.method(console, 'error', () => {}); - const { status, body } = await call('GET', '/roadmap', undefined, {}); - errors.mock.restore(); - assert.equal(status, 200); - assert.equal(body.items[0].untracked, false); - }); -}); From 8f71dd8616913e7a906958a1c4a557285748f972 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:00:28 -0700 Subject: [PATCH 012/148] Ask about initiatives once per project row Review fixes on Decide and the initiative page: - goal_reached and reopened_in_closed use goingProjects, so Decide never suggests a close the server refuses; Keep open holds through PR activity on finished projects, waiting for a plan or filing change. - reopened_in_closed joins the project's own row, ahead of reopened, so one project no longer gets two questions with clashing suggestions. The reopen's receipt stays when the row comes back for its plan, and that plan's answer then restarts it rather than keeping it done. - A new no_project reason asks an active plan with no project which project it is; Link to answers it, with Undo. - Not tracked is found by its kind, never the 'not-tracked' slug. - The home UI moves to Initiatives.tsx. Choosers sit above the buttons they feed, all say "File it in"; an answer waiting on an empty chooser is off; Suggested marks only the board's guess. - "Unsorted" is the one word for a project filed nowhere. - A goal question shows the goal. A filing 409 names where the project went. A failed edit Undo says why. - The initiative page asks its projects' questions on their rows and its own at its head, counts them in Health, folds only finished work with nothing asked, and treats Not tracked as a ledger. - primaryOf and bandGloss each exist once; the board's untracked helpers are renamed so the server's names mean one thing. Co-Authored-By: Claude Opus 5.5 --- controllers/api-routes.js | 8 +- frontend-v2/src/model/decide.test.ts | 61 ++ frontend-v2/src/model/initiativeBoard.test.ts | 94 ++- frontend-v2/src/model/initiativeBoard.ts | 88 ++- frontend-v2/src/model/initiativesData.test.ts | 12 +- frontend-v2/src/model/initiativesData.ts | 31 +- frontend-v2/src/model/portfolio.test.ts | 10 + frontend-v2/src/model/portfolio.ts | 27 +- frontend-v2/src/model/projectData.ts | 10 +- frontend-v2/src/model/roadmapData.ts | 6 +- frontend-v2/src/model/words.ts | 10 + frontend-v2/src/views/MyWork.tsx | 4 +- frontend-v2/src/views/Projects.tsx | 4 +- frontend-v2/src/views/projects/Decide.tsx | 433 ++++--------- .../src/views/projects/HomeCalls.test.tsx | 299 ++++++++- .../src/views/projects/Initiatives.tsx | 592 +++++++++++++++--- .../src/views/projects/ProjectPage.tsx | 10 +- shared/model/decide.ts | 75 ++- 18 files changed, 1266 insertions(+), 508 deletions(-) diff --git a/controllers/api-routes.js b/controllers/api-routes.js index e6138b3c..4b8c5034 100644 --- a/controllers/api-routes.js +++ b/controllers/api-routes.js @@ -245,7 +245,7 @@ export const API_ROUTES = [ 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`: slug, set_by, set_at, baseline_prs) and `span`, read off their plans: the earliest start, ' + + '(`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', }, @@ -370,11 +370,13 @@ export function apiIndex(req, res) { 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: - 'every project of the open `initiative` is done, dropped, parked, or quiet with no plan, for 14 days; Keep open quiets it until one changes', + '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', + '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, ' + diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts index 73ad7f43..cea0642f 100644 --- a/frontend-v2/src/model/decide.test.ts +++ b/frontend-v2/src/model/decide.test.ts @@ -984,4 +984,65 @@ describe('decideQueue: where projects belong', () => { { 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 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/initiativeBoard.test.ts b/frontend-v2/src/model/initiativeBoard.test.ts index c05eede9..30c9eccb 100644 --- a/frontend-v2/src/model/initiativeBoard.test.ts +++ b/frontend-v2/src/model/initiativeBoard.test.ts @@ -8,11 +8,19 @@ import { 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, untrackedSlugs, type ProjectsData } from './projectData'; +import { trackedData, notTrackedSlugs, type ProjectsData } from './projectData'; import { mergeOrder, trackedPlans } from './roadmapData'; const NOW = Date.UTC(2026, 9, 11, 12); @@ -161,7 +169,7 @@ describe('the words', () => { 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('in no initiative'); + expect(homeWords(null)).toBe('Unsorted'); }); it('says a band’s dates and counts from its projects’ plans', () => { @@ -181,8 +189,84 @@ describe('the words', () => { 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. Owner: dana.' + '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 with an end'); + }); +}); + +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, + ]); }); }); @@ -226,7 +310,7 @@ describe('a Not tracked project', () => { // 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 = untrackedSlugs(data, initiatives); + const untracked = notTrackedSlugs(data, initiatives); const today = trackedToday(fullToday, untracked); const plans = trackedPlans(allPlans, initiatives); const all = portfolioItems(data.projects, fullToday, {}, () => 'Store', NOW, allPlans); @@ -262,7 +346,7 @@ describe('a Not tracked project', () => { it('is tracked again the moment a filing on this board says so', () => { const filed = [...initiatives, initiative(2, 'ops-work', ['ops'])]; - expect(untrackedSlugs(data, filed)).toEqual(new Set()); + expect(notTrackedSlugs(data, filed)).toEqual(new Set()); expect(trackedPlans(allPlans, filed).map(p => p.id)).toEqual([1, 2, 3]); }); }); diff --git a/frontend-v2/src/model/initiativeBoard.ts b/frontend-v2/src/model/initiativeBoard.ts index 0cf0ef4c..7914eb8b 100644 --- a/frontend-v2/src/model/initiativeBoard.ts +++ b/frontend-v2/src/model/initiativeBoard.ts @@ -1,11 +1,12 @@ import { n } from '../../../shared/format'; import type { GrownProject } from '../../../shared/model/decide'; import { homesOf, initiativeSpan, type Initiative } from '../../../shared/model/initiatives'; -import type { Today } from '../../../shared/model/projects'; +import { MISC_SLUG, type Today } from '../../../shared/model/projects'; import type { RoadmapItem } from '../../../shared/model/roadmap'; import type { PortfolioItem } from './portfolio'; import { dayWords, type ProjectsData } from './projectData'; -import { byRank, untrackedNow } from './initiativesData'; +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 @@ -37,7 +38,7 @@ export function grownProjects( ): GrownProject[] { return (data?.grew ?? []).flatMap(slug => { const counts = data?.grew_counts?.[slug]; - return counts && untrackedNow(slug, true, initiatives) ? [{ slug, ...counts }] : []; + return counts && isNotTrackedNow(slug, true, initiatives) ? [{ slug, ...counts }] : []; }); } @@ -88,10 +89,10 @@ export function homeChoices(initiatives: readonly Initiative[]): Initiative[] { ]; } -/** "in Release safety", "in Keep the lights on", "Not tracked", "in no - * initiative": where a project lives, mid-sentence. */ +/** "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 'in no initiative'; + if (!home) return UNSORTED_WORD; return home.kind === 'untracked' ? 'Not tracked' : `in ${home.name}`; } @@ -107,23 +108,86 @@ export function spanWords( if (!span.start) return ''; const day = (d: string) => dayWords(d, { now }); if (span.end_kind === 'ended') return span.ended ? `Ended ${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 + ? Math.floor((Date.parse(today) - Date.parse(span.end)) / (7 * 86400 * 1000)) + : 0; const parts = [ - span.end ? `${day(span.start)} to ${day(span.end)}` : `Since ${day(span.start)}`, + span.end + ? `${day(span.start)} to ${day(span.end)}${past ? `, ${n(past, 'week')} past` : ''}` + : `Since ${day(span.start)}`, span.with_end ? `${span.with_end} with an end` : '', span.ongoing ? `${span.ongoing} ongoing` : '', ]; return parts.filter(Boolean).join(' · '); } -/** What an initiative's band says on hover: its goal, its owner, and how - * much it holds ("10 projects, 7 being worked 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, + initiative: Pick, projects: number, working: number ): string { const held = `${n(projects, 'project')}, ${working} being worked on`; - const owner = initiative.owner ? ` Owner: ${initiative.owner}.` : ''; + const owner = initiative.owner ? ` Owned by ${initiative.owner}.` : ''; const goal = initiative.goal ? `${initiative.goal.replace(/\.?$/, '.')} ` : ''; - return `${goal}${held}.${owner}`; + const closed = initiative.status === 'open' ? '' : ` Closed as ${initiative.status}.`; + 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/initiativesData.test.ts b/frontend-v2/src/model/initiativesData.test.ts index dc5b91b2..8baf15e0 100644 --- a/frontend-v2/src/model/initiativesData.test.ts +++ b/frontend-v2/src/model/initiativesData.test.ts @@ -152,16 +152,16 @@ describe('the initiatives store', () => { }); }); -describe('untrackedNow', () => { +describe('isNotTrackedNow', () => { it('takes a filing’s word over the server’s, and the server’s with none', async () => { - const { untrackedNow } = await load(); + const { isNotTrackedNow } = await load(); const list = [ initiative(1, 'a', ['tracked']), initiative(2, 'nt', ['shelved'], { kind: 'untracked' }), ] as Initiative[]; - expect(untrackedNow('tracked', true, list)).toBe(false); - expect(untrackedNow('shelved', false, list)).toBe(true); - expect(untrackedNow('repo-default', true, list)).toBe(true); - expect(untrackedNow('shelved', false, null)).toBe(false); + 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); }); }); diff --git a/frontend-v2/src/model/initiativesData.ts b/frontend-v2/src/model/initiativesData.ts index b2731df8..c069eff3 100644 --- a/frontend-v2/src/model/initiativesData.ts +++ b/frontend-v2/src/model/initiativesData.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { isDummy } from '../backend/dummy'; import { DUMMY_GROWN, DUMMY_INITIATIVES } from '../backend/dummyProjects'; import { createMemoryStore } from '../storage'; +import { UNSORTED_WORD } from './words'; import { homesOf, type Filing, @@ -275,6 +276,18 @@ export async function fileProject( ...(baseline_prs != null && set_at != null ? { baseline_prs } : {}), }) .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; + void loadInitiatives(); + return { + error: `Couldn’t move the project: someone moved it to ${name} since, so nothing changed.`, + }; + } if (reply.status !== 200) { takeBack(); return { error: problemOf(reply, 'move the project') }; @@ -360,12 +373,24 @@ export function useInitiatives(): InitiativesState { * 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 untrackedNow( +export function isNotTrackedNow( slug: string | null, serverSays: boolean, initiatives: readonly Initiative[] | null = store.get().initiatives ): boolean { if (!slug || !initiatives) return serverSays; - const home = initiatives.find(i => i.projects.some(f => f.project === slug)); - return home ? home.kind === 'untracked' : 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 0e3e49cf..2a2eb78f 100644 --- a/frontend-v2/src/model/portfolio.test.ts +++ b/frontend-v2/src/model/portfolio.test.ts @@ -769,6 +769,16 @@ describe('groupItems by initiative', () => { 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:”', () => { diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts index 93c050b6..27807abe 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -40,7 +40,9 @@ import { NEEDS_A_PLAN, NO_PLAN, NO_UPDATE_YET, + NO_PROJECT_YET, pastEnd, + UNSORTED_WORD, UPDATE_DUE, } from './words'; @@ -265,16 +267,17 @@ 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): words until the - // board's Decide answers them + // the initiative calls (shared/model/decide.ts), in Decide's section words case 'unfiled': - return { kind: 'no_plan', text: 'In no initiative' }; + return { kind: 'no_plan', text: UNSORTED_WORD }; + case 'no_project': + return { kind: 'no_plan', text: NO_PROJECT_YET }; case 'untracked_grew': - return { kind: 'no_plan', text: 'Still not tracked?' }; + 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: 'Did it reach its goal?' }; + return { kind: 'issues_done', text: 'Goal maybe reached' }; } } @@ -792,7 +795,7 @@ export const GROUPINGS: [string, string][] = [ ]; /** The group for projects in no initiative. */ -export const UNSORTED_TITLE = 'Unsorted'; +export const UNSORTED_TITLE = UNSORTED_WORD; /** * The team a project mostly belongs to: the team most of its developers @@ -828,7 +831,10 @@ export function groupItems( by: string, nameOf: (slug: string) => string = slug => slug, teams: readonly string[] = [], - initiatives: readonly Initiative[] = [] + 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; items: PortfolioItem[]; @@ -836,7 +842,7 @@ export function groupItems( /** by initiative: the one the group is, absent for Unsorted */ initiative?: Initiative; }[] { - if (by === 'initiative') return byInitiative(items, initiatives); + 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]; @@ -878,7 +884,8 @@ export function groupItems( */ function byInitiative( items: readonly PortfolioItem[], - initiatives: readonly Initiative[] + initiatives: readonly Initiative[], + keep: ReadonlySet ): { title: string; items: PortfolioItem[]; @@ -904,7 +911,7 @@ function byInitiative( repeats: ReadonlySet; initiative?: Initiative; }[] = ranked.flatMap(i => - groups.has(i.id) + groups.has(i.id) || keep.has(i.id) ? [ { title: i.name, diff --git a/frontend-v2/src/model/projectData.ts b/frontend-v2/src/model/projectData.ts index e01fa4be..8cd4fa74 100644 --- a/frontend-v2/src/model/projectData.ts +++ b/frontend-v2/src/model/projectData.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import type { Initiative } from '../../../shared/model/initiatives'; -import { dummyTracking, untrackedNow, useInitiatives } from './initiativesData'; +import { dummyTracking, isNotTrackedNow, useInitiatives } from './initiativesData'; import { createMemoryStore } from '../storage'; import { dateOf, dayOf } from './days'; import { isDummy, loadDummy } from '../backend/dummy'; @@ -60,15 +60,15 @@ export interface ProjectsData { /** * The Not tracked slugs now: the server's list, with any filing made on this - * board since (initiativesData.ts untrackedNow), and every slug a filing + * board since (initiativesData.ts isNotTrackedNow), and every slug a filing * puts in Not tracked. */ -export function untrackedSlugs( +export function notTrackedSlugs( data: Pick | null | undefined, initiatives: readonly Initiative[] | null ): Set { const out = new Set( - (data?.untracked ?? []).filter(slug => untrackedNow(slug, true, initiatives)) + (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); @@ -86,7 +86,7 @@ export function trackedData( initiatives: readonly Initiative[] | null ): D { if (!data) return data; - const out = untrackedSlugs(data, initiatives); + const out = notTrackedSlugs(data, initiatives); if (!out.size) return data; return { ...data, diff --git a/frontend-v2/src/model/roadmapData.ts b/frontend-v2/src/model/roadmapData.ts index 1399bc0c..4255f29d 100644 --- a/frontend-v2/src/model/roadmapData.ts +++ b/frontend-v2/src/model/roadmapData.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { isDummy, loadDummy } from '../backend/dummy'; import type { Initiative } from '../../../shared/model/initiatives'; -import { dummyTracking, untrackedNow, useInitiatives, readInitiatives } from './initiativesData'; +import { dummyTracking, isNotTrackedNow, useInitiatives, readInitiatives } from './initiativesData'; import { DUMMY_PROJECTS, DUMMY_ROADMAP, DUMMY_ROADMAP_UPDATES } from '../backend/dummyProjects'; import { createMemoryStore } from '../storage'; import { @@ -618,7 +618,7 @@ export async function takeBackRoadmapUpdate( /** * 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 untrackedNow). Every list, count and + * board since: initiativesData.ts isNotTrackedNow). Every list, count and * question reads these; only a reader that names the full set (`all`) sees * the rest. */ @@ -626,7 +626,7 @@ export function trackedPlans !untrackedNow(i.project, !!i.untracked, initiatives)); + return items.filter(i => !isNotTrackedNow(i.project, !!i.untracked, initiatives)); } /** The roadmap as the board has it now, outside React: the tracked plans, diff --git a/frontend-v2/src/model/words.ts b/frontend-v2/src/model/words.ts index d861f845..4db50417 100644 --- a/frontend-v2/src/model/words.ts +++ b/frontend-v2/src/model/words.ts @@ -45,6 +45,16 @@ 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 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 set 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 */ diff --git a/frontend-v2/src/views/MyWork.tsx b/frontend-v2/src/views/MyWork.tsx index 75a35835..d8861726 100644 --- a/frontend-v2/src/views/MyWork.tsx +++ b/frontend-v2/src/views/MyWork.tsx @@ -16,7 +16,7 @@ import { import { DEFAULT_RANGE, ongoingSlugs, - untrackedSlugs, + notTrackedSlugs, resolveRange, teamLookup, useProjectsData, @@ -174,7 +174,7 @@ function useYourProjects( return useMemo(() => { if (!data || !plans) return null; const now = Date.now(); - const untracked = untrackedSlugs(data, initiatives); + const untracked = notTrackedSlugs(data, initiatives); const today = buildToday( data.projects, allPulls, diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index 136ed032..0aafa129 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -16,7 +16,7 @@ import { resolveRange, teamLookup, trackedData, - untrackedSlugs, + notTrackedSlugs, useProjectsData, type Range, } from '../model/projectData'; @@ -119,7 +119,7 @@ export function Projects({ // and question reads the tracked set below const fullData = useProjectsData(prefix ? range : null, null, { all: true }); const { initiatives } = useInitiatives(); - const untracked = useMemo(() => untrackedSlugs(fullData, initiatives), [fullData, initiatives]); + 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, null, { all: true }); diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 6c0ab41f..6e266ec1 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -1,7 +1,6 @@ import { createContext, useCallback, - useContext, useEffect, useId, useMemo, @@ -71,10 +70,9 @@ import { fileProject, readInitiatives, updateInitiative, - useInitiativesValue, type Home, } from '../../model/initiativesData'; -import { guessHome, homeChoices, UNSORTED } from '../../model/initiativeBoard'; +import { UNSORTED } from '../../model/initiativeBoard'; import { homesOf } from '../../../../shared/model/initiatives'; import { ALL_ISSUES_CLOSED, @@ -83,6 +81,7 @@ import { COMMIT_THROUGH, COPY_AS_TEXT, DONE_WHEN, + GOAL, IN_PROGRESS, LAST_14_DAYS, missedTarget, @@ -92,6 +91,8 @@ import { pastEnd, PLAN_IT, targetOn, + NO_PROJECT_YET, + UNSORTED_WORD, } from '../../model/words'; import type { WorkData } from '../../model/workData'; import { @@ -115,6 +116,7 @@ import { type ProjectsNav, } from './parts'; import { PLAN_STATUS_WORD, vouchWords, when } from './roadmapHealth'; +import { HomeChooser, HomeStrip, InitiativeSwitch, useHomeChooser } from './Initiatives'; /** The decisions owed now, worst first, for this view, the tab's label and * the Overview's tile. `today` and `items` are the tracked ones; the @@ -170,18 +172,18 @@ const SECTIONS: { more: string[]; }[] = [ { - kinds: ['reopened'], - title: 'Done or dropped, still worked on', + kinds: ['reopened_in_closed'], + title: 'Its initiative is closed, 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, + 'Its initiative was closed as done or dropped, and the project has open PRs or a plan under way again. Reopen the initiative, or move the project to another. A plan of its own marked done but still worked on is asked about once this is answered.', ], }, { - kinds: ['reopened_in_closed'], - title: 'Its initiative is closed, still worked on', + kinds: ['reopened'], + title: 'Done or dropped, 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 move the project to another.', + '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, ], }, { @@ -226,7 +228,7 @@ const SECTIONS: { }, { kinds: ['goal_reached'], - title: 'Did it reach its goal?', + 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.', ], @@ -255,14 +257,21 @@ const SECTIONS: { }, { kinds: ['unfiled'], - title: 'In no initiative', + title: UNSORTED_WORD, more: [ - `In no initiative 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.`, + `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: 'Still not tracked?', + title: 'Not tracked, but growing', more: [ 'Set 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.', ], @@ -272,8 +281,9 @@ const SECTIONS: { 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) => +/** 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 @@ -338,12 +348,13 @@ const nameOf = (row: DecideRow, project?: Pick) => initiativeById(askedInitiative(row))?.name ?? 'A plan'; /** An initiative by id, as the board has them now. */ -const initiativeById = (id: number | null) => +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. */ const HOME_KINDS: readonly DecideReason['kind'][] = [ 'unfiled', + 'no_project', 'untracked_grew', 'goal_reached', 'reopened_in_closed', @@ -366,10 +377,14 @@ export type Call = | { kind: 'done' } | { kind: 'drop' } | { kind: 'ongoing' } - /** file the project in the initiative with slug `to`, named `name` */ - | { kind: 'file'; to: string; name: string } - /** keep it Not tracked, starting its count again */ - | { kind: 'keep_untracked' } + /** 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 */ @@ -382,10 +397,10 @@ type PlanCallKind = Extract /** The calls that write a filing or an initiative's status. */ type HomeCall = Extract< Call, - { kind: 'file' | 'keep_untracked' | 'close' | 'keep_open' | 'reopen' } + { kind: 'file' | 'keep_untracked' | 'link' | 'close' | 'keep_open' | 'reopen' } >; const isHomeCall = (call: Call): call is HomeCall => - ['file', 'keep_untracked', 'close', 'keep_open', 'reopen'].includes(call.kind); + ['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. */ @@ -394,8 +409,14 @@ export interface Filed { 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". */ +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. */ const NEW_END = 'Move the end date?'; @@ -435,6 +456,8 @@ export function askOf(reason: DecideReason): { // 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': @@ -484,7 +507,9 @@ export function answerOf( 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 = askOf(primaryOf(row)).call as Exclude; + 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( @@ -493,6 +518,20 @@ export function answerOf( 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. */ +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) + ); +} + const VERBS: Record = { commit: ['Promise', 'Promised'], park: ['Park', 'Parked'], @@ -501,6 +540,7 @@ const VERBS: Record = { ongoing: ['Mark', 'Marked'], file: ['File', 'Filed'], keep_untracked: ['Keep', 'Kept'], + link: ['Link', 'Linked'], close: ['Close', 'Closed'], keep_open: ['Keep', 'Kept'], reopen: ['Reopen', 'Reopened'], @@ -564,9 +604,11 @@ export function answerWords(call: Call, plan: RoadmapItem | null): string { case 'ongoing': return 'Mark ongoing'; case 'file': - return call.to === 'not-tracked' ? 'Set not tracked' : `File in ${call.name}`; + return call.untracked ? 'Set 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': @@ -648,7 +690,11 @@ function reasonFacts(reason: DecideReason, item: RoadmapItem | null, bare = fals : `All its issues are closed (${tally})${open}`; } case 'unfiled': - return bare ? 'It isn’t filed anywhere yet' : 'It’s in no initiative'; + 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 @@ -773,10 +819,10 @@ export function callWords( row: DecideRow, project: Pick | undefined, today: string, - filed?: Pick + filed?: Pick ): string { // a plan call that files the project too says where, once - const home = filed && (filed.to === 'not-tracked' ? 'Not tracked' : `in ${filed.name}`); + const home = filed && filedWhere(filed); if (home && call.kind !== 'commit' && !isHomeCall(call)) { return `${callWords(call, row, project, today)} It’s ${home} now.`; } @@ -787,9 +833,11 @@ export function callWords( home ? `, ${home}` : '' }. Decide asks again if it runs past that.`; case 'file': - return call.to === 'not-tracked' + return call.untracked ? 'Set not tracked, so it counts nowhere. Decide asks again if it grows.' : `Filed in ${call.name}.`; + case 'link': + return `Linked to ${call.name}, so it goes where that project is filed.`; case 'keep_untracked': return 'Kept not tracked. Its count starts again, so Decide asks only if it doubles again.'; case 'close': @@ -878,7 +926,7 @@ export function keepCalls( const again = byKey.get(key); if (m.state !== 'made') { if (!again && m.row.reasons.length) back.push(m.row); - } else if (!again || kindsOf(again) === kindsOf(m.row)) { + } else if (!again || kindsOf(again) === kindsOf(m.row) || answeredHome(m, again)) { settled.set(key, m); } } @@ -887,6 +935,13 @@ export function keepCalls( return { owed, all, settled }; } +/** 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(); @@ -988,6 +1043,8 @@ function afterRow( interface Landed { id: number | null; home?: { project: string; 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 }; @@ -1006,10 +1063,15 @@ async function homeWrite( 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 to = call.kind === 'file' ? call.to : 'not-tracked'; - const r = await fileProject(slug, to, { expect: homeSlugOf(slug) }); + const r = await fileProject(slug, call.to, { expect: homeSlugOf(slug) }); return 'error' in r ? { why: whyNot(r.error) } : { home: { project: slug, before: r.before } }; @@ -1137,7 +1199,15 @@ function undoCall(key: string, batch?: { after: Promise }): Promise): void { +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')]; @@ -1340,11 +1410,9 @@ export function DecideCall({

{shown.filed && shown.filedBefore - ? `${upperFirst( - shown.filed.to === 'not-tracked' - ? 'set not tracked' - : `filed in ${shown.filed.name}` - )}; the plan didn’t save.` + ? `${ + shown.filed.untracked ? 'Set not tracked' : `Filed in ${shown.filed.name}` + }; the plan didn’t save.` : 'Didn’t save.'} {shown.why} @@ -1409,11 +1477,15 @@ export function DecideCall({ /> ) : ( <> + {/* where it goes, then the answer that files it there */} + {chooser.shown && } act(() => makeCall(row, call, project, undefined, chooser.filed))() } @@ -1421,226 +1493,12 @@ export function DecideCall({ full={opened} quiet={compact} /> - {chooser.shown && } )}

); } -/** - * The Initiative 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. - */ -function useHomeChooser(row: DecideRow, { away = false }: { away?: boolean } = {}) { - const { initiatives } = useInitiativesValue(); - const items = useContext(AllItemsContext); - const asks = row.reasons.some(r => r.kind === 'new' || r.kind === 'unfiled'); - const list = initiatives ?? []; - const current = row.slug ? homesOf(list).get(row.slug)?.initiative ?? null : null; - // `away`: moving it out of the home it has (Track it, Move the project), - // so it starts on the guess - const guess = - (!current || away) && row.slug && list.length ? guessHome(row.slug, items, list) : null; - const [pick, setPick] = useState(null); - const choices = homeChoices(list); - const chosen = pick ?? (away ? guess?.slug : current?.slug ?? guess?.slug) ?? ''; - const to = choices.find(i => i.slug === chosen); - return { - shown: asks && !!row.slug && choices.length > 0, - choices, - chosen, - current, - guessed: (!current || away) && !!guess && chosen === guess.slug, - setPick, - filed: - to && to.slug !== current?.slug - ? ({ to: to.slug, name: to.name, from: current?.slug ?? null } as Filed) - : undefined, - }; -} - -/** The chooser's line: "Initiative [Release safety]", with "guessed" while - * it shows a guess. A select, so the keyboard and a screen reader get the - * platform's own list. */ -function HomeChooser({ - name, - chooser, - label = 'Initiative', -}: { - name: string; - chooser: ReturnType; - label?: string; -}) { - const id = useId(); - return ( -
- - - {chooser.guessed && ( - - guessed - - )} -
- ); -} - -/** - * The answers to a row that asks where a project belongs, or about an - * initiative, in the same shape as CallStrip: the suggested answer filled, - * then Other answers. In no initiative: File in the chooser's pick. Still - * not tracked: Keep not tracked, or Track it, which opens the chooser. Did - * it reach its goal: Close as done, or Close as dropped or Keep open. Its - * initiative closed: Reopen it, or move the project with the chooser. - */ -function HomeStrip({ - row, - name, - describedBy, - onCall, -}: { - row: DecideRow; - name: string; - describedBy?: string; - onCall: (call: Call) => void; -}) { - const [more, setMore] = useState(false); - const primary = primaryOf(row); - const chooser = useHomeChooser(row, { away: primary.kind !== 'unfiled' }); - const initiative = initiativeById(askedInitiative(row)); - const fileTo = (): Call | null => { - const to = chooser.choices.find(i => i.slug === chooser.chosen); - return to ? { kind: 'file', to: to.slug, name: to.name } : null; - }; - const filing = fileTo(); - const options: { call: Call | null; words: string }[] = - primary.kind === 'unfiled' - ? [{ call: filing, words: filing ? answerWords(filing, null) : 'File it' }] - : primary.kind === 'untracked_grew' - ? [ - { call: { kind: 'keep_untracked' }, words: 'Keep not tracked' }, - ...(more && filing?.kind === 'file' && filing.to !== 'not-tracked' - ? [{ call: filing, words: answerWords(filing, null) }] - : []), - ] - : primary.kind === 'goal_reached' - ? [ - { call: { kind: 'close', as: 'done' }, words: 'Close as done' }, - ...(more - ? [ - { call: { kind: 'close', as: 'dropped' } as Call, words: 'Close as dropped' }, - { call: { kind: 'keep_open' } as Call, words: 'Keep open' }, - ] - : []), - ] - : [ - { - call: { kind: 'reopen' }, - words: initiative ? `Reopen ${initiative.name}` : 'Reopen it', - }, - ...(more && filing?.kind === 'file' && filing.to !== initiative?.slug - ? [{ call: filing, words: `Move it to ${filing.name}` }] - : []), - ]; - const moreWords = - primary.kind === 'untracked_grew' - ? 'Track it' - : primary.kind === 'reopened_in_closed' - ? 'Move the project' - : 'Other answers'; - // Track it and Move the project open the chooser; it starts on a guess, - // never on the home being left - const chooses = - primary.kind === 'unfiled' || - (more && (primary.kind === 'untracked_grew' || primary.kind === 'reopened_in_closed')); - const pickable = - primary.kind === 'unfiled' - ? chooser.choices - : chooser.choices.filter( - i => i.kind !== 'untracked' && i.slug !== (initiative?.slug ?? null) - ); - return ( -
-
- {options.map((o, k) => ( - - ))} - {primary.kind !== 'unfiled' && !more && ( - - )} -
- {chooses && pickable.length > 0 && ( - i.slug === chooser.chosen) ? chooser.chosen : '', - }} - label={primary.kind === 'unfiled' ? 'Initiative' : 'Move it to'} - /> - )} -
- ); -} - /** Where a decided plan's work came from: optional, after the call, the * same switch as the roadmap's editor. */ function OriginRun({ @@ -1688,11 +1546,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 */ @@ -1751,7 +1617,7 @@ function CallStrip({ // 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; const button = (o: Option, words = o.label) => ( + ); + })} + {primary.kind !== 'unfiled' && primary.kind !== 'no_project' && !more && ( + + )} + + + ); +} + +/** + * Decide's Initiative switch, beside the team switch: an owner runs their + * slice. A select, since there can be twenty; each names how many are owed, + * and only the ones with something owed 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. + */ +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(); + const counted = (label: string, count: number) => (count ? `${label} · ${count}` : 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, + ].filter(([slug]) => owedIn(slug) > 0 || slug === value); + if (!offered.length) return null; + return ( +
+ + +
+ ); +} diff --git a/frontend-v2/src/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx index b9386e98..517b2e46 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, @@ -62,6 +62,7 @@ import { DecideCall, isHomeReason, planRow, + primaryOf, reasonParts, usePlanCalls, type Call, @@ -669,14 +670,12 @@ export function ProjectPage({ ? { ...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 primaryOf = (row: DecideRow) => - row.reasons.reduce((a, r) => (RANK[r.kind] < RANK[a.kind] ? r : a)); 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 = planAsks.map(row => { - const primary = row.reasons.reduce((a, r) => (RANK[r.kind] < RANK[a.kind] ? r : a)); + 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') @@ -867,7 +866,8 @@ export function ProjectPage({ {untracked && !homeAsks.length && (

Not tracked: it counts in no tile, load or question - {plans && planFor(slug, plans) ? ', and its plan owes no update' : ''}. + {plans && planFor(slug, plans) ? ', and its plan owes no update' : ''}, but + Decide asks again if it doubles in size.

)} diff --git a/shared/model/decide.ts b/shared/model/decide.ts index f14bf752..43c66328 100644 --- a/shared/model/decide.ts +++ b/shared/model/decide.ts @@ -1,5 +1,5 @@ import { dayStart, firstOpenDay, targetOf, utcDay, type Project, type Today } from './projects'; -import { homesOf, type Initiative } from './initiatives'; +import { goingProjects, homesOf, type Initiative } from './initiatives'; import { healthStanding, isUnderWay, planEnd, type RoadmapItem } from './roadmap'; import type { IssueCounts } from './work'; @@ -27,14 +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), four more: + * 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`: every project of an open initiative finished, parked or - * quiet for GOAL_QUIET_DAYS, and nothing changed since Keep open - * - `reopened_in_closed`: a project in a closed initiative has open PRs or - * a plan under way again + * - `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. @@ -74,6 +77,7 @@ export type DecideReason = /** `open`: the plan's PRs still open */ | { 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 } @@ -172,6 +176,7 @@ export const RANK: Record = { reopened_in_closed: 0, goal_reached: 7, unfiled: 10, + no_project: 10, untracked_grew: 10, }; @@ -395,54 +400,60 @@ export function decideQueue({ if (!initiatives.length) return; const homes = homesOf(initiatives); const liveBySlug = new Map(live.map(p => [p.slug, p])); - const going = (slug: string) => (plansOf.get(slug) ?? []).filter(p => isUnderWay(p.status)); - // a project's own row, when it has one already - const rowOf = (slug: string) => [...rows.values()].find(r => r.slug === slug); + 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, so a project is asked + // about once; else on its first plan under way + const ask = (slug: string, reason: DecideReason) => { + const row = [...rows.values()].find(r => r.slug === slug); + 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 => going(slug).length), + ...[...plansOf.keys()].filter(slug => underWay(slug).length), ]); for (const slug of unfiled) { if (homes.has(slug)) continue; - const row = rowOf(slug); // the first call files it too - if (row?.reasons.some(r => r.kind === 'new')) continue; - if (row) row.reasons.push({ kind: 'unfiled' }); - else add(slug, going(slug)[0] ?? null, { kind: 'unfiled' }); + 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 slugs) { - if ((liveBySlug.get(slug)?.open ?? 0) > 0 || going(slug).length) { - add(slug, going(slug)[0] ?? null, { - kind: 'reopened_in_closed', - initiative: initiative.id, - as, - }); - } + for (const slug of going) { + ask(slug, { kind: 'reopened_in_closed', initiative: initiative.id, as }); } continue; } - if (!slugs.length) continue; - // finished, parked or dropped plans, or no plan and nothing in - // flight (Today's live holds the last LIVE_DAYS); when it settled, - // and when anything in it last changed + 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, 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 open = false; + let quiet = true; for (const f of initiative.projects) { const plans = plansOf.get(f.project) ?? []; - const project = liveBySlug.get(f.project); - if (plans.some(p => isUnderWay(p.status)) || (!plans.length && project)) open = true; + 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); - changed = Math.max(changed, settled, f.set_at, project?.lastActivity ?? 0); + changed = Math.max(changed, settled, f.set_at); } - if (open || now - settled < GOAL_QUIET_DAYS * DAY) continue; - // Keep open: asked again only once something in it changes later + 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, From 5ced2a9d0c10dc18171dc9372e84345afd37395b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:00:39 -0700 Subject: [PATCH 013/148] Number lane rows and open linked lanes Roadmap review fixes under Group by Initiative: - Rows are numbered by their place in the lane, under the band's rank; the whole-roadmap priority moves to the grip's label and a hover, so two unrelated numbers no longer sit side by side. - A cold link or a reload to a plan opens its lane: the link effect runs again once the initiatives arrive and the rows sit in lanes. - Plans with no project get a "No project yet" lane, so Unsorted holds projects only and agrees with the Overview, and its hover no longer promises a drag that fails. The plan's details say why it has no home. - While the find narrows, an initiative's own question shows only when the find matches it. - The lane line names the owner as such, a past latest end says so, the Not tracked hover is the shared sentence, and Now/next/later carries the lanes' ranks. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Roadmap.tsx | 102 +++++++++++++++------ 1 file changed, 74 insertions(+), 28 deletions(-) diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 5a3682fb..4dcc14db 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -130,6 +130,9 @@ import { END_WORD, NO_UPDATE_YET, UPDATE_DUE, + NOT_TRACKED_GLOSS, + UNSORTED_WORD, + NO_PROJECT_YET, } from '../../model/words'; import { askedInitiative, askOf, DecideCall, reasonWords, useCallsMadeHere } from './Decide'; import { HomeLine } from './Initiatives'; @@ -227,6 +230,8 @@ const rowMargin = 'scroll-mt-[calc(var(--header-h,0px)_+_var(--roadmap-stuck,0px /** What a plan's span is: its first week and length. */ type Span = { start: string; weeks: number }; +/** the lane, by initiative, of plans with no project */ +const NO_PROJECT_LANE = '(no-project)'; /** no Decide rows yet, the same array each render */ const NO_ROWS: DecideRow[] = []; const NO_SLUGS: ReadonlySet = new Set(); @@ -1024,11 +1029,18 @@ function PlanPanel({ )} )} - {project && navigate && !!initiatives?.length && ( + {navigate && !!initiatives?.length && ( <>
Initiative
- + {project ? ( + + ) : ( + + {NO_PROJECT_YET}: it joins an initiative through its project, once Edit + details links one + + )}
)} @@ -1279,6 +1291,7 @@ function PlanRow({ item, all, rank, + lanePlace, axis, today, linked, @@ -1295,7 +1308,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; @@ -1441,7 +1458,11 @@ function PlanRow({ role="button" tabIndex={0} draggable - aria-label={`${item.name}: priority ${rank}. Drag, or use the up and down arrow keys, to change its place`} + aria-label={`${item.name}: ${ + lanePlace + ? `${lanePlace} in its lane, priority ${rank} on the whole roadmap` + : `priority ${rank}` + }. Drag, or use the up and down arrow keys, to change its place`} title="Drag to change its priority" // padded and widened by .hit to a 24px target, taking no // more room in the row. The rank already says the rows are @@ -1452,8 +1473,11 @@ function PlanRow({ )} - - {rank || ''} + + {lanePlace ?? (rank || '')} @@ -2430,9 +2454,10 @@ export function Roadmap({ const byInitiative = nav.group === 'initiative' && !!initiatives?.length; const homes = homesOf(initiatives ?? []); const homeOf = (slug: string | null) => (slug ? homes.get(slug)?.initiative : undefined); - // the lane a plan or project sits in by initiative: its home's slug, or - // Unsorted + // the lane a plan or project sits in by initiative: its home's slug, + // Unsorted, or for a plan with no project, the lane of those const laneKey = (slug: string | null) => { + if (!slug) return NO_PROJECT_LANE; const home = homeOf(slug); return home && home.kind !== 'untracked' ? home.slug : UNSORTED; }; @@ -2775,14 +2800,15 @@ export function Roadmap({ const slug = item.project; if (!slug) { return setAnnounced( - `${item.name} has no project, so it can’t be filed. Give it a project in Edit details first.` + `${item.name} has no project yet, so it can’t be filed. Link it to its project in Edit details first.` ); } + if (to === NO_PROJECT_LANE) return; const from = homeOf(slug)?.slug ?? null; const target = to === UNSORTED ? null : to; const toName = target ? byRank(initiatives ?? []).find(i => i.slug === target)?.name ?? target - : 'Unsorted'; + : UNSORTED_WORD; run.current = null; void fileProject(slug, target, { expect: from }).then(r => { if ('error' in r) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); @@ -2985,7 +3011,9 @@ export function Roadmap({ 50 ); return () => window.clearTimeout(wait); - }, [loaded, nav.item, panel]); + // again once the initiatives arrive and lay the rows out in lanes, so + // a cold link or a reload opens the lane too + }, [loaded, nav.item, panel, byInitiative]); useEffect(() => { if (adding) scrollClear(document.getElementById('roadmap-new')); }, [adding]); @@ -3020,10 +3048,10 @@ export function Roadmap({ const planRows = ( list: RoadmapItem[], visible: RoadmapItem[] = list, - { ranked = true }: { ranked?: boolean } = {} + { ranked = true, placed = false }: { ranked?: boolean; placed?: boolean } = {} ) => { const visibleIds = visible.map(i => i.id); - return list.map(item => { + return list.map((item, at) => { const key = `plan:${item.id}`; // removed just now: its receipt stands where the row was if (item === gone && receipt) { @@ -3046,6 +3074,7 @@ export function Roadmap({ item={item} all={ordered} rank={ranked ? ids.indexOf(item.id) + 1 : 0} + lanePlace={placed ? at + 1 : undefined} axis={axis} today={today} linked={linked} @@ -3196,6 +3225,8 @@ export function Roadmap({ 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 + if (key === NO_PROJECT_LANE) return; e.preventDefault(); setOverLane(key); }, @@ -3259,12 +3290,18 @@ export function Roadmap({ }; const initiativeBody = (() => { const ranked = laneInitiatives.filter(i => i.status === 'open'); - const keys = [...laneInitiatives.map(i => i.slug), UNSORTED]; + 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) : []; - const asked = initiative ? initiativeRowOf(initiative.id) : undefined; + // 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; @@ -3278,7 +3315,11 @@ export function Roadmap({ 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}` : 'Unsorted'; + const label = initiative + ? `${rank ? `${rank} ` : ''}${initiative.name}` + : key === UNSORTED + ? UNSORTED_WORD + : NO_PROJECT_YET; return (
{initiative.owner && ( - person(initiative.owner as string)} - title={`${initiative.owner} owns ${initiative.name}`} - > - {initiative.owner} - + + owned by{' '} + person(initiative.owner as string)} + title={`See ${initiative.owner} on People`} + > + {initiative.owner} + + )} {initiative.goal && {initiative.goal}}
@@ -3337,7 +3382,7 @@ export function Roadmap({ {receipt?.key === `initiative:${initiative?.id}` && ( )} - {planRows(planned, planned)} + {planRows(planned, planned, { placed: true })} {inFlightRows(loose)} {/* a rank move's receipt stays in view with the lane closed */} @@ -3375,7 +3420,7 @@ export function Roadmap({ id="roadmap:not-tracked" label="Not tracked" count={(showPlans ? notTrackedPlans.length : 0) + notTrackedLoose.length} - gloss="Projects set not tracked: personal and team workflow nobody reports on. They count in no load, capacity line or question, have no place in the order, and owe no update. The most recent PR first." + gloss={`${NOT_TRACKED_GLOSS} They have no place in the order; the most recent PR first.`} load={null} lined={false} defaultOpen={false} @@ -3889,9 +3934,10 @@ export function Roadmap({ ? [ ...laneInitiatives.map(i => ({ key: i.slug as string | null, - title: i.name, + title: rankOf(i) ? `${rankOf(i)} ${i.name}` : i.name, })), - { key: UNSORTED, title: 'Unsorted' }, + { key: UNSORTED, title: UNSORTED_WORD }, + { key: NO_PROJECT_LANE, title: NO_PROJECT_YET }, ] : null } From b4920c36f27d1fa06ffcfc914b497cdcb19bd846 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:00:39 -0700 Subject: [PATCH 014/148] Keep an asked initiative's band on the Overview Onboarding's "Did it reach its goal?" counted in the To decide tile but had no band on the Overview, since none of its projects is being worked on. An initiative Decide asks about now keeps its band in the tab, with the goal, the question and its answers inside. The band's hover is the Roadmap's (goal, total, working, owner), and the Not tracked group uses the shared sentence. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Portfolio.tsx | 71 +++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx index 167b73a7..8cf4ac4e 100644 --- a/frontend-v2/src/views/projects/Portfolio.tsx +++ b/frontend-v2/src/views/projects/Portfolio.tsx @@ -47,8 +47,11 @@ import { devDays, LAST_14_DAYS, NO_PLAN, + GOAL, + NOT_TRACKED_GLOSS, } from '../../model/words'; -import { DecideCall, planRow, reasonWords } from './Decide'; +import { askedInitiative, askOf, DecideCall, planRow, reasonWords } from './Decide'; +import { bandGloss as initiativeGloss } from '../../model/initiativeBoard'; import { BY_PRS_WHY, BY_PRS_WHY_NO_REPO, @@ -789,13 +792,39 @@ 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, initiative?: Initiative): 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 in no initiative yet. Decide asks where each one goes.'; - const owner = initiative.owner ? ` Owner: ${initiative.owner}.` : ''; - const closed = initiative.status === 'open' ? '' : ` Closed as ${initiative.status}.`; - return `${initiative.goal || initiative.name}${owner}${closed}`; + 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`; @@ -1068,6 +1097,7 @@ export function Portfolio( ) { const { items, + calls, placeOf = item => item, nameOf, teams, @@ -1116,7 +1146,21 @@ export function Portfolio( // 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; - const groups = groupItems(shown, by, nameOf, teams, initiatives); + // 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( @@ -1298,15 +1342,22 @@ export function Portfolio( key={g.title} count={g.items.length} label={g.title} - gloss={bandGloss(by, g.title, g.initiative)} + gloss={bandGloss(by, g.title, g.initiative, items)} detail={bandDetail(g.initiative)} // logins keep their case caps={by !== 'lead'} + keepEmpty={!!g.initiative && initiativeAsks.has(g.initiative.id)} id={`portfolio:${by}:${ g.initiative?.slug ?? g.title.replace(/\s+/g, '-') }`} defaultOpen > + {g.initiative && initiativeAsks.has(g.initiative.id) && ( + + )} Date: Mon, 12 Oct 2026 00:00:40 -0700 Subject: [PATCH 015/148] Split Unsorted from one-offs in Look back Look back's split by initiative lumped Unsorted projects, small work and PRs in no project into one "No initiative" row, which read as nearly half the team working outside any initiative. They are now Unsorted, Not tracked, "Small work, no project" and "Not in a project", the Projects table's words, after the initiatives in rank. Not tracked is one group by kind, whatever its initiative's slug. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Retro.tsx | 39 ++++++++---------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/frontend-v2/src/views/projects/Retro.tsx b/frontend-v2/src/views/projects/Retro.tsx index 2d0dc41f..45a57ed7 100644 --- a/frontend-v2/src/views/projects/Retro.tsx +++ b/frontend-v2/src/views/projects/Retro.tsx @@ -11,7 +11,11 @@ import { ArrowDown, ArrowUp, ChevronRight } from 'lucide-react'; import { n, pullKey, shortRepo } from '../../../../shared/format'; import { MISC_SLUG } from '../../../../shared/model/projects'; import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; -import { byRank } from '../../model/initiativesData'; +import { + initiativeSplitKey, + initiativeSplitLabel, + initiativeSplitRank, +} from '../../model/initiativeBoard'; import { ORIGIN_WORD, planFor, type RoadmapItem } from '../../../../shared/model/roadmap'; import type { DerivedPull } from '../../../../shared/model/status'; import type { PullData } from '../../../../shared/types'; @@ -114,9 +118,6 @@ const UNPLANNED = 'no-plan'; const UNSAID = 'unsaid'; /** the team key for people on no developer team, as the URL spells it */ const NO_TEAM = '(none)'; -/** the initiative split's group for days on no project, a one-off, or a - * project in no initiative */ -const NO_INITIATIVE = '(none)'; const AUTHOR_WORDS: Record = { own: 'Their own PRs', @@ -218,13 +219,7 @@ 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, initiatives: readonly Initiative[] = []): string { - if (split === 'initiative') { - if (key === NO_INITIATIVE) return 'No initiative'; - return ( - initiatives.find(i => i.slug === key)?.name ?? - (key === 'not-tracked' ? 'Not tracked' : key) - ); - } + 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; @@ -1229,15 +1224,10 @@ 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, or none + // 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) => { - const project = r.pr.project; - if (!project || project === MISC_SLUG) return NO_INITIATIVE; - const home = homes.get(project)?.initiative; - if (home) return home.slug; - return untracked.has(project) ? 'not-tracked' : NO_INITIATIVE; - }; + const initiativeKey = (r: RetroRow) => initiativeSplitKey(r.pr.project, homes, untracked); const keyOf: Record string> = { origin: originOf, initiative: initiativeKey, @@ -1257,14 +1247,9 @@ 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, Not tracked among them, none last - const order = byRank(initiatives ?? []).map(i => i.slug); - const rank = (key: string) => - key === NO_INITIATIVE - ? order.length + 1 - : order.indexOf(key) < 0 - ? order.length - : order.indexOf(key); + // 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; From 586a9cd676528b43cefa3db21c5141b0a53ef4e6 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:00:40 -0700 Subject: [PATCH 016/148] Record lane numbering and the Unsorted word DESIGN.md's initiative section now says how lane rows are numbered, that "Unsorted" is the one word for a project filed nowhere, and where a plan with no project goes. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index ebc2eca3..9612350b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -848,6 +848,14 @@ icon with a class bolted on. 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 rank; 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. + "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 From 49334b129402109bc356dfee329df6f0a448471c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:27:56 -0700 Subject: [PATCH 017/148] Count a filing as settling in goal_reached "Did it reach its goal?" waited two weeks after the last plan stopped, but a filing's set_at only counted toward Keep open. Filing a project whose plan finished long ago asked at once, so the load's 242 filings would have asked on day one, against "nobody owes anything new". A project now settles in an initiative two weeks after it is filed there too. Keep open still comes back on a later filing, two weeks on. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/decide.test.ts | 35 ++++++++++++++++++++++++---- shared/model/decide.ts | 6 +++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts index cea0642f..aa0c1a04 100644 --- a/frontend-v2/src/model/decide.test.ts +++ b/frontend-v2/src/model/decide.test.ts @@ -948,21 +948,46 @@ describe('decideQueue: where projects belong', () => { 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 - const kept = [initiative(7, ['a', 'b', 'c'], { status_at: ago(10) })]; + // 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 = [ + const refiled = (at: number) => [ { ...kept[0], - projects: [...kept[0].projects, { project: 'd', set_by: null, set_at: ago(1), baseline_prs: 0 }], + projects: [...kept[0].projects, { project: 'd', set_by: null, set_at: at, baseline_prs: 0 }], }, ]; - expect(ask({ ...base, live: [], initiatives: refiled })).toEqual([['#undefined', 'goal_reached']]); + 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 })], diff --git a/shared/model/decide.ts b/shared/model/decide.ts index 43c66328..ca606f0c 100644 --- a/shared/model/decide.ts +++ b/shared/model/decide.ts @@ -440,7 +440,9 @@ export function decideQueue({ 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, and when a plan or filing in it last changed. PR + // 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; @@ -449,7 +451,7 @@ export function decideQueue({ 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); + 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; From e3cee2d447d09349c28cd8db982d7988b27f8bab Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:27:57 -0700 Subject: [PATCH 018/148] Load the roadmap when initiatives can't be read GET /roadmap and every roadmap write's reply awaited the tracking read beside lately(). A failed read of the initiatives tables 500ed the roadmap, which never depended on them before. The roadmap now reads every plan as tracked and logs it, as lately() does; /decide and /load still fail loud, since their counts would be wrong. Co-Authored-By: Claude Opus 5.5 --- controllers/roadmap.js | 14 +++++++++++++- test/roadmap.test.js | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/controllers/roadmap.js b/controllers/roadmap.js index 37c6b104..078057ad 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -90,6 +90,18 @@ 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 @@ -100,7 +112,7 @@ async function lately() { * the standing is read, so a not-tracked plan owes nothing. */ async function sent(items) { - const [bySlug, { untracked }] = await Promise.all([lately(), trackedNow()]); + const [bySlug, untracked] = await Promise.all([lately(), untrackedNow()]); const now = Date.now() / 1000; const today = teamDay(now); return items.map(item => { diff --git a/test/roadmap.test.js b/test/roadmap.test.js index bf604ad7..bdb4ff02 100644 --- a/test/roadmap.test.js +++ b/test/roadmap.test.js @@ -561,6 +561,24 @@ async function withProjects(issues, run) { } } +test('the roadmap still loads when the initiatives can’t be read, every plan tracked', 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); + }); + 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( [ From 05c0cf889afbd2b8f25f4a681ee93f0bf2be9494 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:27:57 -0700 Subject: [PATCH 019/148] Test the close guard's open-PR branch The close guard was tested at the endpoint with plans only. Its open-PR branch, the round-1 bug, was covered only through decideQueue. A done plan with a labeled PR still open on the board now 409s at PATCH, and closes once the PR does. Co-Authored-By: Claude Opus 5.5 --- test/initiatives.test.js | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/initiatives.test.js b/test/initiatives.test.js index 4f402587..00562f75 100644 --- a/test/initiatives.test.js +++ b/test/initiatives.test.js @@ -2,7 +2,11 @@ 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 { trackedOnly } from '../lib/initiatives.js'; import initiativesController from '../controllers/initiatives.js'; import { canWrite } from '../controllers/roadmap.js'; @@ -84,6 +88,8 @@ function fakeQuery(sql, params = []) { filings = [...filings.filter(f => 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}`); } @@ -258,6 +264,57 @@ test('an initiative closes only once nothing in it is going', async () => { assert.ok(closed.body.initiative.status_at > 0); }); +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' }); From 3b1f9ea6bc4278a8d9e92c0cdd02d181051ae64c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:27:58 -0700 Subject: [PATCH 020/148] Push onto lists instead of copying them listInitiatives, grownSlugs and the Overview's initiative groups built each list by spreading it again per row, quadratic in a project's PRs. grownSlugs now counts the PRs after the filing directly, since only the count is used. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/portfolio.ts | 4 +++- lib/initiatives.js | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts index 27807abe..5cb7fa7a 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -897,7 +897,9 @@ function byInitiative( for (const item of items) { const home = homes.get(item.slug)?.initiative; const key = home && home.kind !== 'untracked' ? home.id : null; - groups.set(key, [...(groups.get(key) ?? []), item]); + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); } const ranked = [...initiatives].sort( (a, b) => diff --git a/lib/initiatives.js b/lib/initiatives.js index 6409afb2..80b77896 100644 --- a/lib/initiatives.js +++ b/lib/initiatives.js @@ -59,7 +59,8 @@ export async function listInitiatives() { const byInitiative = new Map(); for (const f of filings) { const id = Number(f.initiative_id); - byInitiative.set(id, [...(byInitiative.get(id) ?? []), filingFromRow(f)]); + if (!byInitiative.has(id)) byInitiative.set(id, []); + byInitiative.get(id).push(filingFromRow(f)); } return rows.map(r => initiativeFromRow(r, byInitiative.get(Number(r.id)) ?? [])); } @@ -294,17 +295,18 @@ export async function grownSlugs(settings, initiatives, untracked) { 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` WHERE l.`title` IN (?)', [[...untracked].map(slug => settings.prefix + slug)] ); - const dates = new Map(); + const filingOf = slug => { + const home = homes.get(slug); + return home?.initiative.kind === 'untracked' ? home.filing : null; + }; + const sinceOf = new Map(); for (const { title, date } of rows) { const slug = title.slice(settings.prefix.length); - dates.set(slug, [...(dates.get(slug) ?? []), Number(date)]); + if (Number(date) > (filingOf(slug)?.set_at ?? 0)) sinceOf.set(slug, (sinceOf.get(slug) ?? 0) + 1); } for (const slug of untracked) { - const home = homes.get(slug); - const filing = home?.initiative.kind === 'untracked' ? home.filing : null; - const setAt = filing?.set_at ?? 0; - const baseline = filing?.baseline_prs ?? 0; - const since = (dates.get(slug) ?? []).filter(d => d > setAt).length; + const since = sinceOf.get(slug) ?? 0; + const baseline = filingOf(slug)?.baseline_prs ?? 0; if (grew(since, baseline)) out.set(slug, { since, baseline }); } return out; From b107035084da944441887596fd1b7f889ba5775e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:37:13 -0700 Subject: [PATCH 021/148] Move Decide's question model and the lanes out Decide.tsx and Initiatives.tsx imported each other, which works only while neither reads the other's bindings at load; and Decide.tsx and Roadmap.tsx had grown by about 450 lines each. This commit moves code and changes nothing it does: - model/decideCalls.ts: Decide's question model with no components (sections, what each reason asks, answer and call words, the Call and Filed types, AllItemsContext, arrowsAmong). - HomeCalls.tsx: the four home parts Decide draws (useHomeChooser, HomeChooser, HomeStrip, InitiativeSwitch), reading only the model. - RoadmapLanes.tsx: the initiative lanes (fileByDrag, rankMove, the lane drop and grip, the lanes themselves) as initiativeLanes(), with TimelineLane and ReceiptLine, which they draw. Importers now read the model directly. madge finds master's two cycles and no new one. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/decide.test.ts | 16 +- frontend-v2/src/model/decideCalls.ts | 770 +++++++++++++++++ frontend-v2/src/views/Projects.tsx | 3 +- frontend-v2/src/views/projects/Decide.tsx | 777 +----------------- .../src/views/projects/DecideCall.test.tsx | 3 +- .../src/views/projects/HomeCalls.test.tsx | 14 +- frontend-v2/src/views/projects/HomeCalls.tsx | 344 ++++++++ .../src/views/projects/Initiatives.tsx | 346 +------- frontend-v2/src/views/projects/Portfolio.tsx | 3 +- .../src/views/projects/ProjectPage.tsx | 6 +- frontend-v2/src/views/projects/Roadmap.tsx | 422 ++-------- .../src/views/projects/RoadmapLanes.tsx | 481 +++++++++++ 12 files changed, 1703 insertions(+), 1482 deletions(-) create mode 100644 frontend-v2/src/model/decideCalls.ts create mode 100644 frontend-v2/src/views/projects/HomeCalls.tsx create mode 100644 frontend-v2/src/views/projects/RoadmapLanes.tsx diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts index aa0c1a04..2d318369 100644 --- a/frontend-v2/src/model/decide.test.ts +++ b/frontend-v2/src/model/decide.test.ts @@ -13,21 +13,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'; diff --git a/frontend-v2/src/model/decideCalls.ts b/frontend-v2/src/model/decideCalls.ts new file mode 100644 index 00000000..45e3ab2e --- /dev/null +++ b/frontend-v2/src/model/decideCalls.ts @@ -0,0 +1,770 @@ +/** + * 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 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, + 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 move the project to 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: [ + 'Set 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) => + 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 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'; + case 'file': + return call.untracked ? 'Set 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 set 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': + return `Promised to finish by ${call.through}${ + home ? `, ${home}` : '' + }. Decide asks again if it runs past that.`; + case 'file': + return call.untracked + ? 'Set not tracked, so it counts nowhere. Decide asks again if it grows.' + : `Filed in ${call.name}.`; + case 'link': + return `Linked to ${call.name}, so it goes where that project is filed.`; + 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/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index 0aafa129..0e3bb24f 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -30,7 +30,8 @@ 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 { AllItemsContext, Decide, decideRows, useKeptCalls } from './projects/Decide'; +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'; diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 6e266ec1..9f9a21b5 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -1,12 +1,10 @@ import { - createContext, useCallback, useEffect, useId, useMemo, useRef, useState, - type KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { Check } from 'lucide-react'; import { epoch, issueUrl, n } from '../../../../shared/format'; @@ -14,25 +12,18 @@ import { compareRows, decideProjects, decideQueue, - DECIDE_MIN_PRS, - STALL_DAYS, type ClosedIssue, type DecideReason, type DecideRow, type GrownProject, } from '../../../../shared/model/decide'; import type { Initiative } from '../../../../shared/model/initiatives'; -import { firstOpenDay, projectSlugs, type Today } from '../../../../shared/model/projects'; +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, @@ -64,7 +55,6 @@ 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, @@ -84,14 +74,9 @@ import { GOAL, IN_PROGRESS, LAST_14_DAYS, - missedTarget, - NEEDS_A_PLAN, - noPrActivity, ONGOING, - pastEnd, PLAN_IT, targetOn, - NO_PROJECT_YET, UNSORTED_WORD, } from '../../model/words'; import type { WorkData } from '../../model/workData'; @@ -115,8 +100,39 @@ import { type Navigate, type ProjectsNav, } from './parts'; -import { PLAN_STATUS_WORD, vouchWords, when } from './roadmapHealth'; -import { HomeChooser, HomeStrip, InitiativeSwitch, useHomeChooser } from './Initiatives'; +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 { HomeChooser, HomeStrip, InitiativeSwitch, useHomeChooser } from './HomeCalls'; /** The decisions owed now, worst first, for this view, the tab's label and * the Overview's tile. `today` and `items` are the tracked ones; the @@ -156,714 +172,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_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 move the project to 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: [ - 'Set 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.', - ], - }, -]; - -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. */ -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; -}; -const rowKey = (row: DecideRow) => - !row.slug && !row.item && askedInitiative(row) != null - ? `initiative:${askedInitiative(row)}` - : `${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 ?? - 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. */ -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([]); - -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' } - /** 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. */ -type PlanCallKind = Extract; -/** The calls that write a filing or an initiative's status. */ -type HomeCall = Extract< - Call, - { kind: 'file' | 'keep_untracked' | 'link' | 'close' | 'keep_open' | 'reopen' } ->; -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". */ -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. */ -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. */ -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) - ); -} - -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 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'; - case 'file': - return call.untracked ? 'Set 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. - */ -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 set 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?"). */ -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: 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. */ -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': - return `Promised to finish by ${call.through}${ - home ? `, ${home}` : '' - }. Decide asks again if it runs past that.`; - case 'file': - return call.untracked - ? 'Set not tracked, so it counts nowhere. Decide asks again if it grows.' - : `Filed in ${call.name}.`; - case 'link': - return `Linked to ${call.name}, so it goes where that project is filed.`; - 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}.`; - } - } -} - /** 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 { @@ -1286,23 +594,6 @@ function sayOrigin(key: string, origin: RoadmapOrigin | null): void { }); } -/** 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(); -} - /** * 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 diff --git a/frontend-v2/src/views/projects/DecideCall.test.tsx b/frontend-v2/src/views/projects/DecideCall.test.tsx index 1b0b36bb..4bdef173 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; diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index cacd6ac3..15ce43ff 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -7,17 +7,11 @@ import type { Initiative } from '../../../../shared/model/initiatives'; import type { RoadmapItem } from '../../../../shared/model/roadmap'; import { loadInitiatives } from '../../model/initiativesData'; import type { PortfolioItem } from '../../model/portfolio'; -import { HomeLine, InitiativePage, InitiativeSwitch } from './Initiatives'; +import { HomeLine, InitiativePage } from './Initiatives'; +import { InitiativeSwitch } from './HomeCalls'; import type { ProjectsNav } from './parts'; -import { - AllItemsContext, - answerWords, - callWords, - DecideCall, - DecideRowView, - keepCalls, - type Made, -} from './Decide'; +import { DecideCall, DecideRowView, keepCalls, type Made } from './Decide'; +import { AllItemsContext, answerWords, callWords } 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; diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx new file mode 100644 index 00000000..4cc998f7 --- /dev/null +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -0,0 +1,344 @@ +/** + * The home answers Decide draws on a row: the File it 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, useState } from 'react'; +import type { DecideRow } from '../../../../shared/model/decide'; +import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; +import { textInputClass } from '../../components/bits'; +import { byRank, useInitiativesValue } from '../../model/initiativesData'; +import { guessHome, homeChoices, UNSORTED } from '../../model/initiativeBoard'; +import { UNSORTED_WORD } from '../../model/words'; +import { + AllItemsContext, + answerWords, + arrowsAmong, + askedInitiative, + initiativeById, + primaryOf, + type Call, + type Filed, +} from '../../model/decideCalls'; + +/** The chooser's label, on every question that files a project. */ +const FILE_IT_IN = 'File it in'; + +/** + * The File it 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 asks = row.reasons.some(r => r.kind === 'new' || r.kind === 'unfiled'); + const list = initiatives ?? []; + const current = row.slug ? homesOf(list).get(row.slug)?.initiative ?? null : null; + // `away`: moving it out of the home it has (Track it, Move the project), + // so it starts on the guess + const guess = + (!current || away) && row.slug && list.length ? guessHome(row.slug, items, list) : null; + const [pick, setPick] = useState(null); + const choices = homeChoices(list); + const chosen = pick ?? (away ? guess?.slug : current?.slug ?? guess?.slug) ?? ''; + const to = choices.find(i => i.slug === chosen); + return { + shown: asks && !!row.slug && choices.length > 0, + choices, + chosen, + current, + picked: pick != null, + guessed: pick == null && (!current || away) && !!guess && chosen === guess.slug, + setPick, + filed: + to && to.slug !== current?.slug + ? ({ + to: to.slug, + name: to.name, + from: current?.slug ?? null, + untracked: to.kind === 'untracked', + } as Filed) + : undefined, + }; +} + +/** The chooser's line, above the answers that use it: "File it in [Release + * safety]", with "guessed" while it shows the board's guess. A select, so + * the keyboard and a screen reader get the platform's own list. */ +export function HomeChooser({ + name, + chooser, +}: { + name: string; + chooser: Pick, 'chosen' | 'choices' | 'guessed' | 'setPick'>; +}) { + const id = useId(); + return ( +
+ + + {chooser.guessed && ( + + guessed + + )} +
+ ); +} + +/** A plan's project chooser, for a plan with no project: "Its project [▾]". */ +function ProjectChooser({ + name, + value, + onChange, +}: { + name: string; + value: string; + onChange: (slug: string) => void; +}) { + const id = useId(); + const items = useContext(AllItemsContext); + const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name)); + 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. 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 move the project 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, +}: { + row: DecideRow; + name: string; + describedBy?: string; + onCall: (call: Call) => void; +}) { + const [more, setMore] = useState(false); + const [project, setProject] = useState(''); + const items = useContext(AllItemsContext); + const primary = primaryOf(row); + const chooser = useHomeChooser(row, { away: primary.kind !== 'unfiled' }); + const initiative = initiativeById(askedInitiative(row)); + // a Track it or a move goes to an initiative, never Not tracked or the + // closed one it's in + const pickable = + primary.kind === 'unfiled' + ? chooser.choices + : chooser.choices.filter(i => i.kind !== 'untracked' && i.slug !== initiative?.slug); + 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 fileOption = { call: filing, words: filing ? answerWords(filing, null) : 'File it' }; + 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' + ? [{ ...fileOption, suggested: chooser.guessed }] + : primary.kind === 'no_project' + ? [ + { + call: linked ? { kind: 'link', project: linked.slug, name: linked.name } : null, + words: linked ? `Link to ${linked.name}` : 'Link it', + }, + ] + : primary.kind === 'untracked_grew' + ? [ + { + call: notTracked ? { kind: 'keep_untracked', to: notTracked.slug } : null, + words: 'Keep not tracked', + suggested: true, + }, + ...(more ? [fileOption] : []), + ] + : primary.kind === 'goal_reached' + ? [ + { call: { kind: 'close', as: 'done' }, words: 'Close as done', suggested: true }, + ...(more + ? [ + { call: { kind: 'close', as: 'dropped' } as Call, words: 'Close as dropped' }, + { call: { kind: 'keep_open' } as Call, words: 'Keep open' }, + ] + : []), + ] + : [ + { + call: { kind: 'reopen' }, + words: initiative ? `Reopen ${initiative.name}` : 'Reopen it', + suggested: true, + }, + ...(more ? [fileOption] : []), + ]; + const moreWords = + primary.kind === 'untracked_grew' + ? 'Track it' + : primary.kind === 'reopened_in_closed' + ? 'Move the project' + : 'Other answers'; + // Track it and Move the project open the chooser; it starts on a guess, + // never on the home being left + const chooses = + primary.kind === 'unfiled' || + (more && (primary.kind === 'untracked_grew' || primary.kind === 'reopened_in_closed')); + return ( +
+ {primary.kind === 'no_project' && ( + + )} + {chooses && pickable.length > 0 && ( + + )} +
+ {options.map((o, k) => { + const filled = k === 0 && !!o.call; + return ( + + ); + })} + {primary.kind !== 'unfiled' && primary.kind !== 'no_project' && !more && ( + + )} +
+
+ ); +} + +/** + * Decide's Initiative switch, beside the team switch: an owner runs their + * slice. A select, since there can be twenty; each names how many are owed, + * and only the ones with something owed 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. + */ +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(); + const counted = (label: string, count: number) => (count ? `${label} · ${count}` : 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, + ].filter(([slug]) => owedIn(slug) > 0 || slug === value); + if (!offered.length) return null; + return ( +
+ + +
+ ); +} diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index 644f13b5..0e742ddb 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { Check } from 'lucide-react'; import { n } from '../../../../shared/format'; import type { DecideRow } from '../../../../shared/model/decide'; @@ -19,29 +19,13 @@ import { import { EmptyState, FactLink, QuietButton, textInputClass } from '../../components/bits'; import { Fold, Rows } from '../../components/Lane'; import { Icon } from '../../components/Icon'; -import { - byRank, - fileProject, - updateInitiative, - useInitiativesValue, - type Home, -} from '../../model/initiativesData'; -import { guessHome, homeChoices, UNSORTED } from '../../model/initiativeBoard'; +import { fileProject, updateInitiative, type Home } from '../../model/initiativesData'; +import { homeChoices } from '../../model/initiativeBoard'; import { dayWords } from '../../model/projectData'; import { stageWord, type PortfolioItem } from '../../model/portfolio'; import { andList, UNSORTED_WORD } from '../../model/words'; -import { - AllItemsContext, - answerWords, - arrowsAmong, - askedInitiative, - DecideCall, - initiativeById, - primaryOf, - reasonParts, - type Call, - type Filed, -} from './Decide'; +import { DecideCall } from './Decide'; +import { askedInitiative, primaryOf, reasonParts } from '../../model/decideCalls'; import { openPlan, type Navigate, type ProjectsNav } from './parts'; import { PLAN_STATUS_WORD, planWords } from './roadmapHealth'; @@ -629,323 +613,3 @@ export function InitiativePage({ ); } - -/** The chooser's label, on every question that files a project. */ -const FILE_IT_IN = 'File it in'; - -/** - * The File it 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 asks = row.reasons.some(r => r.kind === 'new' || r.kind === 'unfiled'); - const list = initiatives ?? []; - const current = row.slug ? homesOf(list).get(row.slug)?.initiative ?? null : null; - // `away`: moving it out of the home it has (Track it, Move the project), - // so it starts on the guess - const guess = - (!current || away) && row.slug && list.length ? guessHome(row.slug, items, list) : null; - const [pick, setPick] = useState(null); - const choices = homeChoices(list); - const chosen = pick ?? (away ? guess?.slug : current?.slug ?? guess?.slug) ?? ''; - const to = choices.find(i => i.slug === chosen); - return { - shown: asks && !!row.slug && choices.length > 0, - choices, - chosen, - current, - picked: pick != null, - guessed: pick == null && (!current || away) && !!guess && chosen === guess.slug, - setPick, - filed: - to && to.slug !== current?.slug - ? ({ - to: to.slug, - name: to.name, - from: current?.slug ?? null, - untracked: to.kind === 'untracked', - } as Filed) - : undefined, - }; -} - -/** The chooser's line, above the answers that use it: "File it in [Release - * safety]", with "guessed" while it shows the board's guess. A select, so - * the keyboard and a screen reader get the platform's own list. */ -export function HomeChooser({ - name, - chooser, -}: { - name: string; - chooser: Pick, 'chosen' | 'choices' | 'guessed' | 'setPick'>; -}) { - const id = useId(); - return ( -
- - - {chooser.guessed && ( - - guessed - - )} -
- ); -} - -/** A plan's project chooser, for a plan with no project: "Its project [▾]". */ -function ProjectChooser({ - name, - value, - onChange, -}: { - name: string; - value: string; - onChange: (slug: string) => void; -}) { - const id = useId(); - const items = useContext(AllItemsContext); - const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name)); - 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. 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 move the project 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, -}: { - row: DecideRow; - name: string; - describedBy?: string; - onCall: (call: Call) => void; -}) { - const [more, setMore] = useState(false); - const [project, setProject] = useState(''); - const items = useContext(AllItemsContext); - const primary = primaryOf(row); - const chooser = useHomeChooser(row, { away: primary.kind !== 'unfiled' }); - const initiative = initiativeById(askedInitiative(row)); - // a Track it or a move goes to an initiative, never Not tracked or the - // closed one it's in - const pickable = - primary.kind === 'unfiled' - ? chooser.choices - : chooser.choices.filter(i => i.kind !== 'untracked' && i.slug !== initiative?.slug); - 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 fileOption = { call: filing, words: filing ? answerWords(filing, null) : 'File it' }; - 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' - ? [{ ...fileOption, suggested: chooser.guessed }] - : primary.kind === 'no_project' - ? [ - { - call: linked ? { kind: 'link', project: linked.slug, name: linked.name } : null, - words: linked ? `Link to ${linked.name}` : 'Link it', - }, - ] - : primary.kind === 'untracked_grew' - ? [ - { - call: notTracked ? { kind: 'keep_untracked', to: notTracked.slug } : null, - words: 'Keep not tracked', - suggested: true, - }, - ...(more ? [fileOption] : []), - ] - : primary.kind === 'goal_reached' - ? [ - { call: { kind: 'close', as: 'done' }, words: 'Close as done', suggested: true }, - ...(more - ? [ - { call: { kind: 'close', as: 'dropped' } as Call, words: 'Close as dropped' }, - { call: { kind: 'keep_open' } as Call, words: 'Keep open' }, - ] - : []), - ] - : [ - { - call: { kind: 'reopen' }, - words: initiative ? `Reopen ${initiative.name}` : 'Reopen it', - suggested: true, - }, - ...(more ? [fileOption] : []), - ]; - const moreWords = - primary.kind === 'untracked_grew' - ? 'Track it' - : primary.kind === 'reopened_in_closed' - ? 'Move the project' - : 'Other answers'; - // Track it and Move the project open the chooser; it starts on a guess, - // never on the home being left - const chooses = - primary.kind === 'unfiled' || - (more && (primary.kind === 'untracked_grew' || primary.kind === 'reopened_in_closed')); - return ( -
- {primary.kind === 'no_project' && ( - - )} - {chooses && pickable.length > 0 && ( - - )} -
- {options.map((o, k) => { - const filled = k === 0 && !!o.call; - return ( - - ); - })} - {primary.kind !== 'unfiled' && primary.kind !== 'no_project' && !more && ( - - )} -
-
- ); -} - -/** - * Decide's Initiative switch, beside the team switch: an owner runs their - * slice. A select, since there can be twenty; each names how many are owed, - * and only the ones with something owed 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. - */ -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(); - const counted = (label: string, count: number) => (count ? `${label} · ${count}` : 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, - ].filter(([slug]) => owedIn(slug) > 0 || slug === value); - if (!offered.length) return null; - return ( -
- - -
- ); -} diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx index 8cf4ac4e..8840dc03 100644 --- a/frontend-v2/src/views/projects/Portfolio.tsx +++ b/frontend-v2/src/views/projects/Portfolio.tsx @@ -50,7 +50,8 @@ import { GOAL, NOT_TRACKED_GLOSS, } from '../../model/words'; -import { askedInitiative, askOf, DecideCall, planRow, reasonWords } from './Decide'; +import { DecideCall, planRow } from './Decide'; +import { askedInitiative, askOf, reasonWords } from '../../model/decideCalls'; import { bandGloss as initiativeGloss } from '../../model/initiativeBoard'; import { BY_PRS_WHY, diff --git a/frontend-v2/src/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx index 517b2e46..110f4681 100644 --- a/frontend-v2/src/views/projects/ProjectPage.tsx +++ b/frontend-v2/src/views/projects/ProjectPage.tsx @@ -57,16 +57,14 @@ import { NO_UPDATE_YET, UPDATE_DUE, } from '../../model/words'; +import { DecideCall, planRow, usePlanCalls } from './Decide'; import { answerWords, - DecideCall, isHomeReason, - planRow, primaryOf, reasonParts, - usePlanCalls, type Call, -} from './Decide'; +} from '../../model/decideCalls'; import { HomeLine } from './Initiatives'; import type { Initiative } from '../../../../shared/model/initiatives'; import { BacklogSection } from './BacklogSection'; diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 4dcc14db..2ca74afa 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, @@ -60,19 +58,14 @@ import { import { Icon } from '../../components/Icon'; import { eyebrowText, - Fold, GroupHeader, openFold, Rows, SubDoor, - useFoldState, } from '../../components/Lane'; import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; -import { byRank, fileProject, moveInitiative, readInitiatives } from '../../model/initiativesData'; -/** The initiatives as the board has them, just after a write. */ -const readInitiativesNow = () => readInitiatives().initiatives ?? []; -import { bandGloss as initiativeGloss, spanWords, UNSORTED } from '../../model/initiativeBoard'; +import { UNSORTED } from '../../model/initiativeBoard'; import { useRowKeys } from '../../components/useRowKeys'; import { dateOf, dayOf, dayWords, useProjectsData, type Range } from '../../model/projectData'; import { findFilter, lowerFirst, planCell, type PortfolioItem } from '../../model/portfolio'; @@ -134,8 +127,17 @@ import { UNSORTED_WORD, NO_PROJECT_YET, } from '../../model/words'; -import { askedInitiative, askOf, DecideCall, reasonWords, useCallsMadeHere } from './Decide'; +import { DecideCall, useCallsMadeHere } from './Decide'; +import { askOf, reasonWords } from '../../model/decideCalls'; import { HomeLine } from './Initiatives'; +import { + initiativeLanes, + NO_PROJECT_LANE, + ReceiptLine, + TimelineLane, + type Receipt, + type Standing, +} from './RoadmapLanes'; import { LoadChart } from './LoadChart'; import { NowNextLater } from './NowNextLater'; import { @@ -150,7 +152,6 @@ import { capacityWords, clearedBy, Dotted, - inInk, isAmber, latestOf, moveWords, @@ -166,7 +167,6 @@ import { vouchWords, when, type PlanCall, - type Said, type Tracked, } from './roadmapHealth'; @@ -230,8 +230,6 @@ const rowMargin = 'scroll-mt-[calc(var(--header-h,0px)_+_var(--roadmap-stuck,0px /** What a plan's span is: its first week and length. */ type Span = { start: string; weeks: number }; -/** the lane, by initiative, of plans with no project */ -const NO_PROJECT_LANE = '(no-project)'; /** no Decide rows yet, the same array each render */ const NO_ROWS: DecideRow[] = []; const NO_SLUGS: ReadonlySet = new Set(); @@ -1192,28 +1190,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. */ @@ -1226,28 +1202,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)', @@ -2083,73 +2037,6 @@ function CapacityLine({ ); } -/** - * 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. - */ -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} - - ); -} - /** The columns' names and the smaller marks under them (months under * quarters, Mondays under months, days under a week), in the sticky header. * A column that can fill the width is a button, its zoom glass shown on @@ -2793,72 +2680,6 @@ export function Roadmap({ }); }; - // 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) => { - const key = `plan:${item.id}`; - const slug = item.project; - if (!slug) { - return setAnnounced( - `${item.name} has no project yet, so it can’t be filed. Link it to its project in Edit details first.` - ); - } - if (to === NO_PROJECT_LANE) return; - const from = homeOf(slug)?.slug ?? null; - const target = to === UNSORTED ? null : to; - const toName = target - ? byRank(initiatives ?? []).find(i => i.slug === target)?.name ?? target - : UNSORTED_WORD; - run.current = null; - void fileProject(slug, target, { expect: from }).then(r => { - if ('error' in r) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); - openFold(laneId(to)); - refocus.current = [`roadmap-grip-${item.id}`]; - say({ - key, - words: `Moved ${item.name} to ${toName}`, - undo: () => { - refocus.current = [`roadmap-grip-${item.id}`]; - void fileProject(slug, r.before.initiative, { - set_at: r.before.set_at, - baseline_prs: r.before.baseline_prs, - }).then(back => { - if ('error' in back) return fail(key); - openFold(laneId(r.before.initiative ?? UNSORTED)); - say(null, `Put ${item.name} back`); - }); - }, - }); - }); - }; - // an initiative moved just above `before` (null: the bottom), with Undo - // back to the one it sat above - const rankMove = (moving: Initiative, before: number | null) => { - const all = byRank(initiatives ?? []); - const was = all[all.findIndex(i => i.id === moving.id) + 1]?.id ?? null; - if (was === before) return; - const key = `initiative:${moving.id}`; - run.current = null; - refocus.current = [`roadmap-lane-grip-${moving.id}`]; - void moveInitiative(moving.id, before).then(r => { - if (r !== true) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); - const ranked = byRank(readInitiativesNow()).filter( - i => i.status === 'open' && i.kind !== 'untracked' - ); - const at = ranked.findIndex(i => i.id === moving.id) + 1; - say({ - key, - words: `Moved ${moving.name} to rank ${at}`, - undo: () => { - refocus.current = [`roadmap-lane-grip-${moving.id}`]; - void moveInitiative(moving.id, was).then(back => - back === true ? say(null, `Put ${moving.name} back`) : fail(key) - ); - }, - }); - }); - }; - const handlersFor = (item: RoadmapItem, visible: number[]) => ({ grip: { onDragStart: (e: DragEvent) => { @@ -3208,191 +3029,44 @@ export function Roadmap({ .filter((t): t is string => !!t && !teams.includes(t)) .sort((a, b) => a.localeCompare(b)), ]; - // ----- 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; - const heldPlan = (i: RoadmapItem) => i === gone || 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 - if (key === NO_PROJECT_LANE) return; - e.preventDefault(); - setOverLane(key); - }, - onDragLeave: () => setOverLane(o => (o === key ? null : o)), - onDrop: (e: DragEvent) => { - e.preventDefault(); - 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); - }, + const { fileByDrag, laneInitiatives, rankOf, initiativeBody } = initiativeLanes({ + asksPlan, + bySlug, + callsHere, + counted, + dragging, + draggingLane, + everyPlan, + everything, + fail, + gone, + homeOf, + inFlightRows, + initiatives, + laneId, + laneKey, + narrowed, + navigate, + openFromReceipt, + ordered, + overLane, + person, + planCalls, + planRows, + projectCalls, + q, + receipt, + refocus, + run, + say, + setAnnounced, + setDragging, + setDraggingLane, + setDropTarget, + setOverLane, + shownPlans, + shownUnplanned, }); - // 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 ( -
- - {initiative && ( -
- navigate({ initiative: initiative.slug, project: null })} - title={`Open ${initiative.name}: its projects, owner and goal`} - > - Initiative page - - {initiative.owner && ( - - owned by{' '} - person(initiative.owner as string)} - title={`See ${initiative.owner} on People`} - > - {initiative.owner} - - - )} - {initiative.goal && {initiative.goal}} -
- )} - {initiative && asked && ( -
- {asked.reasons.length > 0 && ( -

- {askOf(asked.reasons[0]).question} -

- )} - -
- )} - {receipt?.key === `initiative:${initiative?.id}` && ( - - )} - {planRows(planned, planned, { placed: true })} - {inFlightRows(loose)} -
- {/* a rank move's receipt stays in view with the lane closed */} - {initiative && receipt?.key === `initiative:${initiative.id}` && !calls && ( - - )} -
- ); - }); - })(); // ----- 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 diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx new file mode 100644 index 00000000..c2b09f42 --- /dev/null +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -0,0 +1,481 @@ +/** + * 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 { 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 { Fold, openFold, useFoldState } from '../../components/Lane'; +import { askedInitiative, askOf } from '../../model/decideCalls'; +import { bandGloss as initiativeGloss, spanWords, UNSORTED } from '../../model/initiativeBoard'; +import { byRank, fileProject, moveInitiative, readInitiatives } from '../../model/initiativesData'; +import type { PortfolioItem } from '../../model/portfolio'; +import { NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words'; +import { DecideCall } from './Decide'; +import type { Navigate } from './parts'; +import { inInk, SaidWords, type Said } from './roadmapHealth'; + +/** 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 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'; + +export 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'} + + )} +
+ ); +} + +/** + * 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; + fail: (key: string, keep?: Pick) => void; + gone: RoadmapItem | null; + homeOf: (slug: string | null) => Initiative | undefined; + inFlightRows: (list: PortfolioItem[]) => ReactNode; + initiatives: readonly Initiative[] | 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; + run: MutableRefObject; + say: (next: Receipt | null, words?: string) => 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, + fail, + gone, + homeOf, + inFlightRows, + initiatives, + laneId, + laneKey, + narrowed, + navigate, + openFromReceipt, + ordered, + overLane, + person, + planCalls, + planRows, + projectCalls, + q, + receipt, + refocus, + run, + say, + 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) => { + const key = `plan:${item.id}`; + const slug = item.project; + if (!slug) { + return setAnnounced( + `${item.name} has no project yet, so it can’t be filed. Link it to its project in Edit details first.` + ); + } + if (to === NO_PROJECT_LANE) return; + const from = homeOf(slug)?.slug ?? null; + const target = to === UNSORTED ? null : to; + const toName = target + ? byRank(initiatives ?? []).find(i => i.slug === target)?.name ?? target + : UNSORTED_WORD; + run.current = null; + void fileProject(slug, target, { expect: from }).then(r => { + if ('error' in r) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); + openFold(laneId(to)); + refocus.current = [`roadmap-grip-${item.id}`]; + say({ + key, + words: `Moved ${item.name} to ${toName}`, + undo: () => { + refocus.current = [`roadmap-grip-${item.id}`]; + void fileProject(slug, r.before.initiative, { + set_at: r.before.set_at, + baseline_prs: r.before.baseline_prs, + }).then(back => { + if ('error' in back) return fail(key); + openFold(laneId(r.before.initiative ?? UNSORTED)); + say(null, `Put ${item.name} back`); + }); + }, + }); + }); + }; + // an initiative moved just above `before` (null: the bottom), with Undo + // back to the one it sat above + const rankMove = (moving: Initiative, before: number | null) => { + const all = byRank(initiatives ?? []); + const was = all[all.findIndex(i => i.id === moving.id) + 1]?.id ?? null; + if (was === before) return; + const key = `initiative:${moving.id}`; + run.current = null; + refocus.current = [`roadmap-lane-grip-${moving.id}`]; + void moveInitiative(moving.id, before).then(r => { + if (r !== true) return say({ key, words: r.error.replace(/\.$/, ''), failed: true }); + const ranked = byRank(readInitiativesNow()).filter( + i => i.status === 'open' && i.kind !== 'untracked' + ); + const at = ranked.findIndex(i => i.id === moving.id) + 1; + say({ + key, + words: `Moved ${moving.name} to rank ${at}`, + undo: () => { + refocus.current = [`roadmap-lane-grip-${moving.id}`]; + void moveInitiative(moving.id, was).then(back => + back === true ? say(null, `Put ${moving.name} back`) : fail(key) + ); + }, + }); + }); + }; + // ----- 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; + const heldPlan = (i: RoadmapItem) => i === gone || 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 + if (key === NO_PROJECT_LANE) return; + e.preventDefault(); + setOverLane(key); + }, + onDragLeave: () => setOverLane(o => (o === key ? null : o)), + onDrop: (e: DragEvent) => { + e.preventDefault(); + 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 ( +
+ + {initiative && ( +
+ navigate({ initiative: initiative.slug, project: null })} + title={`Open ${initiative.name}: its projects, owner and goal`} + > + Initiative page + + {initiative.owner && ( + + owned by{' '} + person(initiative.owner as string)} + title={`See ${initiative.owner} on People`} + > + {initiative.owner} + + + )} + {initiative.goal && {initiative.goal}} +
+ )} + {initiative && asked && ( +
+ {asked.reasons.length > 0 && ( +

+ {askOf(asked.reasons[0]).question} +

+ )} + +
+ )} + {receipt?.key === `initiative:${initiative?.id}` && ( + + )} + {planRows(planned, planned, { placed: true })} + {inFlightRows(loose)} +
+ {/* a rank move's receipt stays in view with the lane closed */} + {initiative && receipt?.key === `initiative:${initiative.id}` && !calls && ( + + )} +
+ ); + }); + })(); + return { fileByDrag, laneInitiatives, rankOf, initiativeBody }; +} From c1d1894f020171aaff5771a2dc3f83d87dbd6283 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:50:18 -0700 Subject: [PATCH 022/148] Say order as "priority", in the row's own number Three words named one order (rank on lanes, priority on rows, place in the hints), and two labels pointed at numbers the row doesn't show: "Starts before #2 is done" printed the whole roadmap's index while lane rows count 1, 2, 3, and a row moved in a lane said "priority 37". - A clash names the plan it waits on: "Starts before Shopify product and order sync is done" reads the same in every grouping. - Lanes and rows both say priority: grips, hovers and receipts. - Under Group by Initiative a row's receipt and its Undo say the number the row shows, with its lane: "to priority 1 in Site speed". Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Roadmap.tsx | 34 +++++++++++++++---- .../src/views/projects/RoadmapLanes.tsx | 13 ++++--- .../src/views/projects/roadmapHealth.test.ts | 6 ++-- .../src/views/projects/roadmapHealth.tsx | 10 ++---- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 2ca74afa..180d1b8e 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -1414,9 +1414,9 @@ function PlanRow({ draggable aria-label={`${item.name}: ${ lanePlace - ? `${lanePlace} in its lane, priority ${rank} on the whole roadmap` + ? `priority ${lanePlace} in its lane, ${rank} on the whole roadmap` : `priority ${rank}` - }. Drag, or use the up and down arrow keys, to change its place`} + }. Drag, or use the up and down arrow keys, to change its priority`} title="Drag to change its priority" // padded and widened by .hit to a 24px target, taking no // more room in the row. The rank already says the rows are @@ -2589,7 +2589,26 @@ export function Roadmap({ }); }); }; - const reorder = (item: RoadmapItem, next: number[], team?: string | null) => { + // where a plan sits in the order, in the number its row shows: by + // initiative, its place among the rows of its lane (`shown`), named + const placeWords = (item: RoadmapItem, order: readonly number[], shown?: readonly number[]) => { + if (!byInitiative || !shown) return `priority ${order.indexOf(item.id) + 1}`; + const place = order.filter(id => shown.includes(id)).indexOf(item.id) + 1; + const key = laneKey(item.project); + const lane = + key === NO_PROJECT_LANE + ? NO_PROJECT_YET + : key === UNSORTED + ? UNSORTED_WORD + : homeOf(item.project)?.name ?? key; + return `priority ${place} in ${lane}`; + }; + const reorder = ( + item: RoadmapItem, + next: number[], + team?: string | null, + shown?: readonly number[] + ) => { const key = `plan:${item.id}`; if (run.current?.key !== key || !Array.isArray(run.current.from)) { run.current = { key, from: ids, team: item.team, was: item }; @@ -2603,7 +2622,7 @@ export function Roadmap({ const where = team === undefined ? '' : `${team ?? 'No team'}, `; say({ key, - words: `Moved ${item.name} to ${where}priority ${next.indexOf(item.id) + 1}`, + words: `Moved ${item.name} to ${where}${placeWords(item, next, shown)}`, undo: () => { run.current = null; refocus.current = [`roadmap-grip-${item.id}`]; @@ -2614,7 +2633,7 @@ export function Roadmap({ now === fromTeam ? true : updateRoadmapItem(item.id, { team: fromTeam }), ]).then(([a, b]) => a && b - ? say(null, `Put ${item.name} back at priority ${from.indexOf(item.id) + 1}`) + ? say(null, `Put ${item.name} back at ${placeWords(item, from, shown)}`) : fail(key) ); }, @@ -2700,7 +2719,7 @@ export function Roadmap({ const next = stepWithin(ids, visible, item.id, up ? -1 : 1); if (!next) return setAnnounced(`${item.name} is already ${up ? 'first' : 'last'} here`); refocus.current = [`roadmap-grip-${item.id}`]; - reorder(item, next); + reorder(item, next, undefined, visible); }, }, row: { @@ -2726,7 +2745,8 @@ export function Roadmap({ reorder( dropped, moveBefore(ids, dropped.id, item.id), - lanes && dropped.team !== item.team ? item.team : undefined + lanes && dropped.team !== item.team ? item.team : undefined, + visible ); }, }, diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index c2b09f42..7bdd5814 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -270,6 +270,7 @@ export function initiativeLanes(c: LaneContext) { const rankMove = (moving: Initiative, before: number | null) => { const all = byRank(initiatives ?? []); const was = all[all.findIndex(i => i.id === moving.id) + 1]?.id ?? null; + const wasAt = rankOf(moving); if (was === before) return; const key = `initiative:${moving.id}`; run.current = null; @@ -282,11 +283,13 @@ export function initiativeLanes(c: LaneContext) { const at = ranked.findIndex(i => i.id === moving.id) + 1; say({ key, - words: `Moved ${moving.name} to rank ${at}`, + words: `Moved ${moving.name} to priority ${at}`, undo: () => { refocus.current = [`roadmap-lane-grip-${moving.id}`]; void moveInitiative(moving.id, was).then(back => - back === true ? say(null, `Put ${moving.name} back`) : fail(key) + back === true + ? say(null, `Put ${moving.name} back at priority ${wasAt}`) + : fail(key) ); }, }); @@ -341,10 +344,10 @@ export function initiativeLanes(c: LaneContext) { role="button" tabIndex={0} draggable - aria-label={`${i.name}: rank ${ + aria-label={`${i.name}: priority ${ at + 1 - }. Drag onto another initiative, or use the up and down arrow keys, to change its rank`} - title="Drag to change its rank" + }. Drag onto another initiative, or use the up and down arrow keys, to change its priority`} + title="Drag to change its priority" onDragStart={e => { setDraggingLane(i.id); e.dataTransfer.effectAllowed = 'move'; diff --git a/frontend-v2/src/views/projects/roadmapHealth.test.ts b/frontend-v2/src/views/projects/roadmapHealth.test.ts index d2005e5f..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( @@ -357,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 d739465f..4748f4e3 100644 --- a/frontend-v2/src/views/projects/roadmapHealth.tsx +++ b/frontend-v2/src/views/projects/roadmapHealth.tsx @@ -271,13 +271,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 +310,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 }; } From dce2b7475a302489f6165db969d7eed6a28f6adb Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:50:36 -0700 Subject: [PATCH 023/148] Draw a lane's priority receipt once The receipt for an initiative's move was drawn inside its lane and again below it whenever the lane rested closed by default. A lane that rests closed but was opened showed it twice; one that holds a call but was closed showed it nowhere. It is now drawn once, under the lane, whatever the fold's state. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/RoadmapLanes.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 7bdd5814..d7b42ca6 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -466,14 +466,12 @@ export function initiativeLanes(c: LaneContext) { )} - {receipt?.key === `initiative:${initiative?.id}` && ( - - )} {planRows(planned, planned, { placed: true })} {inFlightRows(loose)} - {/* a rank move's receipt stays in view with the lane closed */} - {initiative && receipt?.key === `initiative:${initiative.id}` && !calls && ( + {/* a rank move's receipt, once, under the lane whether it's + open or closed */} + {initiative && receipt?.key === `initiative:${initiative.id}` && ( )} From 9db0edd8718e8157e1d68c2a23dff58bfd3979ce Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:50:54 -0700 Subject: [PATCH 024/148] Refuse a drag of a plan with no project to a lane A plan with no project lit up any initiative's lane as a drop target, and the drop did nothing anyone could see: the reason was only in a screen-reader region, and it pointed at Edit details. The lanes and rows of other lanes now refuse that drag, so nothing lights and the cursor says no. If a drop still lands, the row says why with "Open its plan", where its details ask which project it is. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Roadmap.tsx | 3 +++ .../src/views/projects/RoadmapLanes.tsx | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 180d1b8e..41029afe 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -2725,6 +2725,9 @@ export function Roadmap({ row: { onDragOver: (e: DragEvent) => { if (dragging == null) return; + // a plan with no project can't be filed in another lane + const moving = byInitiative ? ordered.find(i => i.id === dragging) : undefined; + if (moving && !moving.project && laneKey(item.project) !== NO_PROJECT_LANE) return; e.preventDefault(); setDropTarget(item.id); }, diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index d7b42ca6..0b99bb48 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -232,13 +232,19 @@ export function initiativeLanes(c: LaneContext) { const fileByDrag = (item: RoadmapItem, to: string) => { const key = `plan:${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 setAnnounced( - `${item.name} has no project yet, so it can’t be filed. Link it to its project in Edit details first.` - ); + 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 from = homeOf(slug)?.slug ?? null; + const fromHome = homeOf(slug); + const from = fromHome?.slug ?? null; const target = to === UNSORTED ? null : to; const toName = target ? byRank(initiatives ?? []).find(i => i.slug === target)?.name ?? target @@ -312,8 +318,10 @@ export function initiativeLanes(c: LaneContext) { 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 + // 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; e.preventDefault(); setOverLane(key); }, From 0a091e2f0524f8e30ec75c10beb1544ff3be7689 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:51:30 -0700 Subject: [PATCH 025/148] Count weeks past and say a close one way One end date gave two counts in one lane: the band floored the weeks ("2 weeks past") while the plan's row and bar rounded up ("3 wk past"). weeksPast in shared/model/roadmap.ts now rounds up for every view: Decide's reasons, the Overview's cells, the rows and the band. A closed initiative read "Ended Sep 20" on the Roadmap and "closed as done" on the Overview; both and its page now say "Closed as done Sep 20" (closedWords). An open one whose plans have all stopped says "Nothing under way since Sep 20" instead of "Ended", which read as closed. The Overview band names the owner ("owned by X"). Bands with no grip keep the grip's column, so every lane's label starts at one x. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/initiativeBoard.test.ts | 14 ++++++-- frontend-v2/src/model/initiativeBoard.ts | 33 ++++++++++++++----- frontend-v2/src/model/portfolio.ts | 5 +-- .../src/views/projects/Initiatives.tsx | 4 +-- frontend-v2/src/views/projects/Portfolio.tsx | 6 ++-- .../src/views/projects/RoadmapLanes.tsx | 14 ++++++-- .../src/views/projects/roadmapHealth.tsx | 3 +- shared/model/decide.ts | 7 ++-- shared/model/roadmap.ts | 7 ++++ 9 files changed, 66 insertions(+), 27 deletions(-) diff --git a/frontend-v2/src/model/initiativeBoard.test.ts b/frontend-v2/src/model/initiativeBoard.test.ts index 30c9eccb..f564fbfa 100644 --- a/frontend-v2/src/model/initiativeBoard.test.ts +++ b/frontend-v2/src/model/initiativeBoard.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Initiative } from '../../../shared/model/initiatives'; import type { Project, ProjectGroup, Today } from '../../../shared/model/projects'; -import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { weeksPast, type RoadmapItem } from '../../../shared/model/roadmap'; import { bandGloss, grownProjects, @@ -182,7 +182,12 @@ describe('the words', () => { expect(spanWords(['a', 'b', 'c'], plans, NOW)).toBe( 'Jun 1 to Nov 1 · 1 with an end · 1 ongoing' ); - expect(spanWords(['b'], plans, NOW)).toBe('Ended Jun 14'); + // 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(''); }); @@ -200,6 +205,11 @@ describe('the words', () => { // 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 with an end'); + // 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 with an end` + ); + expect(weeksPast('2026-09-27', '2026-10-12')).toBe(3); }); }); diff --git a/frontend-v2/src/model/initiativeBoard.ts b/frontend-v2/src/model/initiativeBoard.ts index 7914eb8b..a6c4afa2 100644 --- a/frontend-v2/src/model/initiativeBoard.ts +++ b/frontend-v2/src/model/initiativeBoard.ts @@ -2,7 +2,7 @@ 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 Today } from '../../../shared/model/projects'; -import type { RoadmapItem } from '../../../shared/model/roadmap'; +import { weeksPast, type RoadmapItem } from '../../../shared/model/roadmap'; import type { PortfolioItem } from './portfolio'; import { dayWords, type ProjectsData } from './projectData'; import { byRank, isNotTrackedNow } from './initiativesData'; @@ -102,18 +102,21 @@ export function homeWords(home: Pick | null | undef export function spanWords( projects: readonly string[], plans: readonly Pick[], - now: number = Date.now() + 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 }); - if (span.end_kind === 'ended') return span.ended ? `Ended ${day(span.ended)}` : ''; + // 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 - ? Math.floor((Date.parse(today) - Date.parse(span.end)) / (7 * 86400 * 1000)) - : 0; + 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` : ''}` @@ -124,18 +127,30 @@ export function spanWords( return parts.filter(Boolean).join(' · '); } +/** 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, + 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' ? '' : ` Closed as ${initiative.status}.`; + const closed = initiative.status === 'open' ? '' : ` ${closedWords(initiative)}.`; return `${goal}${held}.${owner}${closed}`; } diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts index 5cb7fa7a..c752efb7 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -24,6 +24,7 @@ import { healthStanding, isUnderWay, planFor, + weeksPast, type RoadmapItem, } from '../../../shared/model/roadmap'; import type { Status } from '../../../shared/model/status'; @@ -237,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) { diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index 0e742ddb..e78ddca2 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -20,7 +20,7 @@ import { EmptyState, FactLink, QuietButton, textInputClass } from '../../compone import { Fold, Rows } from '../../components/Lane'; import { Icon } from '../../components/Icon'; import { fileProject, updateInitiative, type Home } from '../../model/initiativesData'; -import { homeChoices } from '../../model/initiativeBoard'; +import { closedWords, homeChoices } from '../../model/initiativeBoard'; import { dayWords } from '../../model/projectData'; import { stageWord, type PortfolioItem } from '../../model/portfolio'; import { andList, UNSORTED_WORD } from '../../model/words'; @@ -526,7 +526,7 @@ export function InitiativePage({

{initiative.name}

{kindWord && {kindWord}} {initiative.status !== 'open' && ( - Closed as {initiative.status} + {closedWords(initiative)} )} {!ledger && ( diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx index 8840dc03..530b032a 100644 --- a/frontend-v2/src/views/projects/Portfolio.tsx +++ b/frontend-v2/src/views/projects/Portfolio.tsx @@ -52,7 +52,7 @@ import { } from '../../model/words'; import { DecideCall, planRow } from './Decide'; import { askedInitiative, askOf, reasonWords } from '../../model/decideCalls'; -import { bandGloss as initiativeGloss } from '../../model/initiativeBoard'; +import { bandGloss as initiativeGloss, closedWords } from '../../model/initiativeBoard'; import { BY_PRS_WHY, BY_PRS_WHY_NO_REPO, @@ -1177,10 +1177,10 @@ export function Portfolio( plans ?? [] ); const words = [ - initiative.owner, + initiative.owner ? `owned by ${initiative.owner}` : '', span.with_end ? `${span.with_end} with an end` : '', span.ongoing ? `${span.ongoing} ongoing` : '', - initiative.status === 'open' ? '' : `closed as ${initiative.status}`, + initiative.status === 'open' ? '' : closedWords(initiative), ].filter(Boolean); return words.length ? ( {words.join(' · ')} diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 0b99bb48..5d3a4cea 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -435,8 +435,18 @@ export function initiativeLanes(c: LaneContext) { } load={null} lined={false} - phrase={initiative ? spanWords(projects, everyPlan ?? []) : undefined} - gutter={rank ? laneGrip(initiative as Initiative, ranked) : undefined} + phrase={ + initiative ? spanWords(projects, everyPlan ?? [], undefined, initiative) : undefined + } + // a band with no grip keeps its column, so every label + // starts at one x + gutter={ + rank ? ( + laneGrip(initiative as Initiative, ranked) + ) : ( + + ) + } // an open initiative keeps its place in the list of // contents with nothing in it keepEmpty={!!initiative} diff --git a/frontend-v2/src/views/projects/roadmapHealth.tsx b/frontend-v2/src/views/projects/roadmapHealth.tsx index 4748f4e3..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, @@ -384,7 +385,7 @@ export function planWarnings( 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 diff --git a/shared/model/decide.ts b/shared/model/decide.ts index ca606f0c..691682d6 100644 --- a/shared/model/decide.ts +++ b/shared/model/decide.ts @@ -1,6 +1,6 @@ import { dayStart, firstOpenDay, targetOf, utcDay, type Project, type Today } from './projects'; import { goingProjects, homesOf, type Initiative } from './initiatives'; -import { healthStanding, isUnderWay, planEnd, type RoadmapItem } from './roadmap'; +import { healthStanding, isUnderWay, planEnd, weeksPast, type RoadmapItem } from './roadmap'; import type { IssueCounts } from './work'; /** @@ -270,8 +270,7 @@ export function decideQueue({ 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) => { @@ -292,7 +291,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, }); } diff --git a/shared/model/roadmap.ts b/shared/model/roadmap.ts index faa5d4d5..ff60ebdc 100644 --- a/shared/model/roadmap.ts +++ b/shared/model/roadmap.ts @@ -279,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); From 0690caf2d85d629f9938c6bab73700b726b0a375 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:51:50 -0700 Subject: [PATCH 026/148] Make "Initiative page" a real link It was a button styled as a link, so it couldn't be opened in a new tab, cmd-clicked or copied, though the page has an address. It is an
to the board's hash with the initiative set; a plain click still navigates in place, as before. Co-Authored-By: Claude Opus 5.5 --- .../src/views/projects/RoadmapLanes.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 5d3a4cea..391f27df 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -20,6 +20,17 @@ 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}`; +} + /** The initiatives as the board has them, just after a write. */ const readInitiativesNow = () => readInitiatives().initiatives ?? []; @@ -454,12 +465,20 @@ export function initiativeLanes(c: LaneContext) { > {initiative && (
- navigate({ initiative: initiative.slug, project: null })} + {/* a real link, so it opens in a new tab or copies */} + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) + return; + e.preventDefault(); + navigate({ initiative: initiative.slug, project: null }); + }} title={`Open ${initiative.name}: its projects, owner and goal`} + className="hit pressable rounded text-ink-2 underline decoration-line underline-offset-2 hover:text-brand" > Initiative page - + {initiative.owner && ( owned by{' '} From d2c9395f0f119635d2ffb97728485c221997e041 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:53:14 -0700 Subject: [PATCH 027/148] Test a plan that fails after its filing saved R5 says a filing that rides with a plan call stays when the plan fails, and the row says "Filed in X; the plan didn't save" with Try again. That path had no test. Now one plan's POST fails once: the row says so, and Try again sends only the plan, since the filing's expect is stale. Co-Authored-By: Claude Opus 5.5 --- .../src/views/projects/HomeCalls.test.tsx | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index 15ce43ff..aaf024ce 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -78,6 +78,7 @@ const unlinked: RoadmapItem = { ...created, id: 5, name: 'Checkout redesign', pr /** 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', @@ -114,7 +115,14 @@ beforeAll(async () => { const was = INITIATIVES.find(i => i.id === id) ?? INITIATIVES[3]; return reply({ initiative: { ...was, ...body } }); } - if (path === '/roadmap' && method === 'POST') return reply({ item: created }, 201); + 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') @@ -139,6 +147,7 @@ const ITEMS = [ person('cache', ['dana']), person('answers', ['erin']), person('filed', ['dana']), + person('flaky', ['erin']), ]; function render(row: DecideRow): HTMLDivElement { @@ -297,6 +306,24 @@ describe('the Initiative chooser on a row Decide asks to plan', () => { 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('the words for where a project belongs', () => { From 10f4598640ec81c63bac083805b6f94e2282888d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:53:24 -0700 Subject: [PATCH 028/148] Key Unsorted and no-project plans off the slugs UNSORTED was 'unsorted', a valid initiative slug: an initiative made with it would share a Roadmap lane key (duplicate React keys, one merged lane) and Decide's within=unsorted filter. Unsorted is now '(unsorted)' and plans with no project '(no-project)', which PROJECT_SLUG refuses; Look back's split reuses the first. Decide's Initiative switch counted a plan with no project under Unsorted (4 where the lane said 3). It now has its own entry, No project yet, as on the Roadmap and in Decide's sections. With a team and an initiative both picked, the heading names both: "for Store in Site speed". Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/initiativeBoard.ts | 10 +++++-- frontend-v2/src/views/projects/Decide.tsx | 28 ++++++++++++------- .../src/views/projects/HomeCalls.test.tsx | 22 +++++++++++++++ frontend-v2/src/views/projects/HomeCalls.tsx | 8 ++++-- .../src/views/projects/RoadmapLanes.tsx | 9 ++++-- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/frontend-v2/src/model/initiativeBoard.ts b/frontend-v2/src/model/initiativeBoard.ts index a6c4afa2..945e3040 100644 --- a/frontend-v2/src/model/initiativeBoard.ts +++ b/frontend-v2/src/model/initiativeBoard.ts @@ -14,8 +14,12 @@ import { NOT_IN_A_PROJECT, ONE_OFFS, UNSORTED_WORD } from './words'; * project's home, and the words an initiative's band and page say. */ -/** The slug of the home Unsorted stands for: a project with no filing. */ -export const UNSORTED = 'unsorted'; +/** 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. */ @@ -156,7 +160,7 @@ export function bandGloss( /** 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_UNSORTED = UNSORTED; export const SPLIT_NOT_TRACKED = '(not-tracked)'; export const SPLIT_ONE_OFFS = '(one-offs)'; export const SPLIT_NO_PROJECT = '(none)'; diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 9f9a21b5..5ddd4131 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -62,7 +62,7 @@ import { updateInitiative, type Home, } from '../../model/initiativesData'; -import { UNSORTED } from '../../model/initiativeBoard'; +import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; import { homesOf } from '../../../../shared/model/initiatives'; import { ALL_ISSUES_CLOSED, @@ -2153,24 +2153,35 @@ export function Decide({ 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 || (initiativeOfRow(row)?.slug ?? UNSORTED) === nav.within); + (!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 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; - const whose = !nav.team - ? withinName - ? ` for ${withinName}` - : '' + // 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 whose = withinName + ? teamWords + ? `${teamWords} in ${withinName}` + : ` for ${withinName}` + : teamWords; const heading = owed.length ? `${n(owed.length, 'decision')} to make` : decidedHere.length @@ -2288,10 +2299,7 @@ export function Decide({ {(initiatives ?? []).length > 0 && ( - kept.owed.filter(row => (initiativeOfRow(row)?.slug ?? UNSORTED) === slug) - .length - } + owedIn={slug => kept.owed.filter(row => withinOf(row) === slug).length} value={nav.within} onChange={within => navigate({ within })} /> diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index aaf024ce..d1b7cd38 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -9,6 +9,8 @@ import { loadInitiatives } from '../../model/initiativesData'; import type { PortfolioItem } from '../../model/portfolio'; import { HomeLine, InitiativePage } from './Initiatives'; import { InitiativeSwitch } from './HomeCalls'; +import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; +import { PROJECT_SLUG } from '../../../../shared/model/roadmap'; import type { ProjectsNav } from './parts'; import { DecideCall, DecideRowView, keepCalls, type Made } from './Decide'; import { AllItemsContext, answerWords, callWords } from '../../model/decideCalls'; @@ -513,6 +515,26 @@ describe('Decide’s Initiative switch', () => { 'Catalog · 1', ]); }); + + 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', + 'No project yet · 1', + ]); + // 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', () => { diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx index 4cc998f7..7d71ae54 100644 --- a/frontend-v2/src/views/projects/HomeCalls.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -10,8 +10,8 @@ import type { DecideRow } from '../../../../shared/model/decide'; import { homesOf, type Initiative } from '../../../../shared/model/initiatives'; import { textInputClass } from '../../components/bits'; import { byRank, useInitiativesValue } from '../../model/initiativesData'; -import { guessHome, homeChoices, UNSORTED } from '../../model/initiativeBoard'; -import { UNSORTED_WORD } from '../../model/words'; +import { guessHome, homeChoices, NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; +import { NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words'; import { AllItemsContext, answerWords, @@ -302,7 +302,7 @@ export function HomeStrip({ * slice. A select, since there can be twenty; each names how many are owed, * and only the ones with something owed 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. + * is asked about, then Unsorted and No project yet. */ export function InitiativeSwitch({ initiatives, @@ -321,6 +321,8 @@ export function InitiativeSwitch({ 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/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 391f27df..5bc4e988 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -12,7 +12,12 @@ import { FactLink, QuietButton } from '../../components/bits'; import { Icon } from '../../components/Icon'; import { Fold, openFold, useFoldState } from '../../components/Lane'; import { askedInitiative, askOf } from '../../model/decideCalls'; -import { bandGloss as initiativeGloss, spanWords, UNSORTED } from '../../model/initiativeBoard'; +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 { NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words'; @@ -35,7 +40,7 @@ function hashWith(patch: Record): string { const readInitiativesNow = () => readInitiatives().initiatives ?? []; /** the lane, by initiative, of plans with no project */ -export const NO_PROJECT_LANE = '(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. */ From 7de77c73c28440cf3df9035e531e5b1ce2b6f447 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:54:35 -0700 Subject: [PATCH 029/148] Word every Undo as "Put X back" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undo receipts read three ways: "Put Newsletter promo page back", "Put it back in Store sync and checkout" and "Undid the decision on Visual regression checks". Each now names what went back and where: a drag's Undo "Put Image CDN back in Site speed", a Move's "Put cache back in Site speed", a priority "back at priority 2", and a Decide call "Put X back as it was", the bulk and park ones too. A Move's receipt names the new home, so the home word beside it is hidden while the receipt shows ("Not tracked ✓ Moved to Not tracked" said it twice). Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Decide.tsx | 6 ++--- .../src/views/projects/HomeCalls.test.tsx | 26 +++++++++++++++++++ .../src/views/projects/Initiatives.tsx | 15 ++++++++--- .../src/views/projects/RoadmapLanes.tsx | 2 +- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 5ddd4131..04744714 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -673,7 +673,7 @@ export function DecideCall({ ? '' : mine.state === 'failed' ? `Didn’t save the decision on ${name}.${mine.why ?? ''}` - : `Undid the decision on ${name}.`; + : `Put ${name} back as it was.`; const live = ( {said} @@ -1327,7 +1327,7 @@ function SectionCalls({ }; 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 @@ -1510,7 +1510,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 ?? ''} diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index d1b7cd38..e2d349a6 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -619,6 +619,32 @@ describe('a project page’s home line', () => { expect([...select.options].at(-1)?.textContent).toBe('Unsorted'); expect(move?.disabled).toBe(false); }); + + 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, 'Move'); + 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 === 'Move')?.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('Moved to FixBot chat.'); + click(el, 'Undo'); + await settle(); + expect(said.at(-1)).toBe('Put cache back in Site speed'); + }); }); describe('a row asking whether an initiative reached its goal', () => { diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index e78ddca2..4bd33eaf 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react'; +import { useContext, useEffect, useId, useRef, useState } from 'react'; import { Check } from 'lucide-react'; import { n } from '../../../../shared/format'; import type { DecideRow } from '../../../../shared/model/decide'; @@ -25,7 +25,12 @@ import { dayWords } from '../../model/projectData'; import { stageWord, type PortfolioItem } from '../../model/portfolio'; import { andList, UNSORTED_WORD } from '../../model/words'; import { DecideCall } from './Decide'; -import { askedInitiative, primaryOf, reasonParts } from '../../model/decideCalls'; +import { + AllItemsContext, + askedInitiative, + primaryOf, + reasonParts, +} from '../../model/decideCalls'; import { openPlan, type Navigate, type ProjectsNav } from './parts'; import { PLAN_STATUS_WORD, planWords } from './roadmapHealth'; @@ -62,6 +67,7 @@ export function HomeLine({ onSaid?: (words: string) => void; }) { 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); @@ -95,7 +101,7 @@ export function HomeLine({ else { setReceipt(null); setPick(before.initiative ?? ''); - onSaid?.(`Put it back in ${homeName(home)}`); + onSaid?.(`Put ${projectName} back in ${homeName(home)}`); requestAnimationFrame(() => document.getElementById(moveId)?.focus()); } }, @@ -103,7 +109,8 @@ export function HomeLine({ }; return ( - {moving ? null : home ? ( + {/* the receipt names the new home; saying it beside would repeat it */} + {moving || (receipt && !receipt.failed) ? null : home ? ( home.kind === 'untracked' ? ( Not tracked ) : ( diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 5bc4e988..6f549dc2 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -281,7 +281,7 @@ export function initiativeLanes(c: LaneContext) { }).then(back => { if ('error' in back) return fail(key); openFold(laneId(r.before.initiative ?? UNSORTED)); - say(null, `Put ${item.name} back`); + say(null, `Put ${item.name} back in ${fromHome?.name ?? UNSORTED_WORD}`); }); }, }); From 6709928153a3d3ae01cfe65c3a4685641d1e4f46 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:54:46 -0700 Subject: [PATCH 030/148] Test the Roadmap's initiative lanes No test rendered the Roadmap with initiatives; the lanes were checked only in a browser, which is where round 2 found the doubled receipt. RoadmapLanes.test.tsx renders it against a fake server: - the lanes in priority order, then No project yet and Not tracked, the closed one hidden while it holds nothing - rows numbered by their place in a lane, the roadmap's on the grip - ranking a lane with the arrow keys, one receipt whether the lane is open or closed, and Undo back above the one it sat above - a plan dropped on another lane files its project, leaves the order, and Undo files it back - a plan with no project refused by lanes and other lanes' rows - a row moved in its lane says the number it shows - a link to a plan opens its lane; "Initiative page" has an address Co-Authored-By: Claude Opus 5.5 --- .../src/views/projects/RoadmapLanes.test.tsx | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 frontend-v2/src/views/projects/RoadmapLanes.test.tsx 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..ca8e6ee2 --- /dev/null +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -0,0 +1,318 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Initiative } from '../../../../shared/model/initiatives'; +import type { RoadmapItem } from '../../../../shared/model/roadmap'; +import { loadInitiatives } from '../../model/initiativesData'; +import { loadRoadmap } from '../../model/roadmapData'; +import type { ProjectsNav } from './parts'; +import { Roadmap } from './Roadmap'; + +// 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[] = []; +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 move = path.match(/^\/initiatives\/(\d+)\/move$/); + 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') { + const was = server.find(i => i.projects.some(f => f.project === body?.project)); + 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; +beforeEach(async () => { + server = START.map(i => ({ ...i })); + localStorage.clear(); + await loadInitiatives(); + await loadRoadmap(); + calls.length = 0; +}); +afterEach(() => { + 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, + team: null, + origin: null, + by: 'team', + kind: 'all', + who: null, + only: null, + psort: '', +} as ProjectsNav; + +const settle = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); + +async function mount(nav: Partial = {}) { + host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + await act(async () => + root.render( + undefined} + decisions={[]} + /> + ) + ); + 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. */ +function drag(from: Element, to: Element): { refused: boolean } { + const data = { effectAllowed: '', setData: () => undefined, getData: () => '' }; + const event = (type: string) => + Object.assign(new Event(type, { bubbles: true, cancelable: true }), { dataTransfer: data }); + act(() => { + from.dispatchEvent(event('dragstart')); + }); + const over = event('dragover'); + act(() => { + to.dispatchEvent(over); + }); + act(() => { + to.dispatchEvent(event('drop')); + }); + return { refused: !over.defaultPrevented }; +} + +describe('the Roadmap by initiative', () => { + 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('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('Moved Image CDN to 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('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'); + }); +}); From c186ae456466e74861dda599a48db50912b4f37b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:56:12 -0700 Subject: [PATCH 031/148] Name a closed initiative first on every view Decide asks a project in a closed initiative about the initiative first (its section sorts ahead), but RANK tied reopened_in_closed with the plan's own `reopened`, and the Roadmap row and the Plan cell take the lowest rank, first on a tie. So Akeneo's row asked "Is it still done?" and its plan word said "Done, still worked on" while Decide asked to reopen Catalog migration. RANK now puts reopened_in_closed ahead, the order Decide's sections already ask in. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/portfolio.test.ts | 17 +++++++++++++++++ shared/model/decide.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts index 2a2eb78f..ba2ea713 100644 --- a/frontend-v2/src/model/portfolio.test.ts +++ b/frontend-v2/src/model/portfolio.test.ts @@ -17,6 +17,7 @@ import { groupItems, IDLE_BUCKETS, AGE_BUCKETS, + type Ask, isYours, mainTeam, matchesFind, @@ -319,6 +320,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', diff --git a/shared/model/decide.ts b/shared/model/decide.ts index 691682d6..f7ffb58c 100644 --- a/shared/model/decide.ts +++ b/shared/model/decide.ts @@ -173,7 +173,10 @@ export const RANK: Record = { stalled: 8, at_risk: 9, new: 10, - reopened_in_closed: 0, + // 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, From 17e93e6ecce67ef2a94cef9d975bf2db8f5cc872 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 00:57:29 -0700 Subject: [PATCH 032/148] Keep a project in sight after its home is answered After Reopen Catalog migration, Akeneo 4 (done, PRs open) left the Roadmap lane, the Overview's Being worked on (14 to 13) and Yours: the row came back asking only about its plan, and keepCalls settled it, which took it out of `owed`, the list every other view reads. That row is now owed and settled both: Decide shows it once with the reopen's receipt and Undo, while the Roadmap, the Overview and the counts keep the plan question ("Is it still done?") in view. Checked on the dummy board: the count stays at 14 and the lane holds the plan. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Decide.tsx | 18 +++++++++++++++--- .../src/views/projects/HomeCalls.test.tsx | 7 +++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 04744714..4ba198d6 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -229,17 +229,29 @@ export function keepCalls( ): { owed: DecideRow[]; all: DecideRow[]; settled: ReadonlyMap } { 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[] = []; for (const [key, m] of made) { const again = byKey.get(key); if (m.state !== 'made') { if (!again && m.row.reasons.length) back.push(m.row); - } else if (!again || kindsOf(again) === kindsOf(m.row) || answeredHome(m, again)) { + } 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); + 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))), + ...[...settled.values()].map(m => m.row), + ].sort(compareRows); return { owed, all, settled }; } diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index e2d349a6..32ca012d 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -484,7 +484,7 @@ describe('a project whose closed initiative and done plan are both asked about', expect(buttons(el)).toEqual(['Reopen CatalogSuggested', 'Move the project']); }); - it('keeps the reopen’s receipt when the row comes back asking only about its plan', () => { + it('keeps the reopen’s receipt here, and the plan question owed everywhere else', () => { const made = new Map([ [ 'akeneo:8', @@ -493,8 +493,11 @@ describe('a project whose closed initiative and done plan are both asked about', ]); const plan: DecideRow = { ...both, reasons: [both.reasons[0]] }; const kept = keepCalls([plan], made); - expect(kept.owed).toEqual([]); + // 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]); }); }); From b8e0ddb7ab56577308348ef162ff6507f8f3d009 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 01:02:15 -0700 Subject: [PATCH 033/148] File where each row's chooser says, in bulk too A section's one click ("Promise 1 by Oct 31 and 1 by its target date") planned every row, while each row's own answer waits on its File it in chooser: it promised a row whose chooser was empty and dropped another row's guessed home, so both came back under Unsorted. The chooser's pick now lives in a store by project (useHomeChooser reads it), so the section reads what each row's chooser says (useChosenHomes): a row with an empty chooser is left for its own answer, and the label drops "all"; the others carry their filing, guess included, as their own answer would. The guess is now worked out only for a row whose chooser can show it. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/decideCalls.ts | 9 ++- frontend-v2/src/views/projects/Decide.tsx | 31 ++++++++-- .../src/views/projects/HomeCalls.test.tsx | 49 ++++++++++++++- frontend-v2/src/views/projects/HomeCalls.tsx | 61 +++++++++++++++++-- 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/model/decideCalls.ts b/frontend-v2/src/model/decideCalls.ts index 45e3ab2e..aec77dea 100644 --- a/frontend-v2/src/model/decideCalls.ts +++ b/frontend-v2/src/model/decideCalls.ts @@ -442,7 +442,12 @@ export const VERBS: Record = { * 3 done"), and rows going through their own target dates * count together. */ -export function bulkWords(made: readonly Call[], tense: 'do' | 'did'): string { +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; @@ -450,7 +455,7 @@ export function bulkWords(made: readonly Call[], tense: 'do' | 'did'): string { group.count++; groups.set(key, group); } - const all = groups.size === 1 && tense === 'do' ? 'all ' : ''; + 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]; diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 4ba198d6..d1cf1256 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -132,7 +132,13 @@ import { type Filed, type HomeCall, } from '../../model/decideCalls'; -import { HomeChooser, HomeStrip, InitiativeSwitch, useHomeChooser } from './HomeCalls'; +import { + 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. `today` and `items` are the tracked ones; the @@ -1278,7 +1284,7 @@ export 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, @@ -1307,10 +1313,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); @@ -1330,12 +1344,17 @@ 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; diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index 32ca012d..d5a0b19b 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -8,11 +8,11 @@ import type { RoadmapItem } from '../../../../shared/model/roadmap'; import { loadInitiatives } from '../../model/initiativesData'; import type { PortfolioItem } from '../../model/portfolio'; import { HomeLine, InitiativePage } from './Initiatives'; -import { InitiativeSwitch } from './HomeCalls'; +import { clearHomePicks, InitiativeSwitch } from './HomeCalls'; import { NO_PROJECT, UNSORTED } from '../../model/initiativeBoard'; import { PROJECT_SLUG } from '../../../../shared/model/roadmap'; import type { ProjectsNav } from './parts'; -import { DecideCall, DecideRowView, keepCalls, type Made } from './Decide'; +import { DecideCall, DecideRowView, keepCalls, SectionCalls, type Made } from './Decide'; import { AllItemsContext, answerWords, callWords } from '../../model/decideCalls'; // React's act() warns unless the environment says it's a test @@ -137,6 +137,7 @@ beforeAll(async () => { let host: HTMLDivElement | null = null; afterEach(() => { + clearHomePicks(); host?.remove(); host = null; calls.length = 0; @@ -150,6 +151,11 @@ const ITEMS = [ 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 { @@ -328,6 +334,45 @@ describe('the Initiative chooser on a row Decide asks to plan', () => { }); }); +describe('a section’s one click on rows with a File it 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', () => { diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx index 7d71ae54..80dde156 100644 --- a/frontend-v2/src/views/projects/HomeCalls.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -6,6 +6,8 @@ * a cycle. */ import { useContext, useId, 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 { textInputClass } from '../../components/bits'; @@ -37,14 +39,45 @@ const FILE_IT_IN = 'File it in'; 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 it 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 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 list = initiatives ?? []; const current = row.slug ? homesOf(list).get(row.slug)?.initiative ?? null : null; // `away`: moving it out of the home it has (Track it, Move the project), - // so it starts on the guess + // so it starts on the guess; guessed only where a chooser can show it const guess = - (!current || away) && row.slug && list.length ? guessHome(row.slug, items, list) : null; - const [pick, setPick] = useState(null); + (asks || away) && (!current || away) && row.slug && list.length + ? guessHome(row.slug, items, list) + : null; const choices = homeChoices(list); const chosen = pick ?? (away ? guess?.slug : current?.slug ?? guess?.slug) ?? ''; const to = choices.find(i => i.slug === chosen); @@ -55,7 +88,6 @@ export function useHomeChooser(row: DecideRow, { away = false }: { away?: boolea current, picked: pick != null, guessed: pick == null && (!current || away) && !!guess && chosen === guess.slug, - setPick, filed: to && to.slug !== current?.slug ? ({ @@ -68,6 +100,25 @@ export function useHomeChooser(row: DecideRow, { away = false }: { away?: boolea }; } +/** 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 chooser's line, above the answers that use it: "File it in [Release * safety]", with "guessed" while it shows the board's guess. A select, so * the keyboard and a screen reader get the platform's own list. */ From cf6ad23ba7fc4f20a95adef4ccd7cfb81c38006b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 01:02:26 -0700 Subject: [PATCH 034/148] Keep Track it in place and focus its chooser Track it and Move the project swapped in a chooser above the answers, and the button in their slot became "File in Release safety", a guess: a double-click filed the project somewhere nobody picked, and the keyboard's focus fell to . Other answers did the same with "Close as dropped". The button clicked now stays where it was (aria-expanded, a second click closes), the other answers come after it, and the chooser opens under the answers with the focus and its own File button. The guess's hover says where the project is now ("It's in Catalog migration now", "It's Not tracked now") instead of always "It's Unsorted". Co-Authored-By: Claude Opus 5.5 --- .../src/views/projects/HomeCalls.test.tsx | 25 ++- frontend-v2/src/views/projects/HomeCalls.tsx | 149 ++++++++++++------ 2 files changed, 119 insertions(+), 55 deletions(-) diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index d5a0b19b..c3396d8f 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -190,7 +190,13 @@ describe('a row that asks about an initiative', () => { const el = render(goal); expect(buttons(el)).toEqual(['Close as doneSuggested', 'Other answers']); click(el, 'Other answers'); - expect(buttons(el)).toEqual(['Close as doneSuggested', 'Close as dropped', 'Keep open']); + // 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('keeps it open by saying its status again, and Undo puts back the times', async () => { @@ -258,10 +264,23 @@ describe('a Not tracked project that grew', () => { }); }); - it('tracks it in the chooser’s pick from Track it', () => { + 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'); - expect(el.querySelector('select')).not.toBeNull(); + // 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 = [...el.querySelectorAll('button')].find(b => b.textContent?.startsWith('File in')); + expect(file?.closest('[role="toolbar"]')).toBeNull(); + // 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.' + ); }); }); diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx index 80dde156..fe56f40d 100644 --- a/frontend-v2/src/views/projects/HomeCalls.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -5,7 +5,7 @@ * (model/decideCalls.ts) and never Decide.tsx, so Decide imports it without * a cycle. */ -import { useContext, useId, useState } from 'react'; +import { useContext, useId, useState, type ReactNode } from 'react'; import { createMemoryStore } from '../../storage'; import type { PortfolioItem } from '../../model/portfolio'; import type { DecideRow } from '../../../../shared/model/decide'; @@ -125,18 +125,40 @@ export function useChosenHomes(): (row: DecideRow) => { waiting: boolean; filed? export function HomeChooser({ name, chooser, + autoFocus = false, + below = false, + children, }: { name: string; - chooser: Pick, 'chosen' | 'choices' | 'guessed' | 'setPick'>; + chooser: Pick< + ReturnType, + 'chosen' | 'choices' | 'guessed' | 'setPick' | 'current' + >; + /** opened by a click (Track it, Move the project): the focus moves here */ + autoFocus?: boolean; + /** under the answers that opened it, rather than above the ones it feeds */ + below?: boolean; + /** the answer it feeds, on its own line (File it) */ + children?: ReactNode; }) { const id = useId(); + // 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 ( -
+
setPick(e.target.value)} onKeyDown={e => { - if (e.key === 'Escape') setMoving(false); + if (e.key !== 'Escape') return; + setMoving(false); + requestAnimationFrame(() => document.getElementById(moveId)?.focus()); }} className={`h-7 px-2 text-[13px] text-ink ${textInputClass}`} > @@ -169,7 +161,7 @@ export function HomeLine({ (pick || null) === (home?.slug ?? null) ? 'Choose another home first' : undefined } > - Move + File setMoving(false)}>Cancel @@ -184,11 +176,28 @@ export function HomeLine({ {receipt.undo ? ( Undo - ) : ( + ) : receipt.settled ? null : ( setReceipt(null)}>Close )} )} + {movable && !moving && (!receipt || receipt.settled) && ( + <> + {/* after an Undo, on its own line below the words */} + {receipt && } + { + setReceipt(null); + setPick(home?.slug ?? ''); + setMoving(true); + }} + aria-label={`File elsewhere: ${projectName}`} + > + File elsewhere + + + )} ); } @@ -638,7 +647,7 @@ export function InitiativePage({ ) : (

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

)} diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 59ee4767..9c38e9e5 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -897,7 +897,7 @@ function PlanPanel({ onPerson: (login: string) => void; onOpenItem: (id: number) => void; onOpenProject: (slug: string) => void; - /** the initiatives, for its project's home line and Move: the keyboard's + /** 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; diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx index a16cfeca..7e6611da 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -342,7 +342,7 @@ describe('the Roadmap by initiative', () => { body: { project: 'images', initiative: 'fixbot', expect: 'site-speed' }, }, ]); - expect(el.textContent).toContain('Moved Image CDN to FixBot chat.'); + 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' }); diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index f85895c1..defe6fff 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -314,7 +314,7 @@ export function initiativeLanes(c: LaneContext) { refocus.current = [`roadmap-grip-${item.id}`]; say({ key, - words: `Moved ${item.name} to ${toName}`, + words: `Filed ${item.name} in ${toName}`, undo: () => { refocus.current = [`roadmap-grip-${item.id}`]; // said at once, as the board shows it back at once From b911620cddfae70f40a439663b1338b20e279b7a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 02:09:25 -0700 Subject: [PATCH 049/148] Say a promised end one way everywhere The suggested button said "Promise to finish by Oct 31", the section's bulk receipt "Promised 1 by Oct 31", and the row's receipt right under it "Promised to finish by the end of Oct": one date two ways, on one screen. Every promise now says the end the way the end buttons and the Roadmap editor already do ("End of Oct"): "by the end of Oct", or "by its target date, Nov 1", on the answer, the bulk button, the receipt and the accessible names. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/decide.test.ts | 10 +++++----- frontend-v2/src/model/decideCalls.ts | 19 +++++++++---------- .../src/views/projects/DecideCall.test.tsx | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/frontend-v2/src/model/decide.test.ts b/frontend-v2/src/model/decide.test.ts index 45804f39..a0361a1e 100644 --- a/frontend-v2/src/model/decide.test.ts +++ b/frontend-v2/src/model/decide.test.ts @@ -602,16 +602,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'); diff --git a/frontend-v2/src/model/decideCalls.ts b/frontend-v2/src/model/decideCalls.ts index 3c09e998..061ea965 100644 --- a/frontend-v2/src/model/decideCalls.ts +++ b/frontend-v2/src/model/decideCalls.ts @@ -435,12 +435,11 @@ export const VERBS: Record = { }; /** - * 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. + * 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[], @@ -461,9 +460,7 @@ export function bulkWords( 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 - }` + ? ` by ${call.target && count > 1 ? 'their target dates' : call.through}` : call.kind === 'done' || call.kind === 'ongoing' ? ` ${call.kind}` : ''; @@ -481,7 +478,9 @@ export function answerWords(call: Call, plan: RoadmapItem | null): string { const now = plan?.status; switch (call.kind) { case 'commit': { - const day = dayWords(call.end); + // 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' diff --git a/frontend-v2/src/views/projects/DecideCall.test.tsx b/frontend-v2/src/views/projects/DecideCall.test.tsx index 4bdef173..e73e1b81 100644 --- a/frontend-v2/src/views/projects/DecideCall.test.tsx +++ b/frontend-v2/src/views/projects/DecideCall.test.tsx @@ -130,7 +130,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', () => { From 591d1510f118fa8bbd1f691bf7170f1cb13165c0 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 02:09:48 -0700 Subject: [PATCH 050/148] Name each answer by the words it shows "Restart, finishing by the end of Oct" was named "Promise Store picker by the end of Oct", and "Mark done" was "Mark Akeneo done", so a voice user saying what they see missed (WCAG 2.5.3). An answer's accessible name now starts with its own words, then the row's name and where the call files it ("Mark done: Akeneo"); an end button keeps "Promise X by the end of Oct", which holds its words. The project select is named "Its project: X", not "The project X is". Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Decide.tsx | 18 +++++++++--------- .../src/views/projects/DecideCall.test.tsx | 2 +- frontend-v2/src/views/projects/HomeCalls.tsx | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 11acee40..5e165fa2 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -925,19 +925,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}`, }, ] : []), @@ -956,6 +952,8 @@ function CallStrip({ // 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) && !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) => (
+ {file && ( + + )} + {onCancel && ( + + Cancel + + )} + ); } @@ -285,13 +340,12 @@ export function HomeStrip({ const filing: Call | null = chosen ? { kind: 'file', to: chosen.slug, name: chosen.name, untracked: chosen.kind === 'untracked' } : null; - const fileOption = { call: filing, words: filing ? answerWords(filing, null) : 'File it' }; 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' - ? [{ ...fileOption, suggested: chooser.guessed }] + ? [] : primary.kind === 'no_project' ? [ { @@ -331,6 +385,9 @@ export function HomeStrip({ : 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); @@ -359,7 +416,8 @@ export function HomeStrip({ disabled={!o.call} title={o.call ? undefined : 'Choose one above first'} onClick={() => o.call && onCall(o.call)} - className={`hit pressable inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium disabled:opacity-60 ${ + // a long initiative name wraps inside it on a phone + className={`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 only when there's an answer to make filled ? 'border-brand bg-brand text-surface hover:opacity-90' @@ -379,53 +437,61 @@ export function HomeStrip({ )} {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, + }} /> )} -
- {options.map(button)} - {primary.kind !== 'unfiled' && primary.kind !== 'no_project' && ( - - )} - {extras.map((o, k) => button(o, k + 1))} -
+ {primary.kind !== 'unfiled' && ( +
{ + 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)} + {primary.kind !== 'no_project' && ( + + )} + {extras.map((o, k) => button(o, k + 1))} +
+ )} {moves && pickable.length > 0 && ( - - + file={{ onFile: () => filing && onCall(filing), disabled: !filing }} + /> )}
); @@ -466,7 +532,7 @@ export function InitiativeSwitch({ id={id} value={value ?? ''} onChange={e => onChange(e.target.value || null)} - className={`h-8 px-2 text-[13px] text-ink ${textInputClass}`} + className={`h-8 min-w-0 max-w-full px-2 text-[13px] text-ink ${textInputClass}`} > {offered.map(([slug, name]) => ( diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index 4f8bccd8..fd0dcf3c 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -25,6 +25,7 @@ import { dayWords } from '../../model/projectData'; import { stageWord, type PortfolioItem } from '../../model/portfolio'; import { andList, UNSORTED_WORD } from '../../model/words'; import { DecideCall } from './Decide'; +import { HomeChooser } from './HomeCalls'; import { AllItemsContext, askedInitiative, @@ -83,7 +84,6 @@ export function HomeLine({ const [moving, setMoving] = useState(false); const [pick, setPick] = useState(home?.slug ?? ''); const [receipt, setReceipt] = useState(null); - const selectId = useId(); const moveId = useId(); const doneRef = useRef(null); useEffect(() => { @@ -138,45 +138,32 @@ export function HomeLine({ {UNSORTED_WORD} )} {moving && ( - - - - void move()} - disabled={(pick || null) === (home?.slug ?? null)} - title={ - (pick || null) === (home?.slug ?? null) ? 'Choose another home first' : undefined - } - > - File - - setMoving(false)}>Cancel - + void move(), + disabled: (pick || null) === (home?.slug ?? null), + }} + onCancel={() => { + setMoving(false); + requestAnimationFrame(() => document.getElementById(moveId)?.focus()); + }} + /> )} {receipt && ( {!receipt.failed && ( )} - + {receipt.words}. {receipt.undo ? ( From a29da1a6a106cc49ebaf3e4829aa863b205ac355 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:10:28 -0700 Subject: [PATCH 073/148] Close the innermost thing on Escape Escape did different things: in Decide's chooser it closed the chooser and focused its opener; in a plan's details it closed the whole details from inside File elsewhere; in an initiative's Edit form it dropped the focus to the page. Now it closes the innermost thing and gives the focus back to what opened it: the chooser (it stops there, so the details stay open), the answers Other answers opened, and the Edit form (back to Edit; a save's receipt takes the focus instead). Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Decide.tsx | 12 ++- .../src/views/projects/HomeCalls.test.tsx | 75 +++++++++++++++++++ .../src/views/projects/Initiatives.tsx | 14 +++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 9f9b2d71..fde601ab 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -6,6 +6,7 @@ import { useRef, useState, } from 'react'; +import { flushSync } from 'react-dom'; import { Check } from 'lucide-react'; import { epoch, issueUrl, n } from '../../../../shared/format'; import { @@ -928,6 +929,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 @@ -1032,6 +1034,7 @@ function CallStrip({ > {button(first, answerWords(first.call, row.item))}
); }; + const leads = leadWords(mine) || projectLeadWords(slugs.flatMap(s => bySlug.get(s) ?? [])); + const goingNames = [...going].map(s => bySlug.get(s)?.name ?? s); const facts: [string, string][] = ledger ? [['Health', owed ? `${n(owed, 'question')} on Decide` : 'nothing owed']] : [ ['Span', spanLine(span, live.length)], - // a closed one's head says how it closed; its end isn't estimated - ...(initiative.status === 'open' - ? [['Ends', endWords(span)] as [string, string]] + // 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', @@ -554,12 +563,10 @@ export function InitiativePage({ .filter(w => w && w !== 'nothing owed') .join(', ') || 'nothing owed', ], - [ - 'People', - leadWords(mine) || - projectLeadWords(slugs.flatMap(s => bySlug.get(s) ?? [])) || - 'No lead named on its plans', - ], + // 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 ( <> From 19253a76b26080fdc4ca4964a6ea544bbc7f5a43 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:13:24 -0700 Subject: [PATCH 077/148] Name a Roadmap question by the words it shows A row's question words on the timeline were named by a hidden sentence ("Catalog migration was closed as done, and this is being worked on again. Reopen its initiative?"), so the name didn't start with what's on screen, and a voice user couldn't say it. The button is named by its own words now, and the sentence is its description, which the details' answer reads too. (The initiative row's "Other answers: X" took the "Other answers for X" form with the one chooser.) Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Roadmap.tsx | 32 +++++++++++-------- .../src/views/projects/RoadmapLanes.test.tsx | 9 +++++- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index c6bf611e..5113c7d4 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -979,7 +979,7 @@ function PlanPanel({ project={linked} opened // the row's words above ask the question its answer answers - describedBy={asked ? `roadmap-words-${item.id}` : undefined} + describedBy={asked ? `roadmap-why-${item.id}` : undefined} /> {/* what finished looks like, so "Done?" has something to check; @@ -1501,21 +1501,25 @@ function PlanRow({ // 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 - + - + )} diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx index d50f8736..9bdca329 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -260,7 +260,14 @@ describe('a row’s question or clash', () => { 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 }); - act(() => el.querySelector('#roadmap-words-2')?.click()); + // 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 From 960f12e995df40821efb0046ea23a3df750d11af Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:14:12 -0700 Subject: [PATCH 078/148] Start every band's detail at one x on a phone At 390 an initiative band's detail sat at the band's right end when it fit beside the label and at the left when it wrapped, so neighbouring bands read two ways. On a phone a band with a gutter now always puts its detail on its own line, under the label; wider, it stays on the label's line at the right, as before. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/components/Lane.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx index fed8174f..ead6ca1b 100644 --- a/frontend-v2/src/components/Lane.tsx +++ b/frontend-v2/src/components/Lane.tsx @@ -515,7 +515,14 @@ export function Fold({ labelInner )} {detail && ( - + {detail} )} From a18c17f8d4c5986018ab28e0969cd914711fb453 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:15:36 -0700 Subject: [PATCH 079/148] Ask Still not tracked? in the Not tracked lane Decide and the Overview ask whether a Not tracked project that grew is still not tracked, but on the Roadmap its lane rested closed and its plan row showed no question, against DESIGN.md's first amendment: a row Decide asks about always shows. A question about a project with a plan under way and no question of its own is said on that plan's row (the details answer it), and the Not tracked lane opens while it holds a row Decide asks about. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/Roadmap.tsx | 21 +++++++++++-- .../src/views/projects/RoadmapLanes.test.tsx | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 5113c7d4..9efc3a08 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -2380,6 +2380,15 @@ export function Roadmap({ if (call.kind !== 'new') projectCalls.set(row.slug, call); } } + // a question about a project with a plan under way and no question of + // its own (Still not tracked?) is said on that plan's row, so every row + // Decide asks about shows on the timeline (DESIGN.md, amendment 1) + const asksProject = (item: RoadmapItem) => + !!item.project && isUnderWay(item.status) && projectCalls.has(item.project); + for (const item of everyPlan ?? []) { + if (!planCalls.has(item.id) && asksProject(item)) + planCalls.set(item.id, projectCalls.get(item.project as string) as PlanCall); + } const lanes = nav.group === 'team'; const byInitiative = nav.group === 'initiative' && !!initiatives?.length; const homes = homesOf(initiatives ?? []); @@ -2575,7 +2584,11 @@ export function Roadmap({ const mine = callsHere.all.filter(r => r.item?.id === item.id); return ( mine.find(r => r.reasons.length > 0) ?? - mine[0] ?? { slug: item.project, item, reasons: [] } + mine[0] ?? + // a question about its project, which its row says (Still not tracked?) + (asksProject(item) + ? callsHere.all.find(r => !r.item && r.slug === item.project) + : undefined) ?? { slug: item.project, item, reasons: [] } ); }; const say = (next: Receipt | null, words?: string) => { @@ -3182,7 +3195,11 @@ export function Roadmap({ gloss={`${NOT_TRACKED_GLOSS} They have no place in the order; the most recent PR first.`} load={null} lined={false} - defaultOpen={false} + // a ledger at rest, open while Decide asks about a row in it + defaultOpen={ + notTrackedPlans.some(i => planCalls.has(i.id)) || + notTrackedLoose.some(p => projectCalls.has(p.slug)) + } // under the initiative lanes, its label starts where theirs do gutter={byInitiative ? : undefined} > diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx index 9bdca329..19704cd2 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -297,6 +297,36 @@ describe('a row’s question or clash', () => { }); }); +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('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 }); From 7922eb77bf3cd79cdb9e6064a921ba6c59ced879 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:15:53 -0700 Subject: [PATCH 080/148] Record round 4's rules in DESIGN.md The one chooser and its layout, filing from a plan's details, where a question's or a clash's details put the focus, the Not tracked lane's question, one Escape rule, the strip's Tab stop and the held words. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 32ffa01a..58d85cef 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -869,13 +869,28 @@ icon with a class bolted on. 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 ("File it - in", "File in"), the button ("File in Release safety"), the receipt - ("Filed in Release safety", "Filed Image CDN in FixBot chat") and its - hover. The chooser shows only while the project is filed nowhere; a filed - project's plan answer stays a plan answer. A question or a clash in a - row's words does the natural thing on a click: the question opens the - plan's details with its answers, the clash the plan it waits on. + opener ("File elsewhere"), the chooser, the button, the receipt ("Filed + in Release safety", "Filed Image CDN in FixBot chat") and its hover. + 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. + 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. Undo's held words + can't be selected, and where the page can't scroll up to keep them + under the pointer, a gap holds them there through a triple click. "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" From 22806f8247bbc5cc8978682acf59b13f56d59469 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:17:32 -0700 Subject: [PATCH 081/148] Count reads after earlier tests' reads land Two tests count the GET /initiatives a 409 sends, and an earlier test's own reload could land inside that count when the suite ran shuffled (seed 23). They wait for stragglers and start from an empty log, so any order passes. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/HomeCalls.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index 6e227897..2c391410 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -534,6 +534,8 @@ describe('the File in chooser and the answer that waits on it', () => { }); 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(() => { @@ -906,6 +908,9 @@ describe('a pick left in a chooser that closed', () => { 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'), From abc1600f78e6299f547da7c25493f3a3cd8a58a2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 03:25:26 -0700 Subject: [PATCH 082/148] Hold Undo's words through a later shift too At 390 an Undo on a closed initiative's row still put the line above under the pointer: Decide's section count and the initiative page's "Closed as done" line come back with the Undo (at once, or when its save lands) and wrap on a phone, pushing the held words down. The Roadmap's keepInPlace becomes holdInPlace, shared with Decide's rows: for a triple click's time it follows the words, scrolling the page (with room added below a page too short to scroll), and lets go at once on a wheel, a touch or a key. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/components/holdInPlace.ts | 66 +++++++++++++++++++ frontend-v2/src/views/projects/Decide.tsx | 19 ++++-- .../src/views/projects/RoadmapLanes.tsx | 35 ++-------- 3 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 frontend-v2/src/components/holdInPlace.ts diff --git a/frontend-v2/src/components/holdInPlace.ts b/frontend-v2/src/components/holdInPlace.ts new file mode 100644 index 00000000..9b61d6d6 --- /dev/null +++ b/frontend-v2/src/components/holdInPlace.ts @@ -0,0 +1,66 @@ +import { flushSync } from 'react-dom'; + +/** How long Undo's words wait under the pointer before they rejoin their + * row: past a triple click. */ +const HOLD_MS = 900; + +/** + * Keep what `find` returns where it is on screen through `write` (an Undo + * that sends its row up or down the list, or changes a count or a line + * above it, at once or when its save lands): drawn at once, and for a + * triple click's time the page scrolls with it, so the words that replaced + * Undo stay under the pointer and a double-click's second press lands on + * them. Near the page's top, where there's no scrolling up, a gap above it + * holds it, then closes. A wheel, a touch or a key lets go at once. + * `write` gets the height the element had, for the words to keep. + */ +export function holdInPlace(find: () => HTMLElement | null, write: (held?: number) => void): void { + const want = find()?.getBoundingClientRect().top; + flushSync(() => write(find()?.getBoundingClientRect().height)); + if (want == null) return; + let gapped: HTMLElement | null = null; + let gap = 0; + // room below, for a page too short to scroll down with it + let room = 0; + let done = false; + const follow = () => { + const el = find(); + if (!el) return; + const top = el.getBoundingClientRect().top; + if (Math.abs(top - want) < 1) return; + // moved down further than the page can scroll + const canDown = document.documentElement.scrollHeight - innerHeight - scrollY; + if (top - want > canDown) { + room += Math.ceil(top - want - canDown); + document.body.style.paddingBottom = `${room}px`; + } + window.scrollBy(0, top - want); + // what the scroll couldn't make up (moved up, at the top) + const short = Math.round(want - el.getBoundingClientRect().top); + if (short < 1 && !(el === gapped && gap > 0)) return; + if (el !== gapped) gap = 0; + gap = Math.max(0, gap + short); + el.style.marginTop = gap ? `${gap}px` : ''; + gapped = el; + }; + const stop = () => { + if (done) return; + done = true; + for (const type of ['wheel', 'touchmove', 'keydown']) removeEventListener(type, stop, true); + if (room) document.body.style.paddingBottom = ''; + if (!gapped) return; + const still = matchMedia?.('(prefers-reduced-motion: reduce)').matches; + gapped.style.transition = still ? '' : 'margin-top 200ms ease-out'; + gapped.style.marginTop = ''; + }; + for (const type of ['wheel', 'touchmove', 'keydown']) addEventListener(type, stop, true); + follow(); + const start = performance.now(); + const tick = () => { + if (done) return; + if (performance.now() - start > HOLD_MS) return stop(); + follow(); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index fde601ab..aa1457f0 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -47,6 +47,7 @@ import { Segmented, textInputClass, } from '../../components/bits'; +import { holdInPlace } from '../../components/holdInPlace'; import { Icon } from '../../components/Icon'; import { ClosedRow } from '../../components/ClosedRow'; import { Fold, GroupHeader, Rows, SubDoor, Truncated } from '../../components/Lane'; @@ -808,10 +809,20 @@ export function DecideCall({ {shown.why && ` ${shown.why}`} {' '} { - setUndid({ height: receiptRef.current?.offsetHeight ?? 0, held: true }); - undoCall(rowKey(row)); - })} + onClick={act(() => + // its words where Undo was, even when a count above + // the row changes with it (a section's, on a phone) + holdInPlace( + () => rootRef.current, + () => { + setUndid({ + height: receiptRef.current?.offsetHeight ?? 0, + held: true, + }); + undoCall(rowKey(row)); + } + ) + )} aria-label={`Undo the decision on ${name}`} > Undo diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index cec41029..d069359b 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -11,6 +11,7 @@ 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 { holdInPlace } from '../../components/holdInPlace'; import { Fold, openFold, useFoldState } from '../../components/Lane'; import { askedInitiative, askOf } from '../../model/decideCalls'; import { @@ -54,40 +55,12 @@ export interface PlanRun { was: RoadmapItem; } -/** - * Keep the receipt with this key where it is on screen through `write` (an - * Undo that sends its row or lane back up or down the list): drawn at once, - * then the page scrolls with it before the click is done, so the words - * that replaced Undo stay under the pointer and a double-click's second - * press lands on them. - */ +/** Keep the receipt with this key where it is on screen through `write` + * (holdInPlace): an Undo that sends its row or lane back up or down. */ export function keepInPlace(key: string, write: (held?: number) => void): void { - const receipt = () => document.querySelector(`[data-receipt="${key}"]`); - const was = receipt()?.getBoundingClientRect(); - // the words keep the receipt's height, so a shorter line never leaves - // the next row where Undo was - flushSync(() => write(was?.height)); - const el = receipt(); - const now = el?.getBoundingClientRect(); - if (!was || !el || !now || now.top === was.top) return; - window.scrollBy(0, now.top - was.top); - // moved up near the page's top, where there's no scrolling up to follow - // it: a gap above the words holds them under the pointer through a - // double or triple click, then closes - const short = was.top - el.getBoundingClientRect().top; - if (short < 1) return; - el.style.marginTop = `${short}px`; - setTimeout(() => { - const still = matchMedia?.('(prefers-reduced-motion: reduce)').matches; - el.style.transition = still ? '' : 'margin-top 200ms ease-out'; - el.style.marginTop = ''; - }, HOLD_MS); + holdInPlace(() => document.querySelector(`[data-receipt="${key}"]`), write); } -/** How long Undo's words wait under the pointer before they rejoin their - * row: past a triple click. */ -const HOLD_MS = 900; - /** The initiatives as the board has them, just after a write. */ const readInitiativesNow = () => readInitiatives().initiatives ?? []; From 8cfd7865ac1333494eadfc109d0e3ae9235bc7b4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 04:16:26 -0700 Subject: [PATCH 083/148] Guard a still pointer after every click Three review rounds found the same fault in new places: a click changes what sits under the pointer (a receipt replaces the answers, a row joins a section above, a lane opens, a hold lets go on its timer), and a double-click's second press lands on a control that slid there. holdInPlace fought each shift by scrolling for 900 ms, which bounced on a second hold (its leftover transition) and shared body padding between overlapping holds. settle.ts replaces it with one rule for every view the initiatives feature draws on (Decide, the Roadmap and its details, the project and initiative pages, the Overview): a pointer click on a control in a PRESS_GUARD region holds its point, and a press within 24 px of it reaches nothing until the pointer moves away or a wheel scrolls, or 1.6 s with the pointer still. A drop holds its point too. Nothing is drawn, no style is left on the page, and no timer runs: each press checks. What the click left is focused and shown below the sticky headers (focusResult, reveal), so a details filing into a lane above no longer hides its receipt under the timeline header. Decide's held Undo words go when the pointer moves away or the next action starts, not only on Tab. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/components/holdInPlace.ts | 66 ----- frontend-v2/src/components/settle.test.ts | 252 ++++++++++++++++++ frontend-v2/src/components/settle.ts | 202 ++++++++++++++ frontend-v2/src/views/projects/Decide.tsx | 29 +- .../src/views/projects/HomeCalls.test.tsx | 98 +++++++ .../src/views/projects/Initiatives.tsx | 7 +- frontend-v2/src/views/projects/Overview.tsx | 3 +- .../src/views/projects/ProjectPage.tsx | 5 +- frontend-v2/src/views/projects/Roadmap.tsx | 8 +- .../src/views/projects/RoadmapLanes.test.tsx | 147 +++++++++- .../src/views/projects/RoadmapLanes.tsx | 9 +- 11 files changed, 738 insertions(+), 88 deletions(-) delete mode 100644 frontend-v2/src/components/holdInPlace.ts create mode 100644 frontend-v2/src/components/settle.test.ts create mode 100644 frontend-v2/src/components/settle.ts diff --git a/frontend-v2/src/components/holdInPlace.ts b/frontend-v2/src/components/holdInPlace.ts deleted file mode 100644 index 9b61d6d6..00000000 --- a/frontend-v2/src/components/holdInPlace.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { flushSync } from 'react-dom'; - -/** How long Undo's words wait under the pointer before they rejoin their - * row: past a triple click. */ -const HOLD_MS = 900; - -/** - * Keep what `find` returns where it is on screen through `write` (an Undo - * that sends its row up or down the list, or changes a count or a line - * above it, at once or when its save lands): drawn at once, and for a - * triple click's time the page scrolls with it, so the words that replaced - * Undo stay under the pointer and a double-click's second press lands on - * them. Near the page's top, where there's no scrolling up, a gap above it - * holds it, then closes. A wheel, a touch or a key lets go at once. - * `write` gets the height the element had, for the words to keep. - */ -export function holdInPlace(find: () => HTMLElement | null, write: (held?: number) => void): void { - const want = find()?.getBoundingClientRect().top; - flushSync(() => write(find()?.getBoundingClientRect().height)); - if (want == null) return; - let gapped: HTMLElement | null = null; - let gap = 0; - // room below, for a page too short to scroll down with it - let room = 0; - let done = false; - const follow = () => { - const el = find(); - if (!el) return; - const top = el.getBoundingClientRect().top; - if (Math.abs(top - want) < 1) return; - // moved down further than the page can scroll - const canDown = document.documentElement.scrollHeight - innerHeight - scrollY; - if (top - want > canDown) { - room += Math.ceil(top - want - canDown); - document.body.style.paddingBottom = `${room}px`; - } - window.scrollBy(0, top - want); - // what the scroll couldn't make up (moved up, at the top) - const short = Math.round(want - el.getBoundingClientRect().top); - if (short < 1 && !(el === gapped && gap > 0)) return; - if (el !== gapped) gap = 0; - gap = Math.max(0, gap + short); - el.style.marginTop = gap ? `${gap}px` : ''; - gapped = el; - }; - const stop = () => { - if (done) return; - done = true; - for (const type of ['wheel', 'touchmove', 'keydown']) removeEventListener(type, stop, true); - if (room) document.body.style.paddingBottom = ''; - if (!gapped) return; - const still = matchMedia?.('(prefers-reduced-motion: reduce)').matches; - gapped.style.transition = still ? '' : 'margin-top 200ms ease-out'; - gapped.style.marginTop = ''; - }; - for (const type of ['wheel', 'touchmove', 'keydown']) addEventListener(type, stop, true); - follow(); - const start = performance.now(); - const tick = () => { - if (done) return; - if (performance.now() - start > HOLD_MS) return stop(); - follow(); - requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); -} diff --git a/frontend-v2/src/components/settle.test.ts b/frontend-v2/src/components/settle.test.ts new file mode 100644 index 00000000..b222d27c --- /dev/null +++ b/frontend-v2/src/components/settle.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearHolds, GUARD_PX, guardAt, HOLD_MS, onLeave, reveal, settle } from './settle'; + +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); + +/** A region under the rule, with a button that counts its clicks. */ +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); + 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). */ +function pressAt(el: HTMLElement, 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); + el.dispatchEvent(new MouseEvent('mousedown', at)); + el.dispatchEvent(new MouseEvent('mouseup', at)); + el.dispatchEvent(new MouseEvent('click', { ...at, detail })); + return down; +} + +const moveTo = (x: number, y: number) => + document.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: x, clientY: y })); + +let now = 1000; +beforeEach(() => { + now = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => now); +}); +afterEach(() => { + clearHolds(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('the press guard', () => { + it('lets no press at a click’s point reach what slid there', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + expect(hits.first).toBe(1); + // the click moved `other` under the pointer: a double-click's second + // press finds it, and reaches nothing + const down = pressAt(other, 100 + GUARD_PX - 2, 100); + expect(hits.other).toBe(0); + // nothing takes the focus or starts a selection either + expect(down.defaultPrevented).toBe(true); + // the same control again, too: a triple click is one click + pressAt(first, 100, 100); + expect(hits.first).toBe(1); + }); + + it('lets a press away from the point through', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + pressAt(other, 100 + GUARD_PX + 2, 100); + expect(hits.other).toBe(1); + }); + + it('ends once the pointer moves away, and not for a wobble', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + 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('lets go after its time with the pointer still, and no sooner', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + 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('lets go on a wheel: the page moved on purpose', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100); + document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 40 })); + pressAt(other, 100, 100); + expect(hits.other).toBe(1); + }); + + it('holds nothing for a key’s click, or outside its regions', () => { + const { first, other, hits } = region(); + pressAt(first, 100, 100, 0); + pressAt(other, 100, 100); + expect(hits.other).toBe(1); + const outside = document.createElement('button'); + document.body.append(outside); + let outsideHits = 0; + outside.addEventListener('click', () => outsideHits++); + pressAt(outside, 300, 300); + pressAt(outside, 300, 300); + expect(outsideHits).toBe(2); + }); + + it('holds at a drop’s point', () => { + const { other, hits } = region(); + guardAt(50, 60); + pressAt(other, 52, 61); + expect(hits.other).toBe(0); + }); +}); + +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(); + }); +}); diff --git a/frontend-v2/src/components/settle.ts b/frontend-v2/src/components/settle.ts new file mode 100644 index 00000000..8ffedbc0 --- /dev/null +++ b/frontend-v2/src/components/settle.ts @@ -0,0 +1,202 @@ +import { flushSync } from 'react-dom'; + +/** + * One rule for what follows a click in the initiatives views (Decide, the + * Roadmap and a plan's details, a project's and an initiative's page, the + * Overview): nothing new can be clicked where the pointer is until it + * moves. A click there often changes what sits under the pointer (a + * receipt replaces the answers, a row joins a section above, a lane + * opens), and a double-click's second press, or a "to be sure" press a + * second later, would land on whatever slid there. + * + * A pointer click on a control inside a `PRESS_GUARD` region starts a hold + * at its point. While it lasts, a press within GUARD_PX of that point + * reaches nothing: no focus, no selection, no click. It lasts until the + * pointer moves away or a wheel scrolls, or HOLD_MS with the pointer + * still. Nothing is drawn and nothing waits on a timer: each press checks. + * + * 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; + +const CONTROL = + 'button, a[href], summary, [role="button"], [role="tab"], [role="radio"], [role="switch"], [role="menuitem"]'; + +interface Hold { + /** the press's point; null for a key's click, which holds no point */ + x: number | null; + y: number; + /** presses near the point reach nothing until then */ + until: number; + /** what goes once the pointer moves away or the next action starts */ + leave: (() => void)[]; +} + +let hold: Hold | null = null; + +const near = (h: Hold, e: MouseEvent) => + h.x != null && Math.hypot(e.clientX - h.x, e.clientY - h.y) <= GUARD_PX; + +const guarding = (e: MouseEvent) => !!hold && near(hold, e) && performance.now() < hold.until; + +/** 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()); +} + +/** 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. */ +export function guardAt(x: number | null, y = 0): void { + leave(); + hold = { x, y, until: x == null ? 0 : performance.now() + HOLD_MS, 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, until: 0, leave: [] }; + hold.leave.push(fn); +} + +/** End the hold without running its words' leave (tests start clean). */ +export function clearHolds(): void { + hold = null; +} + +if (typeof window !== 'undefined') { + const eat = (e: Event) => { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + const press = (e: MouseEvent) => { + if (guarding(e)) return eat(e); + // a press away from the point: the pointer moved there + if (hold && e.type === 'pointerdown' && !near(hold, e)) leave(); + }; + for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'dblclick']) + addEventListener(type, press as EventListener, true); + addEventListener( + 'click', + e => { + // a key's click (detail 0) is never a stray press + if (e.detail > 0 && guarding(e)) 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); + }, + true + ); + addEventListener( + 'pointermove', + e => { + if (hold?.x != null && !near(hold, e)) leave(); + }, + { capture: true, passive: true } + ); + // a scroll moves the page on purpose: what's under the pointer is seen + addEventListener( + 'wheel', + () => { + if (hold) hold.until = 0; + }, + { capture: true, passive: true } + ); +} + +/** 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); +} diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index aa1457f0..242e28a1 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -47,7 +47,7 @@ import { Segmented, textInputClass, } from '../../components/bits'; -import { holdInPlace } from '../../components/holdInPlace'; +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'; @@ -671,6 +671,15 @@ export function DecideCall({ 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) + rootRef.current + ?.querySelector('[data-decide-focus]') + ?.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 @@ -691,10 +700,11 @@ export function DecideCall({ const lost = document.activeElement === document.body && view !== 'ask'; if (!refocus.current && !lost) return; refocus.current = false; - // after an Undo, its held words; j and k still land on the answer - ( + // 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 ?? rootRef.current?.querySelector('[data-decide-focus]') - )?.focus(); + ); }, [view]); const act = (fn: () => void) => () => { refocus.current = true; @@ -811,8 +821,10 @@ export function DecideCall({ // its words where Undo was, even when a count above - // the row changes with it (a section's, on a phone) - holdInPlace( + // 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({ @@ -820,7 +832,8 @@ export function DecideCall({ held: true, }); undoCall(rowKey(row)); - } + }, + letGo ) )} aria-label={`Undo the decision on ${name}`} @@ -2361,7 +2374,7 @@ export function Decide({ reasons: [], }; return ( -
+
{ act(() => roots.splice(0).forEach(r => r.unmount())); clearHomePicks(); + clearHolds(); host?.remove(); host = null; calls.length = 0; @@ -205,6 +207,14 @@ const fileButton = (el: HTMLElement) => /** 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 = { @@ -579,6 +589,50 @@ describe('a plan with no project', () => { }); }); +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('a project whose closed initiative and done plan are both asked about', () => { const both: DecideRow = { slug: 'akeneo', @@ -992,6 +1046,50 @@ describe('Undo on a row', () => { 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 }, diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index a8f853e7..b9208fa9 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -19,6 +19,7 @@ import { import { EmptyState, FactLink, 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, updateInitiative, type Home } from '../../model/initiativesData'; import { closedWords, homeChoices } from '../../model/initiativeBoard'; import { dayWords } from '../../model/projectData'; @@ -87,7 +88,7 @@ export function HomeLine({ const moveId = useId(); const doneRef = useRef(null); useEffect(() => { - if (receipt) doneRef.current?.focus(); + if (receipt) focusResult(doneRef.current); }, [receipt]); const choices = homeChoices(initiatives); const say = (r: MoveReceipt) => { @@ -569,7 +570,7 @@ export function InitiativePage({ : []), ]; return ( - <> +

{initiative.name}

@@ -665,6 +666,6 @@ export function InitiativePage({

)}
- + ); } diff --git a/frontend-v2/src/views/projects/Overview.tsx b/frontend-v2/src/views/projects/Overview.tsx index b18042b3..291a3d1c 100644 --- a/frontend-v2/src/views/projects/Overview.tsx +++ b/frontend-v2/src/views/projects/Overview.tsx @@ -3,6 +3,7 @@ import { n } from '../../../../shared/format'; import { STALL_DAYS, type DecideRow } from '../../../../shared/model/decide'; import type { Today } from '../../../../shared/model/projects'; import type { Initiative } from '../../../../shared/model/initiatives'; +import { PRESS_GUARD } from '../../components/settle'; import { FactLink, LoadFailed } from '../../components/bits'; import { Fold, FoldRows, laneShown, RestGroup, SubDoor } from '../../components/Lane'; import type { RowOptions } from '../../components/Row'; @@ -563,7 +564,7 @@ export function Overview({ const outside = today.misc.length + today.unsorted.length; const allOpen = today.live.reduce((sum, g) => sum + g.open.length, 0) + outside; return ( -
+
{data === null && ( +

{said}

@@ -1217,6 +1218,6 @@ export function ProjectPage({ headerExtra={rangePicker} />
- +
); } diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 9efc3a08..177a12cd 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -54,6 +54,7 @@ import { Segmented, textInputClass, } from '../../components/bits'; +import { focusResult, guardAt, PRESS_GUARD } from '../../components/settle'; import { Icon } from '../../components/Icon'; import { eyebrowText, @@ -2801,6 +2802,8 @@ export function Roadmap({ onDrop: (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); + // a drop is a press: nothing it moves there takes a click yet + guardAt(e.clientX, e.clientY); const dropped = ordered.find(i => i.id === dragging); setDragging(null); setDropTarget(null); @@ -2953,7 +2956,8 @@ export function Roadmap({ if (!want) return; refocus.current = null; const el = want.map(id => document.getElementById(id)).find(Boolean); - el?.focus(); + // the receipt beside it shows too, below the sticky headers + focusResult(el, receipt && document.querySelector(`[data-receipt="${receipt.key}"]`)); }); const planRows = ( @@ -3401,7 +3405,7 @@ export function Roadmap({ ); return ( -
+
{/* the timeline's toolbar and column names stay in place while its rows scroll, from sm up; on a phone they'd eat the screen */}
{ calls.length = 0; }); afterEach(() => { + clearHolds(); // an unmounted tree stops listening to the stores the next test writes act(() => root?.unmount()); root = null; @@ -236,11 +238,15 @@ const receipts = (el: HTMLElement, words: string) => .length; const writes = () => calls.filter(c => c.method !== 'GET'); -/** A drag from `from`, over and onto `to`, as the browser sends it. */ -function drag(from: Element, to: Element): { refused: boolean } { +/** 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 }); + Object.assign(new Event(type, { bubbles: true, cancelable: true }), { + dataTransfer: data, + ...at, + }); act(() => { from.dispatchEvent(event('dragstart')); }); @@ -553,3 +559,138 @@ describe('the Roadmap by initiative', () => { 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('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, and every receipt at `receiptTop`. */ + 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 real = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + if (this === header) return box(0, 120); + if (this.closest('[data-receipt]')) return box(receiptTop(), 30); + return box(0, 0); + }; + 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.prototype.getBoundingClientRect = real; + 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('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, and no further + expect(page.scrolled.at(-1)).toBe(50 - 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); + }); +}); diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index d069359b..db8ac4c2 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -11,7 +11,7 @@ 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 { holdInPlace } from '../../components/holdInPlace'; +import { guardAt, settle } from '../../components/settle'; import { Fold, openFold, useFoldState } from '../../components/Lane'; import { askedInitiative, askOf } from '../../model/decideCalls'; import { @@ -56,9 +56,9 @@ export interface PlanRun { } /** Keep the receipt with this key where it is on screen through `write` - * (holdInPlace): an Undo that sends its row or lane back up or down. */ + * (settle): an Undo that sends its row or lane back up or down. */ export function keepInPlace(key: string, write: (held?: number) => void): void { - holdInPlace(() => document.querySelector(`[data-receipt="${key}"]`), write); + settle(() => document.querySelector(`[data-receipt="${key}"]`), write); } /** The initiatives as the board has them, just after a write. */ @@ -419,6 +419,9 @@ export function initiativeLanes(c: LaneContext) { 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); From 9b8ce2524b9de434742b2c0b234ac75696335112 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 04:17:07 -0700 Subject: [PATCH 084/148] Open File elsewhere below its home everywhere On a project's page and in a plan's details, File elsewhere replaced itself and the home words with the chooser, while on Decide it stays, says it's open, and opens the chooser below. The same words now do the same thing on every view: the opener stays (aria-expanded), a second click closes it, and the chooser opens on the line below. A project in a closed initiative started its chooser on that home, which no option carries, so the select showed the first initiative while File stayed off, and picking the one it showed fired no change. The chooser now shows "Choose where it goes" whenever nothing offered is chosen, and File comes on only for an offered home other than the project's own. Co-Authored-By: Claude Opus 5.5 --- .../src/views/projects/HomeCalls.test.tsx | 34 +- .../views/projects/HomeCalls.test.tsx.orig | 1271 +++++++++++++++++ frontend-v2/src/views/projects/HomeCalls.tsx | 5 +- .../src/views/projects/Initiatives.tsx | 52 +- 4 files changed, 1338 insertions(+), 24 deletions(-) create mode 100644 frontend-v2/src/views/projects/HomeCalls.test.tsx.orig diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index 41941783..07507bd9 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -808,11 +808,16 @@ describe('an initiative’s page', () => { }); describe('a project page’s home line', () => { - it('puts the chooser in place of its home, and File waits for another pick', () => { + 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'); - expect(el.textContent).not.toContain('in Site speed'); + // 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; @@ -825,6 +830,31 @@ describe('a project page’s home line', () => { 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( diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx.orig b/frontend-v2/src/views/projects/HomeCalls.test.tsx.orig new file mode 100644 index 00000000..41941783 --- /dev/null +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx.orig @@ -0,0 +1,1271 @@ +// @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 } 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, + keepCalls, + SectionCalls, + useKeptCalls, + type Made, +} from './Decide'; +import { + AllItemsContext, + answerWords, + callWords, + rowKey, + sectionOf, +} 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; + +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 } + : { 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(() => { + act(() => roots.splice(0).forEach(r => r.unmount())); + clearHomePicks(); + clearHolds(); + host?.remove(); + host = null; + 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 ?? '')); +/** 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('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 }, + }); + 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'); + }); +}); + +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', + set_at: 50, + baseline_prs: 2, + }); + }); + + 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.' + ); + }); +}); + +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('Set 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('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); + expect(buttons(el)).toEqual(['Link it']); + const select = el.querySelector('select') as HTMLSelectElement; + act(() => { + select.value = 'cache'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + click(el, 'Link to cache'); + 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('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]); + }); +}); + +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', + 'Catalog · 1', + ]); + }); + + 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', + 'No project yet · 1', + ]); + // 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('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('Close'); + }); +}); + +describe('a project page’s home line', () => { + it('puts the chooser in place of its home, and File waits for another pick', () => { + const el = mount(); + expect(el.textContent).toContain('in Site speed'); + click(el, 'File elsewhere'); + expect(el.textContent).not.toContain('in Site speed'); + 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('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.'); + }); +}); + +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', () => { + /** what its 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); + }; + const escape = (el: Element) => + act(() => { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + const frame = () => act(() => new Promise(resolve => setTimeout(resolve, 40))); + + 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 index 3078383e..a8fc9322 100644 --- a/frontend-v2/src/views/projects/HomeCalls.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -218,7 +218,10 @@ export function HomeChooser({ // a long name never pushes the page sideways on a phone className={`h-8 min-w-0 max-w-full px-2 text-[13px] text-ink ${textInputClass}`} > - {!chooser.chosen && !unsorted && } + {/* nothing offered is chosen (a home that closed since) */} + {!chosen && !(unsorted && chooser.chosen === '') && ( + + )} {chooser.choices.map(i => (
diff --git a/frontend-v2/src/model/decideCalls.ts b/frontend-v2/src/model/decideCalls.ts index 0804f9b2..eb98a6f7 100644 --- a/frontend-v2/src/model/decideCalls.ts +++ b/frontend-v2/src/model/decideCalls.ts @@ -40,6 +40,7 @@ import { pastEnd, targetOn, NO_PROJECT_YET, + MARKED_NOT_TRACKED, UNSORTED_WORD, } from './words'; import { PLAN_STATUS_WORD, when } from '../views/projects/roadmapHealth'; @@ -161,7 +162,7 @@ export const SECTIONS: { 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.', + '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.', ], }, ]; @@ -356,7 +357,7 @@ export function askOf(reason: DecideReason): { case 'no_project': return { question: 'Which project is it?', call: 'link' }; case 'untracked_grew': - return { question: 'Still not tracked?', call: 'keep_untracked' }; + return { question: 'Still Not tracked?', call: 'keep_untracked' }; case 'goal_reached': return { question: 'Did it reach its goal?', call: 'close' }; case 'reopened_in_closed': @@ -507,7 +508,7 @@ export function answerWords(call: Call, plan: RoadmapItem | null): string { case 'file': return call.untracked ? 'Mark Not tracked' : `File in ${call.name}`; case 'keep_untracked': - return 'Keep not tracked'; + return 'Keep Not tracked'; case 'link': return `Link to ${call.name}`; case 'close': @@ -739,7 +740,7 @@ export function callWords( } case 'file': return call.untracked - ? 'Marked Not tracked, so it counts nowhere. Decide asks again if it grows.' + ? `${MARKED_NOT_TRACKED}.` : `Filed in ${call.name}.`; case 'link': { // what the link did, by where that project is filed now @@ -751,7 +752,7 @@ export function callWords( : `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.'; + 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': diff --git a/frontend-v2/src/model/words.ts b/frontend-v2/src/model/words.ts index 6b92a0c3..a150c66e 100644 --- a/frontend-v2/src/model/words.ts +++ b/frontend-v2/src/model/words.ts @@ -48,6 +48,10 @@ 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 */ @@ -96,3 +100,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/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index c24d0c2b..92a9e27b 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -338,8 +338,8 @@ describe('a Not tracked project that grew', () => { 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'); + 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', @@ -1078,6 +1078,24 @@ describe('a project page’s home line', () => { 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', () => { @@ -1219,7 +1237,7 @@ describe('Undo on a row', () => { 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'); + click(el, 'Keep Not tracked'); await settle(); const undo = [...el.querySelectorAll('button')].find( b => b.textContent === 'Undo' @@ -1242,10 +1260,10 @@ describe('Undo on a row', () => { act(() => undo.click()); await settle(); expect(calls.length).toBe(sent); - expect(buttons(el)).toEqual(['Keep not trackedSuggested', 'Track it']); + 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' + 'Keep Not trackedSuggested' ); // and a Tab on from the words lets them go act(() => { @@ -1261,7 +1279,7 @@ describe('Undo on a row', () => {
); - click(el, 'Keep not tracked'); + click(el, 'Keep Not tracked'); await settle(); const undo = [...el.querySelectorAll('button')].find( b => b.textContent === 'Undo' @@ -1280,7 +1298,7 @@ describe('Undo on a row', () => { 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'); + expect(document.activeElement?.textContent).toBe('Keep Not trackedSuggested'); }); it('lets its words go when the next action starts', async () => { @@ -1289,7 +1307,7 @@ describe('Undo on a row', () => { ); - click(el, 'Keep not tracked'); + click(el, 'Keep Not tracked'); await settle(); click(el, 'Undo'); await settle(); diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx index a3d5be9d..b36546c2 100644 --- a/frontend-v2/src/views/projects/HomeCalls.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -337,7 +337,7 @@ function ProjectChooser({ * 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 + * 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 @@ -386,7 +386,7 @@ export function HomeStrip({ ? [ { call: notTracked ? { kind: 'keep_untracked', to: notTracked.slug } : null, - words: 'Keep not tracked', + words: 'Keep Not tracked', suggested: true, }, ] diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index 248faee8..5a6de2ae 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -24,7 +24,7 @@ import { fileProject, updateInitiative, type Home } from '../../model/initiative import { closedWords, homeChoices } from '../../model/initiativeBoard'; import { dayWords } from '../../model/projectData'; import { stageWord, type PortfolioItem } from '../../model/portfolio'; -import { andList, UNSORTED_WORD } from '../../model/words'; +import { andList, MARKED_NOT_TRACKED, UNSORTED_WORD, undoName } from '../../model/words'; import { DecideCall } from './Decide'; import { HomeChooser } from './HomeCalls'; import { @@ -46,6 +46,8 @@ 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; } @@ -101,7 +103,8 @@ export function HomeLine({ setMoving(false); if (to === (home?.slug ?? null)) return; if (onFile) return onFile(to); - const toName = homeName(choices.find(i => i.slug === 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; @@ -114,9 +117,10 @@ export function HomeLine({ baseline_prs: before.baseline_prs, }); // the server's reason, with the Undo kept to try again - if ('error' in back) say({ words: back.error.replace(/\.$/, ''), failed: true, undo }); + if ('error' in back) + say({ words: back.error.replace(/\.$/, ''), failed: true, undo, undoes: words }); }; - say({ words: `Filed in ${toName}`, undo }); + say({ words, undo }); }; const close = () => { setMoving(false); @@ -158,7 +162,7 @@ export function HomeLine({ {receipt.undo ? ( {receipt.failed ? 'Try again' : 'Undo'} @@ -345,6 +349,12 @@ function EditFields({ 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: { @@ -353,17 +363,13 @@ function EditFields({ }, }).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 } : null) + onDone( + 'error' in u + ? { words: u.error.replace(/\.$/, ''), failed: true, undo, undoes: words } + : null + ) ); - onDone({ - words: [ - set.length ? `Saved ${its(set)}` : '', - cleared.length ? `${set.length ? 'cleared' : 'Cleared'} ${its(cleared)}` : '', - ] - .filter(Boolean) - .join(' and '), - undo, - }); + onDone({ words, undo }); }; const field = `h-8 w-full px-2.5 text-[13px] text-ink ${textInputClass}`; return ( @@ -663,7 +669,7 @@ export function InitiativePage({ {receipt.undo ? ( {receipt.failed ? 'Try again' : 'Undo'} diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 8ba704e3..1bcdc69f 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -2415,7 +2415,7 @@ export function Roadmap({ if (call.kind !== 'new') projectCalls.set(row.slug, call); } } - // a question about a project with a plan under way (Still not tracked?) + // a question about a project with a plan under way (Still Not tracked?) // is said on one plan's row, its first in rank with no question of its // own, so every row Decide asks about shows on the timeline (DESIGN.md, // amendment 1), and once @@ -2625,7 +2625,7 @@ export function Roadmap({ return ( mine.find(r => r.reasons.length > 0) ?? mine[0] ?? - // a question about its project, which its row says (Still not tracked?) + // a question about its project, which its row says (Still Not tracked?) (asksProject(item) ? callsHere.all.find(r => !r.item && r.slug === item.project) : undefined) ?? { slug: item.project, item, reasons: [] } @@ -2643,7 +2643,7 @@ export function Roadmap({ const said = (key: string, words: string, held?: number) => say({ key, words, held }); // a save that failed says so where the click was, not in the toolbar; // `keep` holds a removed plan's place, and its Undo, to try again - const fail = (key: string, keep?: Pick) => { + const fail = (key: string, keep?: Pick) => { const why = readRoadmap().problem; dismissRoadmapProblem(); run.current = null; @@ -2786,7 +2786,7 @@ export function Roadmap({ const undo = () => { refocus.current = [`roadmap-grip-${item.id}`]; void restoreRoadmapItem(item.id).then(ok => - ok ? said(key, `Put ${item.name} back on the roadmap`) : fail(key, { gone: item, undo }) + ok ? said(key, `Put ${item.name} back on the roadmap`) : fail(key, { gone: item, undo, undoes: `Removed ${item.name} from the roadmap` }) ); }; say({ key, words: `Removed ${item.name} from the roadmap`, gone: item, undo }); diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx index b0fbd856..26f2f95c 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -335,12 +335,12 @@ describe('the Not tracked lane', () => { ) as HTMLDetailsElement; expect(fold.open).toBe(true); const words = el.querySelector('#roadmap-words-5') as HTMLButtonElement; - expect(words.textContent).toContain('Still not tracked?'); + 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'); + expect(details.textContent).toContain('Keep Not tracked'); }); it('says a project’s question on one of its plans, the first in rank', async () => { @@ -354,7 +354,7 @@ describe('the Not tracked lane', () => { await act(() => loadRoadmap()); const el = await mount({}, { decisions: [grew] }); const asking = [...el.querySelectorAll('[id^="roadmap-words-"]')].filter(w => - w.textContent?.includes('Still not tracked?') + w.textContent?.includes('Still Not tracked?') ); expect(asking.map(w => w.id)).toEqual(['roadmap-words-6']); } finally { @@ -778,6 +778,20 @@ describe('after a click on the Roadmap', () => { 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); @@ -869,7 +883,10 @@ describe('a write on the Roadmap that fails', () => { act(() => undoOf(el).click()); await settle(); expect(el.textContent).toContain('someone filed it in Catalog since.'); - const again = el.querySelector('[aria-label="Try the Undo again"]'); + // 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(); @@ -889,7 +906,13 @@ describe('a write on the Roadmap that fails', () => { act(() => undoOf(el).click()); await settle(); expect(el.textContent).toContain('the database is down.'); - act(() => el.querySelector('[aria-label="Try the Undo again"]')?.click()); + 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'); }); diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 6734c3f6..4d5cf074 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -22,7 +22,13 @@ import { } from '../../model/initiativeBoard'; import { byRank, fileProject, moveInitiative, readInitiatives } from '../../model/initiativesData'; import type { PortfolioItem } from '../../model/portfolio'; -import { andList, NO_PROJECT_YET, UNSORTED_WORD } from '../../model/words'; +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'; @@ -93,6 +99,8 @@ export interface Receipt { 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 */ @@ -135,7 +143,7 @@ export function ReceiptLine({ receipt, onOpen }: { receipt: Receipt; onOpen: (id {receipt.undo && ( {receipt.failed ? 'Try again' : 'Undo'} @@ -342,6 +350,8 @@ export function initiativeLanes(c: LaneContext) { // 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 @@ -355,14 +365,20 @@ export function initiativeLanes(c: LaneContext) { 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 }); + 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: `Filed ${item.name} in ${toName}`, undo }); + say({ key, words, undo }); }); }); }; @@ -408,7 +424,13 @@ export function initiativeLanes(c: LaneContext) { 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 }); + say({ + key, + words: done.error.replace(/\.$/, ''), + failed: true, + undo, + undoes: `Moved ${andList(now)}`, + }); }); }); }; From d85f1ce614d95adc1d03c027068f5b61c9aa3837 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 09:57:43 -0700 Subject: [PATCH 123/148] Mark the Roadmap's goal guess Suggested The Roadmap's goal row outlines its answers to sit quietly beside rows whose questions are words, and in doing so it dropped the Suggested mark that Overview and Decide show on "Close as done", so the board's own guess vanished in one view (UX review 7, L5). The outlined button keeps the mark. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/projects/HomeCalls.test.tsx | 7 +++++++ frontend-v2/src/views/projects/HomeCalls.tsx | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index 92a9e27b..811bc75c 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -254,6 +254,13 @@ describe('a row that asks about an initiative', () => { ]); }); + 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'); diff --git a/frontend-v2/src/views/projects/HomeCalls.tsx b/frontend-v2/src/views/projects/HomeCalls.tsx index b36546c2..240c1bf7 100644 --- a/frontend-v2/src/views/projects/HomeCalls.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.tsx @@ -354,8 +354,8 @@ export function HomeStrip({ name: string; describedBy?: string; onCall: (call: Call) => void; - /** its answer outlined and unmarked, at a row's end beside rows whose - * questions are words (the Roadmap's goal question) */ + /** 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); @@ -454,7 +454,8 @@ export function HomeStrip({ }`} > {o.words} - {filled && o.suggested && ( + {/* the board's guess says so outlined too, so no view loses it */} + {k === 0 && o.call && o.suggested && ( Suggested )} From 62d27c467d77d863ccd032050e8db42092e8fa2b Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 09:57:44 -0700 Subject: [PATCH 124/148] Ask Unsorted's question in its Plan cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the Overview's "Unsorted · 3" band, a row's Plan cell said "Unsorted" in amber, the band's own name, where its siblings named a call (UX review 7, L6). It asks Decide's question instead: "Which initiative is it in?". Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/portfolio.test.ts | 6 ++++++ frontend-v2/src/model/portfolio.ts | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts index ba2ea713..28545dbf 100644 --- a/frontend-v2/src/model/portfolio.test.ts +++ b/frontend-v2/src/model/portfolio.test.ts @@ -300,6 +300,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({ diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts index c752efb7..8cb254bc 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -264,9 +264,11 @@ 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 + // 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: UNSORTED_WORD }; + return { kind: 'no_plan', text: 'Which initiative is it in?' }; case 'no_project': return { kind: 'no_plan', text: NO_PROJECT_YET }; case 'untracked_grew': From f9d7c4db6ad93d5712e54d509cee6bf8dbcf6436 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 10:20:22 -0700 Subject: [PATCH 125/148] Dim a kept control instead of outlining it An eaten press outlined its control with the 2 px focus ring for 0.7 s. Under a still pointer, with its hover border too, the control looked picked or focused while the focus was elsewhere, and in dark mode the grey ring barely showed (UX review 7, L4). It now dims to 45% with a 3 px nudge either way for 360 ms, which reads as "not yet" in both themes. Under reduced motion it dims without moving and takes a dashed outline in its own colour. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/components/settle.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend-v2/src/components/settle.ts b/frontend-v2/src/components/settle.ts index fd25156f..0f8264d3 100644 --- a/frontend-v2/src/components/settle.ts +++ b/frontend-v2/src/components/settle.ts @@ -16,7 +16,7 @@ import { flushSync } from 'react-dom'; * 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 flashes an outline) when: + * 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 @@ -263,15 +263,25 @@ const again = (e: MouseEvent) => near(hold, e) && (e.target as Element | null)?.closest?.(TARGET) === hold.first; -/** Outline a control a press didn't reach, a moment, so it reads as kept - * from a stray press, not as dead. */ +/** 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 on = { outline: '2px solid var(--ring)', outlineOffset: '2px' }; - el.animate?.([on, still ? on : { ...on, outlineColor: 'transparent' }], { - duration: 700, - easing: 'ease-in', - }); + 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. */ From ea787d97b72e9f388ac1149b59c82665803485b5 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 10:20:29 -0700 Subject: [PATCH 126/148] Record round 7's rules in DESIGN.md The guard's rule as it now runs: the control just pressed is judged first, boxes are centre and layout size, a browser double-click on it is one click (an opener's too), aiming needs a rest or a double-click's span, any key but a modifier ends a hold, and the kept sign is a dim. It also says the touch rule out loud: a tap on what came under a still finger waits out the hold, since a finger has no move between taps. Also the wording: one receipt for filing into Not tracked, Not tracked capitalised as the home's name, an Undo's name without the full stop and a failed one naming its receipt, Suggested on the Roadmap's goal row, and Unsorted's Plan cell asking its question. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8f985984..4540bff7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -880,6 +880,13 @@ icon with a class bolted on. "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; @@ -899,7 +906,7 @@ icon with a class bolted on. 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. + 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 @@ -915,20 +922,32 @@ icon with a class bolted on. 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 press there reaches nothing (and the control flashes - an outline) only if it lands on a control that arrived or moved since, - or on the same control again within a double-click. A control that - stayed put takes every press: a toggle closes again at once, the next - week, a neighbour, a grip's drag. 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. What the click + 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. + 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 @@ -937,7 +956,8 @@ icon with a class bolted on. 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. On a + 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 From bafc86fe010f864ef0135fab522fcbfc5ebba918 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 12:05:28 -0700 Subject: [PATCH 127/148] Keep drafts out of every Projects count Kyle: draft PRs are common and mean essentially nothing, they're just a prompt. A draft isn't work yet, so the one rule now lives in buildToday, which the board and the server both run: a draft goes to its project's `drafts` (or Today's `drafts` when no listed project holds it), never to `open`, misc, unsorted or two projects, and a draft closed as one never counts as leaving. Idle days, people, flags and live vs quiet read the real PRs, so a project with only drafts is quiet when its issue is open. The server half follows: planWork skips drafts (Decide's open, after-end and after-done counts), the window's spans and the days spent leave drafts out (throughput, backlog, who is on what), and /api/v1/projects lists each project's drafts and the rest beside the counts. The project page's lookup of live rows takes drafts too, so a draft there still draws as its row. Co-Authored-By: Claude Opus 5.5 --- controllers/projects.js | 4 ++ frontend-v2/src/model/initiativeBoard.test.ts | 3 ++ frontend-v2/src/model/portfolio.test.ts | 2 + frontend-v2/src/model/projects.test.ts | 35 +++++++++++++++++ frontend-v2/src/model/work.test.ts | 38 ++++++++++++++++++ frontend-v2/src/model/workData.ts | 1 + frontend-v2/src/views/Projects.tsx | 2 +- .../src/views/projects/ProjectPage.tsx | 4 +- lib/projects.js | 13 ++++--- lib/work.js | 9 +++-- shared/model/projects.ts | 39 +++++++++++++++++-- shared/model/work.ts | 11 +++++- test/projects.test.js | 18 +++++++++ 13 files changed, 163 insertions(+), 16 deletions(-) diff --git a/controllers/projects.js b/controllers/projects.js index 3b2633e0..de18008a 100644 --- a/controllers/projects.js +++ b/controllers/projects.js @@ -156,6 +156,8 @@ function projectRecords(projects, today, stats, prefix, { homes, untracked, grow // 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, @@ -253,6 +255,8 @@ export default { }, 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, }; diff --git a/frontend-v2/src/model/initiativeBoard.test.ts b/frontend-v2/src/model/initiativeBoard.test.ts index f103f12b..93cab600 100644 --- a/frontend-v2/src/model/initiativeBoard.test.ts +++ b/frontend-v2/src/model/initiativeBoard.test.ts @@ -52,6 +52,7 @@ const group = (slug: string, people: string[] = []): ProjectGroup => ({ slug, project: null, open: [], + drafts: [], merged: [], closed: [], people, @@ -99,6 +100,7 @@ describe('trackedToday', () => { misc: [], unsorted: [], doubleLabeled: [], + drafts: [], }; const tracked = trackedToday(today, new Set(['ops', 'quiet-ops'])); expect(tracked.live.map(g => g.slug)).toEqual(['kept']); @@ -316,6 +318,7 @@ describe('a Not tracked project', () => { 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')]; diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts index 28545dbf..7b0b03a4 100644 --- a/frontend-v2/src/model/portfolio.test.ts +++ b/frontend-v2/src/model/portfolio.test.ts @@ -103,6 +103,7 @@ function group( slug, project: p, open, + drafts: [], merged, closed: [], people, @@ -184,6 +185,7 @@ const today: Today = { misc: [], unsorted: [], doubleLabeled: [], + drafts: [], }; const projects = [ alpha, 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/work.test.ts b/frontend-v2/src/model/work.test.ts index 0c148855..f9161dee 100644 --- a/frontend-v2/src/model/work.test.ts +++ b/frontend-v2/src/model/work.test.ts @@ -286,6 +286,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 = { diff --git a/frontend-v2/src/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx index df4f1e6f..eacef293 100644 --- a/frontend-v2/src/views/projects/ProjectPage.tsx +++ b/frontend-v2/src/views/projects/ProjectPage.tsx @@ -595,11 +595,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])); diff --git a/lib/projects.js b/lib/projects.js index 867e440b..66e5089e 100644 --- a/lib/projects.js +++ b/lib/projects.js @@ -205,11 +205,13 @@ 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 no day until it's marked ready, and one closed as a draft + * never does. */ 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 @@ -265,7 +267,7 @@ 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'; // 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 ` + @@ -285,7 +287,8 @@ 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) + const found = [opened, merged, comments, stamps, reviews].flat().filter(r => !Number(r.draft)); const developers = Object.keys(settings.teams).length > 0; const counts = login => !isBot(login) && (!developers || settings.teamOf(login) != null); const rows = timeSpent( @@ -348,7 +351,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 FROM pulls ' + 'WHERE repo IN (?) AND date < ? AND (date_closed IS NULL OR date_closed >= ?)', [repos, to, from] ), diff --git a/lib/work.js b/lib/work.js index d7a0e40a..90bb795a 100644 --- a/lib/work.js +++ b/lib/work.js @@ -653,6 +653,7 @@ const workPull = row => ({ 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 @@ -690,7 +691,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, ${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 @@ -743,7 +744,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, ${ 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 ' + @@ -896,8 +897,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` FROM `pulls` WHERE (`repo`, `number`) IN (?)', pairs ), ]); 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/work.ts b/shared/model/work.ts index 35c85250..d14af93c 100644 --- a/shared/model/work.ts +++ b/shared/model/work.ts @@ -131,6 +131,8 @@ export interface WorkPull { 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 @@ -142,6 +144,9 @@ export interface IssuePull { author: string | null; createdAt: 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,6 +323,8 @@ export function planWork(inputs: WorkInputs): PlanWork[] { const mine = byProject.get(slug) ?? []; if (!mine.length) continue; for (const pr of prs) { + // a draft isn't work yet: not open, not late, not a comeback + if (pr.draft) continue; const plan = planOfWork(mine, pr.createdAt); if (!plan) continue; if (pr.state === 'open') openPulls.set(plan.id, (openPulls.get(plan.id) ?? 0) + 1); @@ -345,13 +352,15 @@ export function projectCounts(inputs: Pick): Map { + 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( [ @@ -369,6 +382,9 @@ 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), @@ -497,6 +513,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 }); From 627591a3775829fd1ace58b6ac8de3a6404286d8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 12:08:20 -0700 Subject: [PATCH 128/148] Fold a project's drafts at the end of its PRs A draft isn't work yet, so on a project's page it leaves every stage and count: its PRs not tied to an issue end in one fold, "Drafts, not counted", closed by default. A draft under an issue still shows there, says "a draft, not counted", and its issue's stage and "N PRs" door leave it out ("1 PR, 1 draft"); an issue whose only PR is a draft has no PR yet. The summary adds "2 drafts, not counted" under the open counts, which brings them into view like every other count there, so "None open" never reads as nothing at all. The stage glosses stop saying "a draft" is in development, and the board's live row says whether a PR is still a draft, newer than what the page was sent. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/stage.test.ts | 27 +++++- frontend-v2/src/model/stage.ts | 25 ++++-- .../src/views/projects/ProjectPage.tsx | 13 ++- frontend-v2/src/views/projects/Work.test.tsx | 86 +++++++++++++++++++ frontend-v2/src/views/projects/Work.tsx | 67 +++++++++++---- 5 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 frontend-v2/src/views/projects/Work.test.tsx diff --git a/frontend-v2/src/model/stage.test.ts b/frontend-v2/src/model/stage.test.ts index 2e85b792..dab839d9 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'); @@ -419,4 +430,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..c3753029 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); @@ -296,9 +307,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/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx index eacef293..ea2c6026 100644 --- a/frontend-v2/src/views/projects/ProjectPage.tsx +++ b/frontend-v2/src/views/projects/ProjectPage.tsx @@ -41,6 +41,7 @@ import { reloadProjectWork, useProjectWork } from '../../model/projectWork'; import { updateRoadmapItem } from '../../model/roadmapData'; import { addedLater, + isDraftPull, issueForecast, plannedAt, prStage, @@ -637,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); } @@ -1097,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) && ( 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..2e49c2a1 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, @@ -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,14 @@ 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', }; +/** the fold at the end of a project's PRs that lists its drafts */ +const DRAFTS_LABEL = 'Drafts, not counted'; +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 +93,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'; }; @@ -274,6 +287,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 (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 +536,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 +563,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) => { @@ -934,7 +957,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 +1039,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 +1051,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_LABEL, 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 +1163,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 +1176,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 +1198,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'} > Date: Mon, 12 Oct 2026 12:17:08 -0700 Subject: [PATCH 129/148] Keep drafts one click away across the tab With drafts out of every count, each place a lead would look for them now has them in a closed "Drafts, not counted" fold: the Overview's PRs outside projects (drafts no listed project holds), a project's row opened in the list, and a person's row on People. The drafts fold on a project's page drops its rows' own "a draft" note, since the fold's name says it. Rows stay as calm as they were. The Open column's hover says "and 1 draft, not counted" when a project has some, and so do the roadmap's open count and Decide's facts. "Drafts included" on the Open column and on who is on what said the old rule; both now say drafts are left out. With only drafts outside projects, that group says "None open, 1 draft, not counted" rather than "0 open PRs". Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/words.ts | 3 + frontend-v2/src/views/Projects.tsx | 1 + frontend-v2/src/views/projects/Decide.tsx | 4 ++ frontend-v2/src/views/projects/Overview.tsx | 27 ++++++-- frontend-v2/src/views/projects/People.tsx | 64 ++++++++++++++++++- frontend-v2/src/views/projects/Portfolio.tsx | 32 ++++++++-- frontend-v2/src/views/projects/Roadmap.tsx | 6 +- .../src/views/projects/WhoIsOnWhat.tsx | 2 +- frontend-v2/src/views/projects/Work.tsx | 13 ++-- 9 files changed, 134 insertions(+), 18 deletions(-) diff --git a/frontend-v2/src/model/words.ts b/frontend-v2/src/model/words.ts index a150c66e..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" */ diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index 37306145..18f1f3dd 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -305,6 +305,7 @@ export function Projects({ items={allItems} nav={nav} navigate={navigate} + opts={tabOpts} /> ) : nav.view === 'decide' ? ( - {outside + today.doubleLabeled.length > 0 && ( + {outside + today.doubleLabeled.length + today.drafts.length > 0 && ( + + + )} diff --git a/frontend-v2/src/views/projects/People.tsx b/frontend-v2/src/views/projects/People.tsx index 7e799a1c..10ebcdd8 100644 --- a/frontend-v2/src/views/projects/People.tsx +++ b/frontend-v2/src/views/projects/People.tsx @@ -11,6 +11,7 @@ import { import { Check, ChevronRight, Plus, X } from 'lucide-react'; import { n } from '../../../../shared/format'; import type { PersonWindow, Today, WeekPoint } from '../../../../shared/model/projects'; +import type { DerivedPull } from '../../../../shared/model/status'; import { mondayOf } from '../../../../shared/model/roadmap'; import type { DeveloperTeams } from '../../../../shared/model/settings'; import { @@ -21,7 +22,16 @@ import { textInputClass, } from '../../components/bits'; import { Icon } from '../../components/Icon'; -import { Fold, foldDomId, GroupHeader, openFold, SubDoor } from '../../components/Lane'; +import { + Fold, + foldDomId, + FoldRows, + GroupHeader, + laneShown, + openFold, + SubDoor, +} from '../../components/Lane'; +import type { RowOptions } from '../../components/Row'; import { useRowKeys } from '../../components/useRowKeys'; import { findFilter, type FindFields, type PortfolioItem } from '../../model/portfolio'; import { @@ -46,7 +56,7 @@ import { } from '../../model/retro'; import { retryRetroData } from '../../model/retroData'; import { saveDeveloperTeams } from '../../model/settingsData'; -import { days, devDays, NOT_IN_A_PROJECT, OVERLOADED } from '../../model/words'; +import { days, devDays, DRAFTS_NOT_COUNTED, NOT_IN_A_PROJECT, OVERLOADED } from '../../model/words'; import { PersonCell, StatsCard } from '../stats/parts'; import { ChartSlot, SplitWeeksChart } from './lazyCharts'; import { @@ -597,10 +607,37 @@ function cellText(row: PersonRow, key: Column['key']): string { return key === 'reviewing' ? `${Math.round(100 * value)}%` : String(Math.round(value)); } +/** A person's row opened in place: their projects, then their open drafts + * folded at its end. */ +function Detail({ + drafts, + opts, + ...rest +}: Parameters[0] & { drafts: DerivedPull[]; opts: RowOptions }) { + const id = `people:drafts:${rest.row.login.toLowerCase()}`; + return ( + <> + + {drafts.length > 0 && ( +

+ + + +
+ )} + + ); +} + /** A person's projects, opened in place, as a list under their name: what * they did on each and their days there, the week they stopped when they * have, and its plan, which opens on the roadmap. */ -function Detail({ +function PersonProjects({ row, items, nameOf, @@ -892,6 +929,8 @@ function PeopleList({ me, nav, navigate, + drafts, + opts, }: { rows: PersonRow[]; line: number; @@ -903,6 +942,9 @@ function PeopleList({ me: string; nav: ProjectsNav; navigate: Navigate; + /** each person's open drafts, by lowercase login */ + drafts: ReadonlyMap; + opts: RowOptions; }) { // rows opened by a click, by lowercase login; the picked person's is // open while the pick stands @@ -1111,6 +1153,8 @@ function PeopleList({ onToggle={toggle} detail={row => ( { + const by = new Map(); + for (const p of [...today.live, ...today.quiet].flatMap(g => g.drafts).concat(today.drafts)) { + const key = p.data.user.login.toLowerCase(); + by.set(key, [...(by.get(key) ?? []), p]); + } + return by; + }, [today]); // the numbers on screen: the range's once both halves land, and until // then the last range's, dimmed, rather than a page blanked to one line // (a failed load says so instead) @@ -1403,6 +1459,8 @@ export function People({ me={me} nav={nav} navigate={navigate} + drafts={draftsBy} + opts={opts} /> {/* right under the folds they sort, above the charts they split */} diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx index a98c23ce..4b9cc3f5 100644 --- a/frontend-v2/src/views/projects/Portfolio.tsx +++ b/frontend-v2/src/views/projects/Portfolio.tsx @@ -8,7 +8,7 @@ import { type ReactNode, } from 'react'; import { ChevronRight } from 'lucide-react'; -import { issueUrl, pullKey, shortRepo } from '../../../../shared/format'; +import { issueUrl, n, pullKey, shortRepo } from '../../../../shared/format'; import { STALL_DAYS, type DecideRow } from '../../../../shared/model/decide'; import { utcDay } from '../../../../shared/model/projects'; import { FactLink, QuietButton, Segmented, textInputClass } from '../../components/bits'; @@ -45,6 +45,7 @@ import { days, daysShort, devDays, + DRAFTS_NOT_COUNTED, LAST_14_DAYS, NO_PLAN, GOAL, @@ -306,10 +307,17 @@ const COLUMNS: Column[] = [ { key: 'open', label: 'Open', - title: 'Its open PRs now, drafts included', + title: 'Its open PRs now, drafts left out: a draft isn’t work yet', width: 'w-12', hide: 'hidden sm:block', - cell: i => i.open || '', + cell: i => + i.open && i.group?.drafts.length ? ( + + {i.open} + + ) : ( + i.open || '' + ), }, { key: 'waiting', @@ -535,7 +543,10 @@ function RowDetail({

)} - {item.group && (item.group.open.length > 0 || item.group.merged.length > 0) && ( + {item.group && + (item.group.open.length > 0 || + item.group.merged.length > 0 || + item.group.drafts.length > 0) && (
+ + +
)} diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 1bcdc69f..a66fafa1 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -1901,7 +1901,11 @@ function InFlightRow({ onClick={onOpen} // the name opens the same page from the keyboard tabIndex={-1} - title="Open the project and its PRs" + title={`Open the project and its PRs${ + item.group?.drafts.length + ? `, and its ${n(item.group.drafts.length, 'draft')}, not counted` + : '' + }`} > {saving ? 'Saving its plan…' : n(item.open, 'open PR')} , 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.tsx b/frontend-v2/src/views/projects/Work.tsx index 2e49c2a1..a852f6b5 100644 --- a/frontend-v2/src/views/projects/Work.tsx +++ b/frontend-v2/src/views/projects/Work.tsx @@ -49,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" */ @@ -81,8 +81,7 @@ const PR_GLOSS: Record = { work: 'Blocked, failing CI, signed off but in conflict, or has changes asked for', }; -/** the fold at the end of a project's PRs that lists its drafts */ -const DRAFTS_LABEL = 'Drafts, not counted'; +/** 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.'; @@ -264,6 +263,7 @@ function PullItem({ navigate, asRow = true, whoSaid = false, + draftSaid = false, }: { pr: IssuePull; pulls: PullLookup; @@ -275,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; @@ -288,7 +290,7 @@ function PullItem({ 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 (isDraftPull(pr, pulls.live)) { + if (!draftSaid && isDraftPull(pr, pulls.live)) { parts.push(parts.length ? DRAFT_WORDS : upperFirst(DRAFT_WORDS)); } if (late) parts.push(parts.length ? late : upperFirst(late)); @@ -1052,7 +1054,7 @@ export function ProjectWorkSections({ window: `in the ${LAST_14_DAYS}`, }, // last, closed, one click away: a draft isn't work yet - { id: 'drafts' as const, word: DRAFTS_LABEL, gloss: DRAFTS_GLOSS, window: undefined }, + { 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); @@ -1217,6 +1219,7 @@ export function ProjectWorkSections({ repoShown={repoShown} nameOf={nameOf} navigate={navigate} + draftSaid={b.id === 'drafts'} /> ))} From 247de07fc29a97faeaecb4680d87f1c11ac79b82 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 12:17:09 -0700 Subject: [PATCH 130/148] Show drafts on the fake board The fake board had one draft and no project to show the rule on. Newsletter promo's two PRs are drafts now, a project with a plan and only drafts; one of MySQL 8's is too, a mix, and it's the viewer's, so People shows a person's drafts; and a one-off's lists under PRs outside projects. The fake board's window and days leave drafts out the way the server's do, so its backlog chart agrees with the counts. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/backend/dummy.ts | 8 +++++++- frontend-v2/src/model/projectData.ts | 3 ++- frontend-v2/src/model/retroData.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 3ad666a4..b9a3dc66 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -136,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]); @@ -143,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/model/projectData.ts b/frontend-v2/src/model/projectData.ts index 8cd4fa74..4d50ccc5 100644 --- a/frontend-v2/src/model/projectData.ts +++ b/frontend-v2/src/model/projectData.ts @@ -249,7 +249,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), 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 }, From b3a0d9d9ea5a23f408a3a5ebfd30e9e85887b191 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 12 Oct 2026 12:17:31 -0700 Subject: [PATCH 131/148] Record the draft rule in DESIGN.md Next to "a PR counts where it links": a draft isn't work yet, so no count takes one, and each waits in a closed "Drafts, not counted" fold where someone would look. Says where those folds are and how a line that says nothing is open names the drafts, so the next view follows. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 4540bff7..38e6620f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -392,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 From f92aab23c430e53ddf145d83f6bc7a3c2f226cef Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 13 Oct 2026 17:15:35 -0700 Subject: [PATCH 132/148] Pick the months the roadmap shows The timeline's window was always built from today, so after a year of plans was backfilled nobody could move it back to see them. A date range picker now sets the window, kept in the URL as `window` (a preset or "2025-10..2026-09"); with no key the roadmap opens and links as before. Its columns follow its length, the arrows step by the window's own length on every view, Today puts back the default, the edge chip moves the window to its plan, zooming out keeps your place, and done plans that ended before the window rest in one closed fold. Also: a done or dropped plan's details no longer offer Decide's calls, and "All quarters" no longer wraps on a phone. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/app.tsx | 6 + frontend-v2/src/lens.ts | 6 + frontend-v2/src/model/roadmapTime.test.ts | 144 +++++++++++ frontend-v2/src/model/roadmapTime.ts | 174 +++++++++++-- .../src/views/projects/DateRangePicker.tsx | 42 +++- .../src/views/projects/RangeCalendar.test.tsx | 55 +++++ .../src/views/projects/RangeCalendar.tsx | 28 ++- frontend-v2/src/views/projects/Roadmap.tsx | 231 ++++++++++++++---- .../src/views/projects/RoadmapLanes.test.tsx | 1 + frontend-v2/src/views/projects/parts.tsx | 3 + 10 files changed, 613 insertions(+), 77 deletions(-) create mode 100644 frontend-v2/src/views/projects/RangeCalendar.test.tsx diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index a5be4642..8b211323 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -18,6 +18,7 @@ import { DEFAULT_SORT, LENS_LABELS, ORIGIN_KEYS, + WINDOW_KEY, ZOOM_KEY, type Lens, } from './lens'; @@ -173,6 +174,7 @@ function readHash(): HashState { : 'all', week: /^\d{4}-\d{2}-\d{2}$/.test(p.get('week') ?? '') ? p.get('week') : null, zoom: ZOOM_KEY.test(p.get('zoom') ?? '') ? p.get('zoom') : null, + window: WINDOW_KEY.test(p.get('window') ?? '') ? p.get('window') : null, team: p.get('team') || null, origin: ORIGIN_KEYS.find(o => o === p.get('origin')) ?? null, by: @@ -212,6 +214,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); @@ -459,6 +462,8 @@ export function App() { 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, @@ -484,6 +489,7 @@ export function App() { prev.project !== view.project || prev.projectsView !== view.projectsView || prev.zoom !== view.zoom || + prev.window !== view.window || prev.only !== view.only || prev.team !== view.team || prev.week !== view.week || diff --git a/frontend-v2/src/lens.ts b/frontend-v2/src/lens.ts index 80cd290c..2eb39b5f 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -20,6 +20,12 @@ export const DEFAULT_GROUP = 'initiative'; * the same reason (model/roadmapTime.ts reads it). */ export const ZOOM_KEY = /^(\d{4})-(?:Q([1-4])|(0[1-9]|1[0-2]))$/; +/** The roadmap's window in the URL, checked here for the same reason + * (model/roadmapTime.ts reads it): a preset's key, or whole months, + * "2025-10..2026-09", the last one counted. */ +export const WINDOW_KEY = + /^(?:quarter|2q|6m|ytd|12m|year|\d{4}-(?:0[1-9]|1[0-2])\.\.\d{4}-(?:0[1-9]|1[0-2]))$/; + /** The origins a roadmap filter can pick in the URL, kept here for the same * reason: shared/model/roadmap.ts's ROADMAP_ORIGINS, plus `unsaid` (a test * holds the two lists together). */ diff --git a/frontend-v2/src/model/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts index 1b857622..059a59a8 100644 --- a/frontend-v2/src/model/roadmapTime.test.ts +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -1,8 +1,21 @@ import { describe, expect, it } from 'vitest'; +import { WINDOW_KEY } from '../lens'; import { columnsFor, commitEnds, + defaultWindow, + doneBefore, + drawnWindow, parseZoom, + resolveWindow, + shiftWindow, + windowHolding, + windowKey, + windowOfDays, + windowUnit, + windowWords, + WINDOW_PRESETS, + type Months, quarterOf, shiftZoom, zoomAround, @@ -114,3 +127,134 @@ 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 key = (k: string) => windowKey(resolveWindow(k, now) as Months); + + it('resolves every preset as of today, year to date through this month', () => { + expect(WINDOW_PRESETS.map(([k]) => [k, key(k)])).toEqual([ + ['quarter', '2026-10..2026-12'], + ['2q', '2026-10..2027-03'], + ['6m', '2026-10..2027-03'], + ['ytd', '2026-01..2026-10'], + ['12m', '2025-11..2026-10'], + ['year', '2026-01..2026-12'], + ]); + // in February, year to date is two months + expect(windowKey(resolveWindow('ytd', new Date(2027, 1, 3)) as Months)).toBe( + '2027-01..2027-02' + ); + }); + + it('reads its own keys back, future ones too, and refuses anything else', () => { + for (const k of ['2025-10..2026-09', '2027-01..2027-01', '2026-11..2028-04']) { + expect(key(k)).toBe(k); + } + // what the URL accepts (lens.ts) is what the picker writes + for (const [k] of WINDOW_PRESETS) expect(WINDOW_KEY.test(k)).toBe(true); + expect(WINDOW_KEY.test(key('ytd'))).toBe(true); + for (const bad of ['2026-10..2026-09', '2026-13..2027-01', '2026-10', 'all', '', null]) { + expect(resolveWindow(bad, now)).toBeNull(); + } + }); + + 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))).toBe('2026-07..2027-06'); + expect(windowKey(defaultWindow('month', now))).toBe('2026-09..2027-03'); + }); + + it('draws months up to four, or a year not on quarters; quarters past that', () => { + const unit = (k: string) => windowUnit(resolveWindow(k, now) as Months); + expect(unit('quarter')).toBe('month'); + expect(unit('ytd')).toBe('month'); + expect(unit('12m')).toBe('month'); + expect(unit('2q')).toBe('quarter'); + expect(unit('year')).toBe('quarter'); + expect(unit('2025-11..2026-02')).toBe('month'); + // longer than a year, off the quarters: out to whole quarters + const long = drawnWindow('quarter', resolveWindow('2025-11..2027-01', now), now); + expect([windowKey(long.months), long.unit]).toEqual(['2025-10..2027-03', 'quarter']); + }); + + it('draws a picked window as its columns', () => { + const ytd = columnsFor('quarter', null, now, resolveWindow('ytd', now)); + 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('quarter', null, now, resolveWindow('2025-10..2026-09', now)); + 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, resolveWindow('ytd', now))).toEqual( + columnsFor('quarter', parseZoom('2026-Q4'), now) + ); + }); + + it('steps by its own length', () => { + const ytd = resolveWindow('ytd', now) as Months; + expect(windowKey(shiftWindow(ytd, -1))).toBe('2025-03..2025-12'); + expect(windowKey(shiftWindow(defaultWindow('quarter', now), -1))).toBe('2025-07..2026-06'); + expect(windowKey(shiftWindow(defaultWindow('month', now), 1))).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'))).toBe('2025-10..2026-09'); + expect(windowKey(windowHolding(w, '2027-09-06', 'month'))).toBe('2027-09..2028-08'); + }); + + it('takes the months of the days picked on a calendar', () => { + expect(windowKey(windowOfDays('2027-02-14', '2027-05-02'))).toBe('2027-02..2027-05'); + }); + + it('says what it covers in its columns’ unit', () => { + const words = (k: string) => { + const d = drawnWindow('quarter', resolveWindow(k, now), now); + return windowWords(d.months, d.unit); + }; + expect(words('ytd')).toBe('Jan to Oct 2026'); + expect(words('2025-10..2026-09')).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]); + }); +}); diff --git a/frontend-v2/src/model/roadmapTime.ts b/frontend-v2/src/model/roadmapTime.ts index 1c03b6e0..055c518f 100644 --- a/frontend-v2/src/model/roadmapTime.ts +++ b/frontend-v2/src/model/roadmapTime.ts @@ -1,4 +1,5 @@ -import { ZOOM_KEY } from '../lens'; +import { endOf, type RoadmapItem } from '../../../shared/model/roadmap'; +import { WINDOW_KEY, ZOOM_KEY } from '../lens'; import { dateOf, dayOf } from './days'; /** @@ -71,7 +72,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: Months | null = null +): Column[] { if (zoom?.kind === 'quarter') { const first = firstDay(zoom); return [0, 1, 2].map(i => { @@ -104,15 +110,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 +125,162 @@ 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. Picked in the date range picker; in + * the URL as a preset's key ("ytd"), or as its months, "2025-10..2026-09", + * the last one counted. 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; +} + +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')}`; + +/** 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'], +]; + +/** A window key's months as of `now`; null for none or a key that isn't one. */ +export function resolveWindow(key: string | null | undefined, now: Date): Months | null { + if (!key || !WINDOW_KEY.test(key)) return null; + const m = monthIndex(now); + const q = m - (m % 3); + const jan = m - now.getMonth(); + switch (key) { + case 'quarter': + return { from: q, to: q + 3 }; + case '2q': + return { from: q, to: q + 6 }; + case '6m': + return { from: m, to: m + 6 }; + case 'ytd': + return { from: jan, to: m + 1 }; + case '12m': + return { from: m - 11, to: m + 1 }; + case 'year': + return { from: jan, to: jan + 12 }; + } + const [a, b] = key.split('..').map(t => Number(t.slice(0, 4)) * 12 + Number(t.slice(5)) - 1); + return a <= b ? { from: a, to: b + 1 } : null; +} + +/** A window's key: its first and last months, "2025-10..2026-09". */ +export function windowKey(w: Months): string { + return `${monthText(w.from)}..${monthText(w.to - 1)}`; +} + +/** The months of the first day through the last day picked on a calendar. */ +export function windowOfDays(start: string, end: string): Months { + return { from: monthIndex(dateOf(start)), to: monthIndex(dateOf(end)) + 1 }; +} + +/** 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 is drawn in, from its length: months up to four + * months, and up to a year that doesn't fall on whole quarters (year to + * date, the last 12 months), so it's drawn as picked; quarters past that, or + * when it's whole quarters anyway. + */ +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, out to whole quarters when it's drawn + * in quarters, or the default at `scale`; and its columns' unit. */ +export function drawnWindow( + scale: Scale, + picked: Months | null, + now: Date +): { months: Months; unit: Scale } { + if (!picked) return { months: defaultWindow(scale, now), unit: scale }; + const unit = windowUnit(picked); + return { + months: + unit === 'quarter' + ? { from: picked.from - (picked.from % 3), to: picked.to + ((3 - (picked.to % 3)) % 3) } + : 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; + return { from: w.from + by * length, to: w.to + by * length }; +} + +/** + * 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)}`; +} + +/** 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" */ diff --git a/frontend-v2/src/views/projects/DateRangePicker.tsx b/frontend-v2/src/views/projects/DateRangePicker.tsx index fe326828..ef75de07 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,27 @@ 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, }: { 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 +65,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 ( ( @@ -63,15 +80,15 @@ export function DateRangePicker({ 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 +98,7 @@ export function DateRangePicker({ >
    - {RANGE_PRESETS.map(([key, label]) => ( + {presets.map(([key, name]) => (
  • @@ -101,7 +118,12 @@ export function DateRangePicker({
}> - pick(`${r.start}..${r.end}`)} /> + pick(keyOf(r))} + />
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..a2dba033 100644 --- a/frontend-v2/src/views/projects/RangeCalendar.tsx +++ b/frontend-v2/src/views/projects/RangeCalendar.tsx @@ -49,16 +49,24 @@ 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`, }: { 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,19 +85,25 @@ 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` + ? words(picked) + : future + ? 'Click the first day, then the last' : `Click the first day, then the last (at most ${MAX_RANGE_DAYS} days)`} diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index a66fafa1..34d8c2db 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -67,7 +67,14 @@ 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 { @@ -101,14 +108,26 @@ import { import { columnsFor, commitEnds, + defaultWindow, + doneBefore, + drawnWindow, parseZoom, quarterOf, + resolveWindow, + shiftWindow, + windowHolding, + windowKey, + windowOfDays, + windowWords, + WINDOW_PRESETS, shiftZoom, zoomAround, zoomKey, zoomWords, type Column, + type Months, } from '../../model/roadmapTime'; +import { DateRangePicker } from './DateRangePicker'; import { andList, BEING_WORKED_ON, @@ -974,16 +993,20 @@ function PlanPanel({ {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') && ( @@ -2313,14 +2336,38 @@ export function Roadmap({ const timeline = nav.scale !== 'now'; const scale = nav.scale === 'month' ? 'month' : 'quarter'; const zoom = timeline ? parseZoom(nav.zoom) : null; - const columns = columnsFor(scale, zoom, now); - // how fine the lines and marks are: quarters, months (seven across, or a + // the window picked, or none for the default at this scale; its columns + // follow its length (model/roadmapTime.ts windowUnit) + const chosen = resolveWindow(nav.window, now); + const { months: shownMonths, unit } = drawnWindow(scale, chosen, now); + const columns = columnsFor(scale, zoom, now, chosen); + // how fine the lines and marks are: quarters, months (a window's, or a // zoomed quarter's three), or a zoomed month's weeks const level = - zoom?.kind === 'month' ? 'weeks' : scale === 'quarter' && !zoom ? 'quarters' : 'months'; + zoom?.kind === 'month' ? 'weeks' : unit === 'quarter' && !zoom ? 'quarters' : 'months'; // zooming out goes up one step: a month to its quarter when the roadmap // is in quarters, else back to the whole view - const zoomOut = zoom?.kind === 'month' && scale === 'quarter' ? zoomKey(quarterOf(zoom)) : null; + const zoomOut = zoom?.kind === 'month' && unit === 'quarter' ? zoomKey(quarterOf(zoom)) : null; + // a window in the URL, or none when it's the default's months + const windowNav = (w: Months) => + windowKey(w) === windowKey(defaultWindow(scale, now)) ? null : windowKey(w); + // the window, moved to hold a day only when it doesn't already: where the + // edge chip and zooming out land + const holding = (day: string) => { + const w = windowHolding(shownMonths, day, unit); + return w === shownMonths ? nav.window : windowNav(w); + }; + // ‹ and ›: a zoom's period, else the window, by its own length + const step = (by: number) => + navigate( + zoom + ? { zoom: zoomKey(shiftZoom(zoom, by)) } + : { window: windowNav(shiftWindow(shownMonths, by)) } + ); + const stepWords = (by: number) => + zoom + ? `The ${zoom.kind} ${by < 0 ? 'before' : 'after'}` + : `${by < 0 ? 'Back' : 'On'} ${n(shownMonths.to - shownMonths.from, 'month')}`; const horizon: Horizon = { start: columns[0].start, end: columns[columns.length - 1].end, @@ -2383,9 +2430,12 @@ export function Roadmap({ : days.map(day => ({ left: place(day), label: String(Number(day.slice(8))) })); // the horizon's own history, so a project's real span starts where its // first PR did, not where the date range picker begins + // capped at the server's 400 days, the most recent ones, for a long window + const pastEnd = today < horizon.end ? today : horizon.end; + const earliest = utcDay((dayStart(pastEnd) as number) - (MAX_RANGE_DAYS - 1) * DAY); const pastRange: Range = { - start: horizon.start, - end: today < horizon.end ? today : horizon.end, + start: horizon.start < earliest ? earliest : horizon.start, + end: pastEnd, }; const history = useProjectsData(pastRange)?.window.projects ?? {}; // one slug each: the full set, so a Not tracked plan's row has its project @@ -2513,9 +2563,20 @@ export function Roadmap({ // an origin picked on the load chart keeps only the plans from there; // work with no plan has no origin to match const fromOrigin = (i: RoadmapItem) => !nav.origin || (i.origin ?? 'unsaid') === nav.origin; + // done plans that ended before the window rest in one fold under the + // rows, closed, instead of each taking a row; one open stays in its place + const before = new Set( + doneBefore(listed, horizon.start).flatMap(i => + i === gone || i.id === nav.item ? [] : [i.id] + ) + ); + const shownBefore = showPlans + ? listed.filter(i => before.has(i.id) && found(i) && fromOrigin(i)) + : []; const shownPlans = showPlans ? listed.filter( i => + !before.has(i.id) && found(i) && fromOrigin(i) && (!members || @@ -3047,9 +3108,11 @@ export function Roadmap({ }) } onShow={day => - zoom - ? navigate({ zoom: zoomKey(zoomAround(zoom.kind, dateOf(day))) }) - : open(item.id, 'plan') + navigate( + zoom + ? { zoom: zoomKey(zoomAround(zoom.kind, dateOf(day))) } + : { window: holding(day) } + ) } dragHandlers={ranked ? handlersFor(item, visibleIds) : NO_DRAG} dropHere={ranked && dropTarget === item.id && dragging !== item.id} @@ -3258,6 +3321,31 @@ export function Roadmap({
); + // ----- Done before the window: a ledger at rest, after every grouping ----- + const doneFold = shownBefore.length > 0 && ( +
+ : undefined} + > + {planRows(shownBefore, shownBefore, { ranked: false })} + +
+ ); + const body = lanes ? ( [...laneTitles, null].map(team => { const inLane = (t: string | null | undefined) => (t ?? null) === team; @@ -3528,48 +3616,98 @@ export function Roadmap({ ['quarter', 'Quarters'], ['now', 'Now, next, later'], ]} - onChange={next => navigate({ scale: next, zoom: null })} + onChange={next => + // Months and Quarters are the two default windows; a + // window picked is kept for coming back from now, next, later + navigate( + next === 'now' + ? { scale: next, zoom: null } + : { scale: next, zoom: null, window: null } + ) + } /> - {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 })} + presets={WINDOW_PRESETS} + label="Pick the months the roadmap shows" + words={() => windowWords(shownMonths, unit)} + future + keyOf={r => windowKey(windowOfDays(r.start, r.end))} + pickedWords={r => { + const d = drawnWindow(scale, windowOfDays(r.start, r.end), now); + return `${windowWords(d.months, d.unit)}, in whole ${d.unit}s`; + }} + /> + )} - {(today < horizon.start || today >= horizon.end) && ( + {(zoom ? 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 @@ -3729,6 +3867,7 @@ export function Roadmap({ ) : (
{body} + {doneFold} {notTrackedLane}
)} diff --git a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx index 26f2f95c..fe3dd5fe 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.test.tsx @@ -192,6 +192,7 @@ const NAV = { show: 'all', week: null, zoom: null, + window: null, team: null, origin: null, by: 'team', diff --git a/frontend-v2/src/views/projects/parts.tsx b/frontend-v2/src/views/projects/parts.tsx index 8acdaae2..33498d6c 100644 --- a/frontend-v2/src/views/projects/parts.tsx +++ b/frontend-v2/src/views/projects/parts.tsx @@ -50,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 From f2624d5e3adef0631cb6a9771aefc33b937808ce Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 13 Oct 2026 17:22:19 -0700 Subject: [PATCH 133/148] Show a year of plans on the fake board The window picker, the steps back and the "Done before" fold only show anything with plans older than the default window, and the dummy board's oldest plan started 11 weeks ago. Fourteen plans from 18 to 6 months back, done and some dropped, make every standing visible on the bench. Also from the bench: the switch shows the columns a window is drawn in, the header drops the Mondays' dates past seven month columns, a picked preset's months show only on the widest screens so the toolbar keeps to one line at 1440, the initiative lanes (which hold only work under way) have no done fold, the load chart's "Next 4 weeks" words step aside when today sits at the window's right edge, and DESIGN.md and the timeline's help say how the window works ("one range per view"). Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 39 +++++++++++++++---- frontend-v2/src/backend/dummyProjects.ts | 28 +++++++++++++ .../src/views/projects/DateRangePicker.tsx | 5 ++- frontend-v2/src/views/projects/LoadChart.tsx | 4 +- frontend-v2/src/views/projects/Roadmap.tsx | 24 +++++++++--- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 38e6620f..4b7bf86c 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 @@ -554,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 @@ -669,9 +674,27 @@ 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), which are + the two defaults the Months and Quarters switch picks. Columns follow the + window's length, so nothing needs a legend: months up to four months, and + up to a year that doesn't fall on whole quarters (Year to date, Last 12 + months, drawn as picked); whole quarters past that, rounded out. The + switch shows which. Past seven month columns the Mondays' dates drop out + of the header; the week lines stay. ‹ and › step by the window's own + length on every view, Today puts back the default, and a plan's edge chip + moves the window to start at its plan. 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. - **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, @@ -680,7 +703,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 diff --git a/frontend-v2/src/backend/dummyProjects.ts b/frontend-v2/src/backend/dummyProjects.ts index d70e2d56..91333fe2 100644 --- a/frontend-v2/src/backend/dummyProjects.ts +++ b/frontend-v2/src/backend/dummyProjects.ts @@ -387,6 +387,34 @@ export const { DUMMY_ROADMAP, DUMMY_ROADMAP_UPDATES } = (() => { 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 diff --git a/frontend-v2/src/views/projects/DateRangePicker.tsx b/frontend-v2/src/views/projects/DateRangePicker.tsx index ef75de07..be623019 100644 --- a/frontend-v2/src/views/projects/DateRangePicker.tsx +++ b/frontend-v2/src/views/projects/DateRangePicker.tsx @@ -24,7 +24,10 @@ export function DateRangePicker({ future = false, keyOf = r => `${r.start}..${r.end}`, pickedWords, + wordsClass = 'hidden sm:inline', }: { + /** where a preset's range shows beside its name */ + wordsClass?: string; rangeKey: string; range: Range; onChange: (key: string) => void; @@ -85,7 +88,7 @@ export function DateRangePicker({ ) : ( <> {preset} - + {/* the comma is for the ear: "Last 30 days, Sep 2 to Oct 1" */} , {words(range)} diff --git a/frontend-v2/src/views/projects/LoadChart.tsx b/frontend-v2/src/views/projects/LoadChart.tsx index 6ac9a370..0249a42f 100644 --- a/frontend-v2/src/views/projects/LoadChart.tsx +++ b/frontend-v2/src/views/projects/LoadChart.tsx @@ -384,8 +384,8 @@ export function LoadChart({ /> )} {/* 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 <= 75) && ( week >= horizon.start) + ? // past seven months, the Mondays' dates crowd: the week lines say them + weeks + .filter(week => week >= horizon.start && columns.length <= 7) .map(week => ({ left: place(week), label: String(Number(week.slice(8))) })) : days.map(day => ({ left: place(day), label: String(Number(day.slice(8))) })); // the horizon's own history, so a project's real span starts where its @@ -3322,7 +3323,9 @@ export function Roadmap({ ); // ----- Done before the window: a ledger at rest, after every grouping ----- - const doneFold = shownBefore.length > 0 && ( + // the initiative lanes hold only work under way, so they have no fold + // of it either + const doneFold = !byInitiative && shownBefore.length > 0 && (
: undefined} > {planRows(shownBefore, shownBefore, { ranked: false })} @@ -3574,6 +3576,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 @@ -3610,7 +3618,8 @@ export function Roadmap({

navigate({ window: key })} 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" words={() => windowWords(shownMonths, unit)} future @@ -3742,7 +3754,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 From 2716e082618236c7497e8f11b0944e3837f8b778 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 13 Oct 2026 17:48:14 -0700 Subject: [PATCH 134/148] Fix the roadmap window's review findings A blind review found the "Done before" fold hid a done plan Decide asks to reopen (and the initiative lanes lost it), a picked week left the fold full of plans the chart didn't count, the 8% sliver rule ate February and every name on long windows, the Months and Quarters switch threw a picked window away, and a window wholly ahead asked the server for an inverted range. Now a plan Decide asks about is never folded and the fold follows the picked week. Month and quarter names shorten, turn to letters or thin out by measured width, every column keeping its zoom button. The switch redraws the same window in the other unit, does nothing on the lit option, and Now, next, later keeps the window. A window ahead asks for no history. Windows stop at five years, a longer link is cut and the URL rewritten. Presets keep their own unit whatever the month, the URL says a custom span's unit ("2025-Q4..2026-Q3"), Today hides on a preset that holds today, the picker is named "Roadmap window: ...", and the developer line's name moves to the end with lower bars. Render tests now cover the toolbar, the edge chip and the fold. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 41 ++- frontend-v2/src/app.tsx | 5 +- frontend-v2/src/lens.ts | 40 ++- frontend-v2/src/model/roadmapTime.test.ts | 182 ++++++++--- frontend-v2/src/model/roadmapTime.ts | 177 ++++++++--- .../src/views/projects/DateRangePicker.tsx | 8 + frontend-v2/src/views/projects/LoadChart.tsx | 28 +- .../src/views/projects/RangeCalendar.tsx | 11 +- frontend-v2/src/views/projects/Roadmap.tsx | 128 +++++--- .../src/views/projects/RoadmapWindow.test.tsx | 285 ++++++++++++++++++ 10 files changed, 748 insertions(+), 157 deletions(-) create mode 100644 frontend-v2/src/views/projects/RoadmapWindow.test.tsx diff --git a/DESIGN.md b/DESIGN.md index 4b7bf86c..43471587 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -677,24 +677,37 @@ icon with a class bolted on. 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), which are - the two defaults the Months and Quarters switch picks. Columns follow the - window's length, so nothing needs a legend: months up to four months, and - up to a year that doesn't fall on whole quarters (Year to date, Last 12 - months, drawn as picked); whole quarters past that, rounded out. The - switch shows which. Past seven month columns the Mondays' dates drop out - of the header; the week lines stay. ‹ and › step by the window's own - length on every view, Today puts back the default, and a plan's edge chip - moves the window to start at its plan. 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 + 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. + 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, diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 8b211323..bf1f70c0 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -18,7 +18,7 @@ import { DEFAULT_SORT, LENS_LABELS, ORIGIN_KEYS, - WINDOW_KEY, + windowParam, ZOOM_KEY, type Lens, } from './lens'; @@ -174,7 +174,8 @@ function readHash(): HashState { : 'all', week: /^\d{4}-\d{2}-\d{2}$/.test(p.get('week') ?? '') ? p.get('week') : null, zoom: ZOOM_KEY.test(p.get('zoom') ?? '') ? p.get('zoom') : null, - window: WINDOW_KEY.test(p.get('window') ?? '') ? p.get('window') : 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: diff --git a/frontend-v2/src/lens.ts b/frontend-v2/src/lens.ts index 2eb39b5f..3b7f6027 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -20,11 +20,41 @@ export const DEFAULT_GROUP = 'initiative'; * the same reason (model/roadmapTime.ts reads it). */ export const ZOOM_KEY = /^(\d{4})-(?:Q([1-4])|(0[1-9]|1[0-2]))$/; -/** The roadmap's window in the URL, checked here for the same reason - * (model/roadmapTime.ts reads it): a preset's key, or whole months, - * "2025-10..2026-09", the last one counted. */ -export const WINDOW_KEY = - /^(?:quarter|2q|6m|ytd|12m|year|\d{4}-(?:0[1-9]|1[0-2])\.\.\d{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 = /^(\d{4})-(0[1-9]|1[0-2])\.\.(\d{4})-(0[1-9]|1[0-2])$/; +const QUARTERS_KEY = /^(\d{4})-Q([1-4])\.\.(\d{4})-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 diff --git a/frontend-v2/src/model/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts index 059a59a8..7152bf84 100644 --- a/frontend-v2/src/model/roadmapTime.test.ts +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -1,21 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { WINDOW_KEY } from '../lens'; +import { MAX_WINDOW_MONTHS, windowParam } from '../lens'; import { + columnNames, columnsFor, commitEnds, defaultWindow, doneBefore, drawnWindow, + inUnit, + isPreset, parseZoom, resolveWindow, shiftWindow, windowHolding, windowKey, windowOfDays, - windowUnit, windowWords, WINDOW_PRESETS, - type Months, + type Picked, quarterOf, shiftZoom, zoomAround, @@ -131,33 +133,70 @@ describe('commitEnds', () => { describe('the roadmap window', () => { // Oct 13, 2026 const now = new Date(2026, 9, 13); - const key = (k: string) => windowKey(resolveWindow(k, now) as Months); + 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, year to date through this month', () => { + 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-10..2027-03'], + ['2q', '2026-Q4..2027-Q1'], ['6m', '2026-10..2027-03'], ['ytd', '2026-01..2026-10'], ['12m', '2025-11..2026-10'], - ['year', '2026-01..2026-12'], + ['year', '2026-Q1..2026-Q4'], ]); // in February, year to date is two months - expect(windowKey(resolveWindow('ytd', new Date(2027, 1, 3)) as Months)).toBe( + expect(windowKey(resolveWindow('ytd', new Date(2027, 1, 3)) as Picked, 'month')).toBe( '2027-01..2027-02' ); }); - it('reads its own keys back, future ones too, and refuses anything else', () => { - for (const k of ['2025-10..2026-09', '2027-01..2027-01', '2026-11..2028-04']) { + 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); } - // what the URL accepts (lens.ts) is what the picker writes - for (const [k] of WINDOW_PRESETS) expect(WINDOW_KEY.test(k)).toBe(true); - expect(WINDOW_KEY.test(key('ytd'))).toBe(true); - for (const bad of ['2026-10..2026-09', '2026-13..2027-01', '2026-10', 'all', '', null]) { + 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('1900-01..2100-12')).toBe('1900-01..1904-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('1900-01..2100-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', () => { @@ -168,25 +207,36 @@ describe('the roadmap window', () => { unit: scale, }); } - expect(windowKey(defaultWindow('quarter', now))).toBe('2026-07..2027-06'); - expect(windowKey(defaultWindow('month', now))).toBe('2026-09..2027-03'); + expect(windowKey(defaultWindow('quarter', now), 'quarter')).toBe('2026-Q3..2027-Q2'); + expect(windowKey(defaultWindow('month', now), 'month')).toBe('2026-09..2027-03'); }); - it('draws months up to four, or a year not on quarters; quarters past that', () => { - const unit = (k: string) => windowUnit(resolveWindow(k, now) as Months); - expect(unit('quarter')).toBe('month'); - expect(unit('ytd')).toBe('month'); - expect(unit('12m')).toBe('month'); - expect(unit('2q')).toBe('quarter'); - expect(unit('year')).toBe('quarter'); - expect(unit('2025-11..2026-02')).toBe('month'); + 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 = drawnWindow('quarter', resolveWindow('2025-11..2027-01', now), now); - expect([windowKey(long.months), long.unit]).toEqual(['2025-10..2027-03', 'quarter']); + 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, resolveWindow('ytd', now)); + const ytd = columnsFor('quarter', null, now, picked('ytd')); expect(ytd.map(c => c.zoom)).toEqual([ '2026-01', '2026-02', @@ -200,39 +250,40 @@ describe('the roadmap window', () => { '2026-10', ]); expect(ytd.at(-1)?.end).toBe('2026-11-01'); - const back = columnsFor('quarter', null, now, resolveWindow('2025-10..2026-09', now)); + 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, resolveWindow('ytd', now))).toEqual( + expect(columnsFor('quarter', parseZoom('2026-Q4'), now, picked('ytd'))).toEqual( columnsFor('quarter', parseZoom('2026-Q4'), now) ); }); it('steps by its own length', () => { - const ytd = resolveWindow('ytd', now) as Months; - expect(windowKey(shiftWindow(ytd, -1))).toBe('2025-03..2025-12'); - expect(windowKey(shiftWindow(defaultWindow('quarter', now), -1))).toBe('2025-07..2026-06'); - expect(windowKey(shiftWindow(defaultWindow('month', now), 1))).toBe('2027-04..2027-10'); + 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'))).toBe('2025-10..2026-09'); - expect(windowKey(windowHolding(w, '2027-09-06', 'month'))).toBe('2027-09..2028-08'); - }); - - it('takes the months of the days picked on a calendar', () => { - expect(windowKey(windowOfDays('2027-02-14', '2027-05-02'))).toBe('2027-02..2027-05'); + 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', resolveWindow(k, now), now); + const d = drawnWindow('quarter', picked(k), now); return windowWords(d.months, d.unit); }; expect(words('ytd')).toBe('Jan to Oct 2026'); - expect(words('2025-10..2026-09')).toBe('Q4 2025 to Q3 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'); @@ -258,3 +309,52 @@ describe('doneBefore', () => { ).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); + expect(short.every(t => t && t.length === 3)).toBe(true); + 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('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']); + }); +}); diff --git a/frontend-v2/src/model/roadmapTime.ts b/frontend-v2/src/model/roadmapTime.ts index 055c518f..a4543404 100644 --- a/frontend-v2/src/model/roadmapTime.ts +++ b/frontend-v2/src/model/roadmapTime.ts @@ -1,5 +1,5 @@ import { endOf, type RoadmapItem } from '../../../shared/model/roadmap'; -import { WINDOW_KEY, ZOOM_KEY } from '../lens'; +import { MAX_WINDOW_MONTHS, windowParam, ZOOM_KEY } from '../lens'; import { dateOf, dayOf } from './days'; /** @@ -76,7 +76,7 @@ export function columnsFor( scale: Scale, zoom: Zoom | null, now: Date, - picked: Months | null = null + picked: Picked | null = null ): Column[] { if (zoom?.kind === 'quarter') { const first = firstDay(zoom); @@ -141,19 +141,25 @@ 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. Picked in the date range picker; in - * the URL as a preset's key ("ytd"), or as its months, "2025-10..2026-09", - * the last one counted. None picked is the default: last quarter and the - * next three, or at Months, last month and the next six. + * 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][] = [ @@ -165,38 +171,73 @@ export const WINDOW_PRESETS: [string, string][] = [ ['year', 'This year'], ]; -/** A window key's months as of `now`; null for none or a key that isn't one. */ -export function resolveWindow(key: string | null | undefined, now: Date): Months | null { - if (!key || !WINDOW_KEY.test(key)) return null; +// 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(); - switch (key) { - case 'quarter': - return { from: q, to: q + 3 }; - case '2q': - return { from: q, to: q + 6 }; - case '6m': - return { from: m, to: m + 6 }; - case 'ytd': - return { from: jan, to: m + 1 }; - case '12m': - return { from: m - 11, to: m + 1 }; - case 'year': - return { from: jan, to: jan + 12 }; - } - const [a, b] = key.split('..').map(t => Number(t.slice(0, 4)) * 12 + Number(t.slice(5)) - 1); - return a <= b ? { from: a, to: b + 1 } : null; + 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: its first and last months, "2025-10..2026-09". */ -export function windowKey(w: Months): string { - return `${monthText(w.from)}..${monthText(w.to - 1)}`; +/** 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 months of the first day through the last day picked on a calendar. */ -export function windowOfDays(start: string, end: string): Months { - return { from: monthIndex(dateOf(start)), to: monthIndex(dateOf(end)) + 1 }; +/** 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. */ @@ -207,10 +248,10 @@ export function defaultWindow(scale: Scale, now: Date): Months { } /** - * The columns a window is drawn in, from its length: months up to four - * months, and up to a year that doesn't fall on whole quarters (year to - * date, the last 12 months), so it's drawn as picked; quarters past that, or - * when it's whole quarters anyway. + * 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; @@ -218,22 +259,14 @@ export function windowUnit(w: Months): Scale { return length <= 4 || (length <= 12 && !quarters) ? 'month' : 'quarter'; } -/** The window drawn: the one picked, out to whole quarters when it's drawn - * in quarters, or the default at `scale`; and its columns' unit. */ +/** The window drawn: the one picked, in its unit, or the default at `scale`. */ export function drawnWindow( scale: Scale, - picked: Months | null, + picked: Picked | null, now: Date ): { months: Months; unit: Scale } { if (!picked) return { months: defaultWindow(scale, now), unit: scale }; - const unit = windowUnit(picked); - return { - months: - unit === 'quarter' - ? { from: picked.from - (picked.from % 3), to: picked.to + ((3 - (picked.to % 3)) % 3) } - : picked, - unit, - }; + return { months: { from: picked.from, to: picked.to }, unit: picked.unit }; } /** The window `by` of its own lengths later (earlier when negative). */ @@ -269,6 +302,58 @@ export function windowWords(w: Months, unit: Scale): string { 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 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, (_, 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>( diff --git a/frontend-v2/src/views/projects/DateRangePicker.tsx b/frontend-v2/src/views/projects/DateRangePicker.tsx index be623019..3abbb578 100644 --- a/frontend-v2/src/views/projects/DateRangePicker.tsx +++ b/frontend-v2/src/views/projects/DateRangePicker.tsx @@ -25,7 +25,13 @@ export function DateRangePicker({ 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; @@ -80,6 +86,7 @@ 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" > @@ -124,6 +131,7 @@ export function DateRangePicker({ pick(keyOf(r))} /> diff --git a/frontend-v2/src/views/projects/LoadChart.tsx b/frontend-v2/src/views/projects/LoadChart.tsx index 0249a42f..02faf1ae 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 }) { @@ -212,6 +219,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 ( @@ -364,8 +384,10 @@ export function LoadChart({ it; the way to the teams on People */} `${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 */ @@ -99,13 +102,7 @@ export default function RangeCalendar({ className="pd-calendar text-[13px] text-ink" />
- - {picked - ? words(picked) - : future - ? 'Click the first day, then the last' - : `Click the first day, then the last (at most ${MAX_RANGE_DAYS} days)`} - + {picked ? words(picked) : hint} void; }) { + // the axis's width, so month and quarter names shorten or thin out + // before they touch + const ref = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const el = ref.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const watch = new ResizeObserver(() => setWidth(el.clientWidth)); + watch.observe(el); + return () => watch.disconnect(); + }, []); + const share = (c: Column) => axis.at(c.end) - axis.at(c.start); + const names = columnNames( + columns, + columns.map(c => (share(c) / 100) * width) + ); return (
Project - + {columns.map((c, i) => { const style = { left: `${axis.at(c.start)}%` }; const zoom = c.zoom; - // a sliver of a week at a month's edge keeps its line, not a name - if (axis.at(c.end) - axis.at(c.start) < 8) { + // a sliver of a week at a month's edge keeps its line, not a + // name; months and quarters always keep their button + if (!zoom && share(c) < 8) { return ( ); } - // four quarters across a phone fit only without the year, so - // it's said where it starts: the first column and each Q1 - const short = /^Q[2-4] \d{4}$/.test(c.label) && i > 0 ? c.label.slice(0, 2) : null; - const label = short ? ( - <> - {short} - {c.label} - - ) : ( - c.label - ); + // short, a letter or none when the full names would touch + // (columnNames); its button fills the column either way + const label = zoom ? names[i] ?? '' : c.label; return zoom ? ( - {(zoom ? today < horizon.start || today >= horizon.end : !!nav.window) && ( + {(zoom || isPreset(nav.window) + ? today < horizon.start || today >= horizon.end + : !!nav.window) && ( 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..5b81dabc --- /dev/null +++ b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx @@ -0,0 +1,285 @@ +// @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 }), +]; +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 }], + }, +]; + +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) => { + asked.push(path); + 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' }); + }); +}); From 30b9afd5989b4ef94d25641468bfdc5f0a30dc79 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 14:51:06 -0700 Subject: [PATCH 135/148] Require sign-in to read initiatives GET /initiatives was added in app.js but never registered behind the sign-in gate in lib/authentication.js, so anyone could read every initiative's name, goal, owner and filed projects. Writes were already behind canWrite. The test reads app.js's GET routes and fails if any isn't gated, so the next new read can't ship open the same way. Co-Authored-By: Claude Opus 5.5 --- lib/authentication.js | 1 + test/auth-routes.test.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 test/auth-routes.test.js 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/test/auth-routes.test.js b/test/auth-routes.test.js new file mode 100644 index 00000000..2376098f --- /dev/null +++ b/test/auth-routes.test.js @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import authManager from '../lib/authentication.js'; + +// Every board read in app.js must sit behind the sign-in gate that +// setupRoutes registers ahead of it. /initiatives once shipped without it and +// answered anonymous requests with every initiative's name, goal and owner. +test('every GET route app.js adds requires sign-in', () => { + 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)), + [] + ); +}); From 0c1ec321811838d2bcc530f616aba8e5b5d6ed24 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:18:07 -0700 Subject: [PATCH 136/148] Keep a filing through a case-only label rename initiative_projects.project compares without case, so after the UPDATE renamed 'Docs' to 'docs' the DELETE of 'Docs' matched the renamed row and dropped the project's filing. The delete now compares BINARY. Co-Authored-By: Claude Opus 5.5 --- lib/work.js | 4 +++- test/hooks-label-rename.test.js | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/work.js b/lib/work.js index 90bb795a..ba190e13 100644 --- a/lib/work.js +++ b/lib/work.js @@ -457,7 +457,9 @@ export async function renameProject(settings, from, to) { to, from, ]); - await db.query('DELETE FROM `initiative_projects` WHERE `project` = ?', [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)) { diff --git a/test/hooks-label-rename.test.js b/test/hooks-label-rename.test.js index 97c4b7d2..df2f9699 100644 --- a/test/hooks-label-rename.test.js +++ b/test/hooks-label-rename.test.js @@ -130,3 +130,25 @@ test('a project another repo still labels the old way keeps its filing too', asy 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 }]); +}); From fa610d303be343b75cb8245ea074a9450ab5e1f6 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:18:08 -0700 Subject: [PATCH 137/148] Make initiative filings honest and race-free A slug sent in another case is now stored and answered as its label spells it, and expect compares without case, so the board finds its home. Unsorted is refused for a project Not tracked by its repos, since deleting the row left it Not tracked while answering home: null; an undo (undo: true, now sent by the board's three Undos) may still put a repo default back. Undo also sends expect, so it can't overwrite a later filing. The growth check counts no drafts or bots on either side, baseline_prs is capped, and filing, closing and deleting run one at a time so a close or delete can't pass its check while a filing lands. Co-Authored-By: Claude Opus 5.5 --- controllers/api-routes.js | 13 +- controllers/initiatives.js | 49 ++++-- frontend-v2/src/model/initiativesData.ts | 14 +- frontend-v2/src/views/projects/Decide.tsx | 13 +- .../src/views/projects/HomeCalls.test.tsx | 7 + .../src/views/projects/Initiatives.tsx | 3 + .../src/views/projects/RoadmapLanes.tsx | 2 + lib/initiatives.js | 81 ++++++--- test/initiatives.test.js | 155 ++++++++++++++++-- test/projects.test.js | 6 +- 10 files changed, 285 insertions(+), 58 deletions(-) diff --git a/controllers/api-routes.js b/controllers/api-routes.js index a673b69b..ab6cfd93 100644 --- a/controllers/api-routes.js +++ b/controllers/api-routes.js @@ -67,7 +67,8 @@ export const API_ROUTES = [ '?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. Each project says its `initiative` (slug, or null for ' + - 'Unsorted), whether it is `tracked`, and whether it `grew` while not tracked', + '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', @@ -262,10 +263,12 @@ export const API_ROUTES = [ path: '/api/v1/initiatives/file', handlers: [canWrite, initiativesController.file], does: - 'File a project: {project, initiative: or null for none, expect?, baseline_prs?, set_at?}. `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 `set_at` is sent (an undo). Answers `before` and `home` (initiative, ' + - 'set_by, set_at, baseline_prs); undo by sending `before` back with its set_at and baseline_prs. ' + + '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', }, diff --git a/controllers/initiatives.js b/controllers/initiatives.js index ac11d38d..34305745 100644 --- a/controllers/initiatives.js +++ b/controllers/initiatives.js @@ -60,6 +60,17 @@ async function liveProjects(initiative) { ); } +// Writes that check and then change initiatives run one at a time, so a +// close can't pass its check while a filing lands, nor a delete while one +// files into it. ponytail: one process serves the board and the API; a +// lock in MySQL if Pulldasher ever runs more than one +let writing = Promise.resolve(); +function serially(run) { + const done = writing.then(run, run); + writing = done.catch(() => {}); + return done; +} + /** A failed write's 500, logged. */ function failed(res, what) { return err => { @@ -127,7 +138,7 @@ export default { const { restate, undo, ...body } = req.body ?? {}; const checked = checkInitiativeFields(body, { partial: true }); if (checked.error) return res.status(400).json({ error: checked.error }); - (async () => { + 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'; @@ -150,7 +161,7 @@ export default { }); if (!changed) return res.status(404).json({ error: 'no such initiative' }); res.json({ initiative: (await sent([changed]))[0] }); - })().catch(failed(res, 'update')); + }).catch(failed(res, 'update')); }, /** DELETE /initiatives/:id -- only one with no projects in it (the undo @@ -158,7 +169,7 @@ export default { remove: function (req, res) { const id = idOf(req); if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); - deleteInitiative(id) + serially(() => deleteInitiative(id)) .then(result => { if (result.missing) return res.status(404).json({ error: 'no such initiative' }); if (result.notEmpty) { @@ -197,7 +208,9 @@ export default { * 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 (`set_at`) putting a filing back. + * 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 ?? {}; @@ -211,7 +224,8 @@ export default { .status(400) .json({ error: 'send initiative (and expect) as an initiative’s slug, or null for none' }); } - const count = v => v == null || (Number.isInteger(v) && v >= 0); + // 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)) { @@ -220,13 +234,17 @@ export default { }); } const settings = projectSettings(); - fileProject(project, initiative, req.roadmapLogin, { - prefix: settings?.prefix ?? DEFAULT_PROJECT_PREFIX, - expect, - baseline_prs: body.baseline_prs ?? null, - set_at: body.set_at ?? null, - now, - }) + 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) { @@ -234,12 +252,17 @@ export default { .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, before: result.before, home: result.home }); + res.json({ project: result.project, before: result.before, home: result.home }); }) .catch(failed(res, 'filing')); }, diff --git a/frontend-v2/src/model/initiativesData.ts b/frontend-v2/src/model/initiativesData.ts index 0166938b..c032e112 100644 --- a/frontend-v2/src/model/initiativesData.ts +++ b/frontend-v2/src/model/initiativesData.ts @@ -41,6 +41,7 @@ interface FileBody { expect?: string | null; baseline_prs?: number; set_at?: number; + undo?: true; } interface Api { @@ -102,7 +103,7 @@ function dummyApi(): Api { const now = () => Math.floor(Date.now() / 1000); return { list: () => ok({ initiatives: copy() }), - file: ({ project, initiative, expect, baseline_prs, set_at }) => { + file: ({ project, initiative, expect, baseline_prs, set_at, undo }) => { const homes = homesOf(dummy.initiatives); const current = homes.get(project); const was: Home = current @@ -119,7 +120,7 @@ function dummyApi(): Api { 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) { + 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) { @@ -262,7 +263,13 @@ export async function fileProject( expect, set_at, baseline_prs, - }: { expect?: string | null; set_at?: number | null; baseline_prs?: number } = {} + undo, + }: { + expect?: string | null; + set_at?: number | null; + baseline_prs?: number; + undo?: boolean; + } = {} ): Promise<{ before: Home } | { error: string }> { const takeBack = optimistic(list => withFiling(list, project, to, { @@ -278,6 +285,7 @@ export async function fileProject( ...(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) { diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 9505fec6..87f60067 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -393,7 +393,7 @@ function afterRow( // What Undo takes back interface Landed { id: number | null; - home?: { project: string; before: Home }; + home?: { project: string; to: string | null; before: Home }; /** the plan a link gave a project */ linked?: number; initiative?: { @@ -425,7 +425,7 @@ async function homeWrite( const r = await fileProject(slug, call.to, { expect: homeSlugOf(slug) }); return 'error' in r ? { why: whyNot(r.error) } - : { home: { project: slug, before: r.before } }; + : { home: { project: slug, to: call.to, before: r.before } }; } const id = askedInitiative(row); if (id == null) return { why: '' }; @@ -495,11 +495,11 @@ function makeCall( // 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, before: filedBefore }; + 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, before: r.before }; + else done.home = { project: row.slug, to: filed.to, before: r.before }; } } if (why != null) { @@ -586,10 +586,13 @@ function undoCall(key: string, batch?: { after: Promise }): Promise { 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, }); }); }); @@ -359,8 +362,10 @@ describe('a Not tracked project that grew', () => { 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, }); }); @@ -406,8 +411,10 @@ describe('a Not tracked project that grew', () => { 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, }); }); }); diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index 5a6de2ae..a57778de 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -112,9 +112,12 @@ export function HomeLine({ // 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) diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 4d5cf074..6d08816f 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -359,8 +359,10 @@ export function initiativeLanes(c: LaneContext) { 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 diff --git a/lib/initiatives.js b/lib/initiatives.js index 7e0fb80c..7b035069 100644 --- a/lib/initiatives.js +++ b/lib/initiatives.js @@ -1,4 +1,5 @@ import db from './db.js'; +import { isBot } from './review-model.js'; import { grew, homesOf, moveBefore, MISC_SLUG } from '../shared/dist/index.js'; /** @@ -176,15 +177,27 @@ async function homeOf(project) { /** * 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 [row] = await db.query( - 'SELECT COUNT(DISTINCT p.`repo`, p.`number`) AS `n` FROM `pull_labels` l ' + + 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` > ?', + 'WHERE l.`title` = ? AND p.`date` > ? AND p.`draft` = 0', [prefix + project, since] ); - return Number(row?.n ?? 0); + 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; } /** @@ -200,38 +213,59 @@ export async function prCount(prefix, project, since = 0) { * `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. * - * Resolves `{ before, home }`, the homes before and after, for the undo log. + * `{ 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( - project, + sent, to, login, - { prefix, expect, baseline_prs = null, set_at = null, now = Math.floor(Date.now() / 1000) } + { + 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; - if (expect !== undefined && (current?.initiative ?? null) !== expect) { - return { conflict: true, home: home(current) }; + // 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 { before, home: null }; + return { project, before, home: null }; } const [target] = await db.query( - 'SELECT `id`, `kind`, `status` FROM `initiatives` WHERE `slug` = ?', + '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 (set_at) may put a filing back there. - // ponytail: any set_at counts as an undo; writers are trusted (canWrite) - // and the board and the skill send it only on Undo. An explicit - // `undo: true` if that changes - if (target.status !== 'open' && set_at == null) return { closed: true }; + // 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 = @@ -247,7 +281,11 @@ export async function fileProject( baseline_prs: baseline, }; await db.query('REPLACE INTO `initiative_projects` SET ?', [row]); - return { before, home: { initiative: to, set_by: login, set_at: row.set_at, baseline_prs: baseline } }; + return { + project, + before, + home: { initiative: target.slug, set_by: login, set_at: row.set_at, baseline_prs: baseline }, + }; } /** @@ -300,8 +338,9 @@ export async function grownSlugs(settings, initiatives, untracked) { if (!untracked.size) return out; const homes = homesOf(initiatives); const rows = await db.query( - 'SELECT l.`title`, p.`date` FROM `pull_labels` l ' + - 'JOIN `pulls` p ON p.`repo` = l.`repo` AND p.`number` = l.`number` WHERE l.`title` IN (?)', + '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 => { @@ -309,7 +348,9 @@ export async function grownSlugs(settings, initiatives, untracked) { return home?.initiative.kind === 'untracked' ? home.filing : null; }; const sinceOf = new Map(); - for (const { title, date } of rows) { + // 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); } diff --git a/test/initiatives.test.js b/test/initiatives.test.js index 6b8cc5b3..8a38c6f1 100644 --- a/test/initiatives.test.js +++ b/test/initiatives.test.js @@ -7,7 +7,7 @@ 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 { trackedOnly } from '../lib/initiatives.js'; +import { fileProject, grownSlugs, prCount, trackedOnly } from '../lib/initiatives.js'; import initiativesController from '../controllers/initiatives.js'; import { canWrite } from '../controllers/roadmap.js'; import { API_ROUTES, PROJECT_WRITES } from '../controllers/api-routes.js'; @@ -19,10 +19,13 @@ import { API_ROUTES, PROJECT_WRITES } from '../controllers/api-routes.js'; let initiatives = []; let filings = []; let plans = []; -// labeled PRs: { title, date } +// 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 = []) { @@ -67,25 +70,44 @@ function fakeQuery(sql, params = []) { } if (sql.startsWith('SELECT f.`project`')) { return filings - .filter(f => f.project === params[0]) + .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 COUNT(DISTINCT')) { + if (sql.startsWith('SELECT DISTINCT p.`repo`, p.`number`, p.`owner`')) { const [title, since] = params; - return [{ n: prs.filter(p => p.title === title && p.date > since).length }]; + 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 `id`, `kind`, `status` FROM `initiatives`')) { - return initiatives.filter(i => i.slug === params[0]); + 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 => f.project !== params[0]); + filings = filings.filter(f => !same(f.project, params[0])); return { affectedRows: 1 }; } if (sql.startsWith('REPLACE INTO `initiative_projects` SET ?')) { - filings = [...filings.filter(f => f.project !== params[0].project), { ...params[0] }]; + 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 @@ -110,10 +132,29 @@ function makeApp() { return app; } +// holds one statement (`at`) until another (`release`) is sent or 200 ms +// pass, to make two writes interleave +let hold = null; +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) => fakeQuery(sql, params)); + mock.method(db, 'query', async (sql, params) => { + if (hold && sql.startsWith(hold.release)) hold.open(); + if (hold && sql.startsWith(hold.at)) { + hold.reached(); + await Promise.race([hold.until, new Promise(r => setTimeout(r, 200))]); + } + return fakeQuery(sql, params); + }); await new Promise(resolve => { server = makeApp().listen(0, () => { base = `http://127.0.0.1:${server.address().port}`; @@ -131,6 +172,7 @@ beforeEach(() => { plans = []; prs = []; nextId = 1; + hold = null; }); async function call(method, path, body) { @@ -483,3 +525,96 @@ test('every API write is one open boards hear about, initiatives included', () = 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'); +}); diff --git a/test/projects.test.js b/test/projects.test.js index ea9ff078..8889558c 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -449,8 +449,10 @@ before(() => { } if (sql.includes('FROM `initiative_projects`')) return filingRows; if (sql.startsWith('SELECT l.`title`, p.`repo`')) return labelRepos; - if (sql.startsWith('SELECT l.`title`, p.`date`')) { - return labelDates.filter(r => params[0].includes(r.title)); + 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 []; From ad6d2c3495503fb3f0ca0adcaa18c5d7b9d0c4ff Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:22:35 -0700 Subject: [PATCH 138/148] Count a draft from the day it's marked ready Pulldasher kept only the current draft flag, so a draft marked ready counted from the day it opened: past windows' throughput and backlog moved afterwards, time spent took in its draft weeks, and Decide placed it by its open day. Migration 0040 adds pulls.ready_at, which the upsert stamps when a stored draft is saved as ready and clears when it goes back to draft. Spans, time spent and the work model read it. Rows stored before it stay NULL, so a PR readied before the migration still counts from its open day: there's no history to backfill from. Co-Authored-By: Claude Opus 5.5 --- lib/projects.js | 19 ++++++--- lib/schema-check.js | 1 + lib/work.js | 12 +++--- migrations/0040-pulls--add-ready-at.sql | 7 +++ migrations/schema.sql | 1 + models/db_pull.js | 12 +++++- test/db-pull.test.js | 40 +++++++++++++++++ test/projects.test.js | 57 +++++++++++++++++++++++++ test/schema-check.test.js | 6 +++ test/work.test.js | 12 ++++++ 10 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 migrations/0040-pulls--add-ready-at.sql create mode 100644 test/db-pull.test.js diff --git a/lib/projects.js b/lib/projects.js index 66e5089e..2d540aec 100644 --- a/lib/projects.js +++ b/lib/projects.js @@ -206,8 +206,9 @@ function pullProjects(labelRows, prefix, linked = {}) { /** pulls rows + project-label rows + the projects each PR links -> PullSpans, * bots and drafts left out: a draft isn't work yet (shared isDraft), so it - * counts from no day until it's marked ready, and one closed as a draft - * never does. */ + * 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 @@ -219,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, }; @@ -267,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, p.draft'; + 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 ` + @@ -287,8 +289,11 @@ export async function loadTimeSpent(settings, start, end, linked = {}) { settings.prefix.replace(/[\\%_]/g, '\\$&') + '%', ]), ]); - // days on a draft aren't days on a project yet (shared isDraft) - const found = [opened, merged, comments, stamps, reviews].flat().filter(r => !Number(r.draft)); + // 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, 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( @@ -351,7 +356,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, draft 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 6a7d7599..69deab92 100644 --- a/lib/schema-check.js +++ b/lib/schema-check.js @@ -58,6 +58,7 @@ export const MIGRATIONS = [ // 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' }, ]; /** The migration files this database hasn't had yet, oldest first. */ diff --git a/lib/work.js b/lib/work.js index ba190e13..e8d372a0 100644 --- a/lib/work.js +++ b/lib/work.js @@ -644,13 +644,15 @@ 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. + * It's created, as work, the day it was marked ready (ready_at), or opened + * when it never was a draft: 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), + createdAt: Number(row.ready_at ?? row.date), mergedAt: num(row.date_merged), closedAt: num(row.date_closed), state: row.state, @@ -693,7 +695,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, ' + - `p.draft, ${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 @@ -746,7 +748,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.draft, ${ + `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 ' + @@ -900,7 +902,7 @@ async function knownIssues(settings, inputs, slug) { ), db.query( 'SELECT `repo`, `number`, `title`, `owner`, `date`, `date_merged`, `date_closed`, `state`, ' + - '`draft` FROM `pulls` WHERE (`repo`, `number`) IN (?)', + '`draft`, `ready_at` FROM `pulls` WHERE (`repo`, `number`) IN (?)', pairs ), ]); 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/schema.sql b/migrations/schema.sql index 4545f946..efefe32e 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, 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/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/projects.test.js b/test/projects.test.js index 8889558c..1f8e36aa 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, @@ -21,6 +22,7 @@ import { 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 @@ -916,3 +918,58 @@ test('/api/v1/load leaves not-tracked projects out, past and ahead', async () => } ); }); + +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); +}); diff --git a/test/schema-check.test.js b/test/schema-check.test.js index 33e5a4d1..501d7788 100644 --- a/test/schema-check.test.js +++ b/test/schema-check.test.js @@ -134,3 +134,9 @@ test('names the initiatives file until both its tables are in', async () => { 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(); +}); 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(); + } +}); From 4c0b0f4e955a32a27a108d0a7ffa62c1a3074421 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:31:05 -0700 Subject: [PATCH 139/148] Fix the initiative page and Overview bug-hunt items Edit compared the form with the initiative as it is now, so a field someone else saved since counted as changed and was put back; it now compares with the initiative as the form opened. The page counted questions answered this visit as still owed, and waited forever on a failed load instead of offering Try again. Group by Initiative keyed its bands by name, which two initiatives can share. And a Not tracked project's drafts no group lists showed in the Overview's drafts. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/initiativeBoard.test.ts | 33 ++++++++ frontend-v2/src/model/initiativeBoard.ts | 20 ++++- frontend-v2/src/model/portfolio.ts | 22 +++++ frontend-v2/src/views/Projects.tsx | 11 ++- .../src/views/projects/HomeCalls.test.tsx | 84 +++++++++++++++++++ .../src/views/projects/Initiatives.tsx | 48 +++++++++-- frontend-v2/src/views/projects/Portfolio.tsx | 6 +- 7 files changed, 208 insertions(+), 16 deletions(-) diff --git a/frontend-v2/src/model/initiativeBoard.test.ts b/frontend-v2/src/model/initiativeBoard.test.ts index 93cab600..21e05c30 100644 --- a/frontend-v2/src/model/initiativeBoard.test.ts +++ b/frontend-v2/src/model/initiativeBoard.test.ts @@ -109,6 +109,25 @@ describe('trackedToday', () => { }); }); +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 = { @@ -363,3 +382,17 @@ describe('a Not tracked project', () => { 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 index b98d28ad..475f4123 100644 --- a/frontend-v2/src/model/initiativeBoard.ts +++ b/frontend-v2/src/model/initiativeBoard.ts @@ -1,7 +1,9 @@ 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 Today } from '../../../shared/model/projects'; +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'; @@ -22,13 +24,25 @@ export const UNSORTED = '(unsorted)'; 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. */ -export function trackedToday(today: Today, untracked: ReadonlySet): Today { + * 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), }; } diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts index 8cb254bc..9d1750e7 100644 --- a/frontend-v2/src/model/portfolio.ts +++ b/frontend-v2/src/model/portfolio.ts @@ -836,10 +836,32 @@ export function groupItems( 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') diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index 18f1f3dd..b0c02eca 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -119,7 +119,7 @@ export function Projects({ // (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 } = useInitiatives(); + 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 @@ -142,9 +142,12 @@ export function Projects({ : NO_TODAY, [fullData, allPulls, allClosed, prefix] ); - const today = useMemo(() => trackedToday(fullToday, untracked), [fullToday, untracked]); + 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 } = useRoadmap({ all: true }); + const { items: allPlans, loadFailed: plansFailed } = useRoadmap({ all: true }); const plans = useMemo( () => allPlans && trackedPlans(allPlans, initiatives), [allPlans, initiatives] @@ -264,6 +267,8 @@ export function Projects({ items={allItems} plans={allPlans} calls={kept.all} + owed={kept.owed} + loadFailed={initiativesFailed || plansFailed} nav={nav} navigate={navigate} onPerson={onPersonHere} diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index 25ee2fa8..bf6ccba7 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -866,6 +866,7 @@ describe('an initiative’s page', () => { items={items} plans={plans} calls={calls} + owed={calls} nav={{} as ProjectsNav} navigate={nothing} onPerson={nothing} @@ -905,6 +906,7 @@ describe('an initiative’s page', () => { items={ITEMS} plans={[done]} calls={[asked]} + owed={[asked]} nav={{} as ProjectsNav} navigate={go} onPerson={nothing} @@ -917,6 +919,86 @@ describe('an initiative’s page', () => { 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); @@ -937,6 +1019,7 @@ describe('an initiative’s page', () => { items={[...ITEMS, akeneo]} plans={[done]} calls={[]} + owed={[]} nav={{} as ProjectsNav} navigate={nothing} onPerson={nothing} @@ -1467,6 +1550,7 @@ describe('the one chooser, wherever a project is filed', () => { items={ITEMS} plans={[]} calls={[]} + owed={[]} nav={{} as ProjectsNav} navigate={nothing} onPerson={nothing} diff --git a/frontend-v2/src/views/projects/Initiatives.tsx b/frontend-v2/src/views/projects/Initiatives.tsx index a57778de..59feeff7 100644 --- a/frontend-v2/src/views/projects/Initiatives.tsx +++ b/frontend-v2/src/views/projects/Initiatives.tsx @@ -16,11 +16,23 @@ import { planFor, type RoadmapItem, } from '../../../../shared/model/roadmap'; -import { EmptyState, FactLink, QuietButton, textInputClass } from '../../components/bits'; +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, updateInitiative, type Home } from '../../model/initiativesData'; +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'; @@ -331,6 +343,9 @@ function EditFields({ 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 () => { @@ -338,7 +353,7 @@ function EditFields({ if ('error' in checked) return setProblem(checked.error); const changed = Object.fromEntries( Object.entries(checked.fields).filter( - ([k, v]) => v !== (initiative as unknown as Record)[k] + ([k, v]) => v !== (start as unknown as Record)[k] ) ) as Partial; if (!Object.keys(changed).length) return onDone(null); @@ -441,6 +456,8 @@ export function InitiativePage({ items, plans, calls, + owed: owedRows, + loadFailed = false, nav, navigate, onPerson, @@ -453,6 +470,10 @@ export function InitiativePage({ 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; @@ -474,8 +495,19 @@ export function InitiativePage({ document.title = was; }; }, [initiative?.name]); - if (!initiatives || !plans) - return

Loading the initiative…

; + if (!initiatives || !plans) { + return loadFailed ? ( + { + void loadInitiatives(); + void loadRoadmap(); + }} + /> + ) : ( +

Loading the initiative…

+ ); + } if (!initiative) { return ( !finished(r)); const done = rows.filter(finished); - const owed = calls.filter( - r => r.reasons.length && (aboutIt.includes(r) || (!!r.slug && slugs.includes(r.slug))) + 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' diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx index 4b9cc3f5..6d39b9b7 100644 --- a/frontend-v2/src/views/projects/Portfolio.tsx +++ b/frontend-v2/src/views/projects/Portfolio.tsx @@ -1367,7 +1367,7 @@ export function Portfolio(
{groups.map(g => ( )} row(item, g.repeats.has(item.slug) || repeated(item.slug))} /> From 83acb4f1a297eef9d2994acf6907d0ba12368520 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:31:05 -0700 Subject: [PATCH 140/148] Let Undo reopen work, and roll back one write Undo of Reopen sent the old closed status, which the close check refused because the project that made Decide ask is still going in it; an undo now skips that check, on the server and the fake board alike. A failed write restored the whole list from before it, undoing other writes still in flight; now it takes back only its own change and reads the list again. Co-Authored-By: Claude Opus 5.5 --- controllers/initiatives.js | 4 +- frontend-v2/src/model/initiativesData.test.ts | 61 +++++++++++++++++++ frontend-v2/src/model/initiativesData.ts | 60 +++++++++++++----- test/initiatives.test.js | 20 ++++++ 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/controllers/initiatives.js b/controllers/initiatives.js index 34305745..cc969ff1 100644 --- a/controllers/initiatives.js +++ b/controllers/initiatives.js @@ -147,7 +147,9 @@ export default { if (closing && was.kind !== 'initiative') { return res.status(409).json({ error: `a home of kind ${was.kind} is never closed` }); } - if (closing && was.status === 'open') { + // 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' && !undo) { const live = await liveProjects(was); if (live.length) { return res diff --git a/frontend-v2/src/model/initiativesData.test.ts b/frontend-v2/src/model/initiativesData.test.ts index a32e9b71..9e4d492c 100644 --- a/frontend-v2/src/model/initiativesData.test.ts +++ b/frontend-v2/src/model/initiativesData.test.ts @@ -129,6 +129,13 @@ describe('the initiatives store', () => { 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', @@ -165,3 +172,57 @@ describe('isNotTrackedNow', () => { 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; + }); +}); diff --git a/frontend-v2/src/model/initiativesData.ts b/frontend-v2/src/model/initiativesData.ts index c032e112..2b2a4fa7 100644 --- a/frontend-v2/src/model/initiativesData.ts +++ b/frontend-v2/src/model/initiativesData.ts @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { isDummy } from '../backend/dummy'; -import { DUMMY_GROWN, DUMMY_INITIATIVES } from '../backend/dummyProjects'; +import { DUMMY_GROWN, DUMMY_INITIATIVES, DUMMY_ROADMAP } from '../backend/dummyProjects'; import { createMemoryStore } from '../storage'; import { UNSORTED_WORD } from './words'; import { + goingProjects, homesOf, type Filing, type Initiative, @@ -170,6 +171,18 @@ function dummyApi(): Api { 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); @@ -223,12 +236,18 @@ export async function loadInitiatives(): Promise { } } -/** Change the initiatives on screen now; hand back a way to put them back. */ -function optimistic(change: (list: Initiative[]) => Initiative[]): () => void { +/** 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++; - const before = store.get().initiatives; - store.set({ ...store.get(), initiatives: change(before ?? []) }); - return () => store.set({ ...store.get(), initiatives: before }); + 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). */ @@ -271,7 +290,16 @@ export async function fileProject( undo?: boolean; } = {} ): Promise<{ before: Home } | { error: string }> { - const takeBack = optimistic(list => + 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), @@ -295,16 +323,13 @@ export async function fileProject( const name = now ? store.get().initiatives?.find(i => i.slug === now)?.name ?? now : UNSORTED_WORD; - void loadInitiatives(); 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(); - // closed since the board last read it: read them again, so the - // choosers stop offering it - if (reply.status === 409) void loadInitiatives(); return { error: problemOf(reply, 'file the project') }; } void loadInitiatives(); @@ -317,7 +342,7 @@ export async function moveInitiative( id: number, before: number | null ): Promise { - const takeBack = optimistic(list => { + optimistic(list => { const sorted = byRank(list); const moving = sorted.find(i => i.id === id); if (!moving) return list; @@ -328,7 +353,8 @@ export async function moveInitiative( }); const reply = await api.move(id, before).catch((): Reply => ({ status: 0, json: {} })); if (reply.status !== 200) { - takeBack(); + // 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[] }); @@ -344,10 +370,14 @@ export async function updateInitiative( 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); - const takeBack = optimistic(list => list.map(i => (i.id === id ? { ...i, ...fields } : i))); + 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) { - takeBack(); + // 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 }; } diff --git a/test/initiatives.test.js b/test/initiatives.test.js index 8a38c6f1..8307ec67 100644 --- a/test/initiatives.test.js +++ b/test/initiatives.test.js @@ -618,3 +618,23 @@ test('a close racing a filing of a live project into it is refused', async () => 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); +}); From c547f34fbc118c1249269d2ad2f8070e5138eb0e Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:31:05 -0700 Subject: [PATCH 141/148] Check every view key and the URL's week, group, years Opening an initiative's page changed no key viewChanged compared, so it replaced the history entry and Back left the board; every key of the view is compared now, and the page opens at its top. A parent link set group=parent, which the switch no longer offers; unknown groups read as the default. week= took impossible and non-Monday days, and window= and zoom= took years 0000-0099 and 9999. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/app.tsx | 32 ++++++--------- frontend-v2/src/lens.test.ts | 50 +++++++++++++++++++++++ frontend-v2/src/lens.ts | 35 ++++++++++++++-- frontend-v2/src/model/roadmapTime.test.ts | 4 +- frontend-v2/src/views/projects/parts.tsx | 3 +- 5 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 frontend-v2/src/lens.test.ts diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index bf1f70c0..970b1b3e 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -17,7 +17,10 @@ import { DEFAULT_RANGE, DEFAULT_SORT, LENS_LABELS, + groupParam, ORIGIN_KEYS, + viewChanged, + weekParam, windowParam, ZOOM_KEY, type Lens, @@ -160,7 +163,7 @@ function readHash(): HashState { within: p.get('within') || null, range: p.get('range') || DEFAULT_RANGE, status: p.get('status') || 'live', - group: p.get('group') || DEFAULT_GROUP, + 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', @@ -172,7 +175,7 @@ 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')), @@ -485,23 +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.window !== view.window || - 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) { 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 3b7f6027..29c76e07 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -16,17 +16,39 @@ export const DEFAULT_SORT = 'idle'; * 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 = /^(\d{4})-(0[1-9]|1[0-2])\.\.(\d{4})-(0[1-9]|1[0-2])$/; -const QUARTERS_KEY = /^(\d{4})-Q([1-4])\.\.(\d{4})-Q([1-4])$/; +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 @@ -76,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/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts index 7152bf84..bd3f616a 100644 --- a/frontend-v2/src/model/roadmapTime.test.ts +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -191,10 +191,10 @@ describe('the roadmap window', () => { }); it('cuts a span past five years to five, in its own unit', () => { - expect(windowParam('1900-01..2100-12')).toBe('1900-01..1904-12'); + 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('1900-01..2100-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); }); diff --git a/frontend-v2/src/views/projects/parts.tsx b/frontend-v2/src/views/projects/parts.tsx index 33498d6c..b0c713c1 100644 --- a/frontend-v2/src/views/projects/parts.tsx +++ b/frontend-v2/src/views/projects/parts.tsx @@ -351,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 } From 74f2c70efc08c57555008de335c9953edb199de2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:36:46 -0700 Subject: [PATCH 142/148] Fix the Roadmap bug-hunt items A plan saved Done, Parked or Dropped from its details on the initiative lanes, or moved so it ends before the window, vanished or folded away with its Undo; the open plan and the one whose receipt shows now stay. A week picked on the load chart counts only while the chart draws it, so the headline, chip and rows agree after a step. A window past the server's 400 days of PR history hatches the weeks before it and says where history starts. Lane glosses count the rows they head, a past window's history stops on its last day, a drag to the track's edge lands in the window, and a filing reads the roadmap again so a repo default's Not tracked flag is fresh. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/initiativesData.test.ts | 19 +++++ frontend-v2/src/model/initiativesData.ts | 4 + frontend-v2/src/model/roadmapTime.test.ts | 28 +++++++ frontend-v2/src/model/roadmapTime.ts | 29 +++++++ frontend-v2/src/views/projects/LoadChart.tsx | 32 +++++++- frontend-v2/src/views/projects/Roadmap.tsx | 41 +++++----- .../src/views/projects/RoadmapLanes.tsx | 12 ++- .../src/views/projects/RoadmapWindow.test.tsx | 76 ++++++++++++++++++- 8 files changed, 218 insertions(+), 23 deletions(-) diff --git a/frontend-v2/src/model/initiativesData.test.ts b/frontend-v2/src/model/initiativesData.test.ts index 9e4d492c..35ca9566 100644 --- a/frontend-v2/src/model/initiativesData.test.ts +++ b/frontend-v2/src/model/initiativesData.test.ts @@ -226,3 +226,22 @@ describe('two writes in flight', () => { 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 index 2b2a4fa7..518bd2b7 100644 --- a/frontend-v2/src/model/initiativesData.ts +++ b/frontend-v2/src/model/initiativesData.ts @@ -3,6 +3,7 @@ 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, @@ -333,6 +334,9 @@ export async function fileProject( 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 }; } diff --git a/frontend-v2/src/model/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts index bd3f616a..83c515df 100644 --- a/frontend-v2/src/model/roadmapTime.test.ts +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -23,6 +23,8 @@ import { zoomAround, zoomKey, type Zoom, + dayAcross, + historyRange, } from './roadmapTime'; const z = (key: string) => parseZoom(key) as Zoom; @@ -358,3 +360,29 @@ describe('columnNames', () => { 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'); + }); +}); diff --git a/frontend-v2/src/model/roadmapTime.ts b/frontend-v2/src/model/roadmapTime.ts index a4543404..07681eb1 100644 --- a/frontend-v2/src/model/roadmapTime.ts +++ b/frontend-v2/src/model/roadmapTime.ts @@ -1,6 +1,7 @@ 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, @@ -432,3 +433,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/views/projects/LoadChart.tsx b/frontend-v2/src/views/projects/LoadChart.tsx index 02faf1ae..30f78cae 100644 --- a/frontend-v2/src/views/projects/LoadChart.tsx +++ b/frontend-v2/src/views/projects/LoadChart.tsx @@ -169,6 +169,7 @@ export function LoadChart({ onOrigin, onPeople, firstPlan, + historyFrom = null, }: { weeks: LoadWeek[]; /** this week's load, whatever weeks the chart shows */ @@ -199,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 @@ -309,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 @@ -339,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; @@ -359,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.`} > { 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]; @@ -2411,6 +2412,11 @@ export function Roadmap({ }; const place = (day: string) => at(day, horizon); const weeks = mondaysBetween(horizon.start, horizon.end); + // a week picked on the load chart counts only while the chart draws it as + // a week that happened: one the window stepped away from, or one ahead, + // is no pick, so the headline, the chip and the rows never disagree + const picked = + nav.week && weeks.includes(nav.week) && nav.week <= mondayOf(today) ? nav.week : null; // the first of every month in the horizon, and every day, for the lines const months = level === 'quarters' @@ -2443,10 +2449,10 @@ export function Roadmap({ ...columns.map(c => ({ left: place(c.start), strong: true })), ], todayAt: today >= horizon.start && today < horizon.end ? place(today) : null, - picked: nav.week + picked: picked ? { - left: place(nav.week), - width: place(addWeeks(nav.week, 1)) - place(nav.week), + left: place(picked), + width: place(addWeeks(picked, 1)) - place(picked), } : null, }; @@ -2468,15 +2474,9 @@ export function Roadmap({ // the horizon's own history, so a project's real span starts where its // first PR did, not where the date range picker begins // capped at the server's 400 days, the most recent ones, for a long window - const pastEnd = today < horizon.end ? today : horizon.end; - const earliest = utcDay((dayStart(pastEnd) as number) - (MAX_RANGE_DAYS - 1) * DAY); - const pastRange: Range = { - start: horizon.start < earliest ? earliest : horizon.start, - end: pastEnd, - }; // a window wholly ahead has no history to ask for - const history = - useProjectsData(horizon.start <= today ? pastRange : null)?.window.projects ?? {}; + const pastRange: Range | null = historyRange(horizon.start, horizon.end, today, MAX_RANGE_DAYS); + const history = useProjectsData(pastRange)?.window.projects ?? {}; // one slug each: the full set, so a Not tracked plan's row has its project const bySlug = new Map(allItems.map(i => [i.slug, i])); // the calls Decide asks, by the plan they're about, and by project for @@ -2597,7 +2597,6 @@ export function Roadmap({ }); // a week picked on the load chart narrows them to exactly what that week // counts, by the chart's own rule, so its numbers and its rows agree - const picked = nav.week; const members = picked ? weekMembers({ today, plans: ordered, spans, ahead })(picked) : null; // an origin picked on the load chart keeps only the plans from there; // work with no plan has no origin to match @@ -2607,7 +2606,9 @@ export function Roadmap({ // one Decide asks about always shows, as the initiative lanes keep it const before = new Set( doneBefore(listed, horizon.start).flatMap(i => - i === gone || i.id === nav.item || planCalls.has(i.id) ? [] : [i.id] + i === gone || i.id === nav.item || receipt?.key === `plan:${i.id}` || planCalls.has(i.id) + ? [] + : [i.id] ) ); // a week picked on the load chart narrows the fold by the chart's rule @@ -3302,6 +3303,7 @@ export function Roadmap({ homeOf, inFlightRows, initiatives, + item: nav.item, laneId, laneKey, narrowed, @@ -3446,9 +3448,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} @@ -3488,9 +3491,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} @@ -3894,6 +3898,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 ? { diff --git a/frontend-v2/src/views/projects/RoadmapLanes.tsx b/frontend-v2/src/views/projects/RoadmapLanes.tsx index 6d08816f..d97dbf19 100644 --- a/frontend-v2/src/views/projects/RoadmapLanes.tsx +++ b/frontend-v2/src/views/projects/RoadmapLanes.tsx @@ -234,6 +234,8 @@ export interface LaneContext { 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; @@ -287,6 +289,7 @@ export function initiativeLanes(c: LaneContext) { homeOf, inFlightRows, initiatives, + item: openItem, laneId, laneKey, narrowed, @@ -447,7 +450,14 @@ export function initiativeLanes(c: LaneContext) { 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; - const heldPlan = (i: RoadmapItem) => i === gone || isUnderWay(i.status) || planCalls.has(i.id); + // 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); diff --git a/frontend-v2/src/views/projects/RoadmapWindow.test.tsx b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx index 5b81dabc..121e3e2b 100644 --- a/frontend-v2/src/views/projects/RoadmapWindow.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx @@ -47,6 +47,10 @@ const PLANS = [ 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 }), ]; const REOPEN: DecideRow = { slug: 'akeneo', @@ -87,8 +91,19 @@ beforeAll(() => { Element.prototype.scrollIntoView = () => undefined; vi.stubGlobal( 'fetch', - vi.fn(async (path: string) => { + 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 }); @@ -283,3 +298,62 @@ describe('the edge chip', () => { 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 + expect(text(el)).not.toContain('Week of Sep 14'); + 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'); + }); +}); + +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); + }); +}); From a1b008fb0ef5da599d8b9edabf6d745810b16a5d Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 15:46:42 -0700 Subject: [PATCH 143/148] Speed up the Roadmap at prod scale With about 290 plans every change re-rendered for 1.3 to 2.8 s of long tasks on a production build. dayWords built a date formatter per call, every row drew every week line past the seven months whose week dates are already dropped, and loadByWeek worked out each plan's end once per week. Measured on one press on a bar: 1251 to 149 ms on the default window, 1382 to 146 ms on 12 months, 1757 to 209 ms on 5 years in quarters, and 2803 to 317 ms on 5 years in months. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/projectData.ts | 20 ++++++++++++++++---- frontend-v2/src/views/projects/Roadmap.tsx | 6 +++++- shared/model/load.ts | 15 +++++++++------ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend-v2/src/model/projectData.ts b/frontend-v2/src/model/projectData.ts index 4d50ccc5..3beb7be2 100644 --- a/frontend-v2/src/model/projectData.ts +++ b/frontend-v2/src/model/projectData.ts @@ -140,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 { diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index 6b2970c7..b6a60f96 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -2441,7 +2441,11 @@ export function Roadmap({ ? months.map(day => ({ left: place(day), strong: columns.some(c => c.start === day) })) : level === 'months' ? [ - ...weeks.map(week => ({ left: place(week), strong: false })), + // past seven months the week lines crowd, as their dates do + // (ticks), and every row draws them: the months' alone + ...(columns.length <= 7 + ? weeks.map(week => ({ left: place(week), strong: false })) + : []), ...months.map(day => ({ left: place(day), strong: true })), ] : [ 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'); } From c5454906d34ed3bce2dc4520a4546620332ac700 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 16:06:23 -0700 Subject: [PATCH 144/148] Close the remaining server gaps from round two A truthy but empty undo skipped the close check; only one with times to put back does now. Look back credits a former draft's ready day as the day it opened (0041 indexes ready_at for that read). Roadmap status writes, restores and creates, and initiative creates, run through the same one-at-a-time chain as initiative writes, and every query has a 60 s timeout so a hung one can't stall it. The work model keeps createdAt as the open day and carries readyAt beside it, so "opened" stays true on screen while planWork and the late words go by the ready day ("marked ready after the end date"). Co-Authored-By: Claude Opus 5.5 --- controllers/initiatives.js | 31 +++++------- controllers/roadmap.js | 19 +++++--- frontend-v2/src/model/stage.test.ts | 6 +++ frontend-v2/src/model/stage.ts | 9 ++-- frontend-v2/src/model/work.test.ts | 12 +++++ frontend-v2/src/views/projects/Work.tsx | 6 ++- lib/db.js | 17 +++++-- lib/initiatives.js | 14 ++++++ lib/projects.js | 6 ++- lib/schema-check.js | 1 + lib/work.js | 7 +-- migrations/0041-pulls--add-ready-at-index.sql | 4 ++ migrations/schema.sql | 3 +- shared/model/work.ts | 17 +++++-- test/db-retry.test.js | 12 ++++- test/initiatives.test.js | 47 ++++++++++++++++++- test/projects.test.js | 23 +++++++++ test/schema-check.test.js | 6 +++ 18 files changed, 192 insertions(+), 48 deletions(-) create mode 100644 migrations/0041-pulls--add-ready-at-index.sql diff --git a/controllers/initiatives.js b/controllers/initiatives.js index cc969ff1..7f5fd544 100644 --- a/controllers/initiatives.js +++ b/controllers/initiatives.js @@ -10,6 +10,7 @@ import { getInitiative, listInitiatives, moveInitiative, + serially, updateInitiative, } from '../lib/initiatives.js'; import { undoTimes } from './roadmap.js'; @@ -60,17 +61,6 @@ async function liveProjects(initiative) { ); } -// Writes that check and then change initiatives run one at a time, so a -// close can't pass its check while a filing lands, nor a delete while one -// files into it. ponytail: one process serves the board and the API; a -// lock in MySQL if Pulldasher ever runs more than one -let writing = Promise.resolve(); -function serially(run) { - const done = writing.then(run, run); - writing = done.catch(() => {}); - return done; -} - /** A failed write's 500, logged. */ function failed(res, what) { return err => { @@ -103,13 +93,12 @@ export default { const checked = checkInitiativeFields(req.body, { partial: false }); if (checked.error) return res.status(400).json({ error: checked.error }); const kind = checked.fields.kind ?? 'initiative'; - // ponytail: two creates racing can both pass; a unique key on a - // generated column if anyone ever scripts them - (kind === 'initiative' ? Promise.resolve([]) : listInitiatives()) - .then(all => { - if (all.some(i => i.kind === kind)) return { special: true }; - return createInitiative(checked.fields, req.roadmapLogin); - }) + // 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 @@ -136,6 +125,8 @@ export default { 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 () => { @@ -149,7 +140,7 @@ export default { } // 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' && !undo) { + if (closing && was.status === 'open' && !times) { const live = await liveProjects(was); if (live.length) { return res @@ -159,7 +150,7 @@ export default { } const changed = await updateInitiative(id, checked.fields, req.roadmapLogin, { restate: restate === true, - undo: undoTimes(undo), + undo: times, }); if (!changed) return res.status(404).json({ error: 'no such initiative' }); res.json({ initiative: (await sent([changed]))[0] }); diff --git a/controllers/roadmap.js b/controllers/roadmap.js index 078057ad..5619951c 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -15,7 +15,7 @@ import { restoreItem, updateItem, } from '../lib/roadmap.js'; -import { tracking, trackedOnly } from '../lib/initiatives.js'; +import { serially, tracking, trackedOnly } from '../lib/initiatives.js'; import { sharedValue } from '../lib/ttl-cache.js'; import { loadPullLinks, loadWork } from '../lib/work.js'; import { @@ -298,7 +298,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 => { @@ -360,7 +361,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 @@ -377,10 +378,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 decides whether its project is going in an initiative: + // one at a time with initiative closes + const item = await (checked.fields.status ? 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/model/stage.test.ts b/frontend-v2/src/model/stage.test.ts index dab839d9..09dd73b9 100644 --- a/frontend-v2/src/model/stage.test.ts +++ b/frontend-v2/src/model/stage.test.ts @@ -268,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 diff --git a/frontend-v2/src/model/stage.ts b/frontend-v2/src/model/stage.ts index c3753029..583e2f25 100644 --- a/frontend-v2/src/model/stage.ts +++ b/frontend-v2/src/model/stage.ts @@ -199,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); @@ -209,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 diff --git a/frontend-v2/src/model/work.test.ts b/frontend-v2/src/model/work.test.ts index f9161dee..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 }); diff --git a/frontend-v2/src/views/projects/Work.tsx b/frontend-v2/src/views/projects/Work.tsx index a852f6b5..da2ffb06 100644 --- a/frontend-v2/src/views/projects/Work.tsx +++ b/frontend-v2/src/views/projects/Work.tsx @@ -815,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 diff --git a/lib/db.js b/lib/db.js index dd7bd571..6d6f9af2 100644 --- a/lib/db.js +++ b/lib/db.js @@ -37,10 +37,19 @@ export async function retryIfIdleClosed(run) { } } -export default { +// A query that hasn't answered in this long fails (mysql2 drops its +// connection), so a hung one can't hold up what waits on it, such as the +// initiative writes lib/initiatives.js serially runs one at a time. Far past +// any query the board makes. +export const QUERY_TIMEOUT_MS = 60 * 1000; + +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({ sql: query, timeout: QUERY_TIMEOUT_MS }, 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 +57,5 @@ export default { return pool.end(); } }; + +export default db; diff --git a/lib/initiatives.js b/lib/initiatives.js index 7b035069..58de57c7 100644 --- a/lib/initiatives.js +++ b/lib/initiatives.js @@ -145,6 +145,20 @@ export async function deleteInitiative(id) { 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 hung query can't stall the chain: +// lib/db.js gives every query a timeout. ponytail: one process serves the +// board and the API; a lock in MySQL if Pulldasher ever runs more than one +let writing = Promise.resolve(); +export function serially(run) { + const done = writing.then(run, run); + 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. diff --git a/lib/projects.js b/lib/projects.js index 2d540aec..bde3aa1b 100644 --- a/lib/projects.js +++ b/lib/projects.js @@ -279,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), @@ -291,7 +293,7 @@ export async function loadTimeSpent(settings, start, end, linked = {}) { ]); // 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, merged, comments, stamps, reviews] + 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; diff --git a/lib/schema-check.js b/lib/schema-check.js index 69deab92..e286e870 100644 --- a/lib/schema-check.js +++ b/lib/schema-check.js @@ -59,6 +59,7 @@ export const MIGRATIONS = [ { 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 e8d372a0..00b36784 100644 --- a/lib/work.js +++ b/lib/work.js @@ -645,14 +645,15 @@ export function workPullTouched(settings, repo, body, labels = []) { } /** A pulls row as the work model reads a PR; its body's links when read. - * It's created, as work, the day it was marked ready (ready_at), or opened - * when it never was a draft: a draft isn't work yet (shared isDraft). */ + * 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.ready_at ?? row.date), + 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, 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 efefe32e..fae4ec5f 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -171,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 */; diff --git a/shared/model/work.ts b/shared/model/work.ts index d14af93c..03d98279 100644 --- a/shared/model/work.ts +++ b/shared/model/work.ts @@ -123,8 +123,11 @@ 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; @@ -142,7 +145,10 @@ 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. */ @@ -325,13 +331,15 @@ export function planWork(inputs: WorkInputs): PlanWork[] { for (const pr of prs) { // a draft isn't work yet: not open, not late, not a comeback if (pr.draft) continue; - const plan = planOfWork(mine, pr.createdAt); + // 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); } } @@ -359,6 +367,7 @@ function issuePull(pr: WorkPull): IssuePull { title: pr.title, author: pr.author, createdAt: pr.createdAt, + ...(pr.readyAt != null ? { readyAt: pr.readyAt } : {}), state, ...(pr.draft && state === 'open' ? { draft: true as const } : {}), }; diff --git a/test/db-retry.test.js b/test/db-retry.test.js index 32a482d6..96b10863 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, { QUERY_TIMEOUT_MS, retryIfIdleClosed } from '../lib/db.js'; const idleClosed = Object.assign(new Error('disconnected by the server'), { errno: 4031 }); @@ -39,3 +39,11 @@ test('it retries only once', async () => { ); assert.equal(calls, 2); }); + +test('every query is sent with a timeout, so a hung one fails', async () => { + mock.method(db.pool, 'query', async (options, params) => [{ options, params }]); + const sent = await db.query('SELECT ?', [1]); + mock.restoreAll(); + assert.deepEqual(sent, { options: { sql: 'SELECT ?', timeout: QUERY_TIMEOUT_MS }, params: [1] }); + assert.ok(QUERY_TIMEOUT_MS > 0); +}); diff --git a/test/initiatives.test.js b/test/initiatives.test.js index 8307ec67..efe96508 100644 --- a/test/initiatives.test.js +++ b/test/initiatives.test.js @@ -9,7 +9,7 @@ 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 { canWrite } from '../controllers/roadmap.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 @@ -30,6 +30,11 @@ const byRank = () => [...initiatives].sort((a, b) => a.priority - b.priority || 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`')) { @@ -129,12 +134,15 @@ function makeApp() { 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; @@ -148,11 +156,14 @@ let server; let base; before(async () => { mock.method(db, 'query', async (sql, params) => { - if (hold && sql.startsWith(hold.release)) hold.open(); + // 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 => { @@ -173,6 +184,7 @@ beforeEach(() => { prs = []; nextId = 1; hold = null; + ranInOrder.length = 0; }); async function call(method, path, body) { @@ -637,4 +649,35 @@ test('an Undo of a Reopen closes it again, though a project in it is still going // 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 1f8e36aa..64c03b2f 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -410,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' }, @@ -973,3 +974,25 @@ test('time spent leaves out a ready PR’s days from before it was marked ready' 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/schema-check.test.js b/test/schema-check.test.js index 501d7788..6bbd438f 100644 --- a/test/schema-check.test.js +++ b/test/schema-check.test.js @@ -140,3 +140,9 @@ test('names the ready_at migration until pulls has the column', async () => { 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(); +}); From ac0fba08d495959f6764fe4023def192368ae7da Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 16:08:16 -0700 Subject: [PATCH 145/148] Count what's left to decide the same everywhere After a home answer on a row that also asks about its plan, the row stays owed for the plan, so the tab badge, the Initiative and team switches and the initiative page said 1 while Decide's heading said nothing was left. keepCalls now returns `open`, the owed rows not answered this visit, and every count reads it. The plan question still comes on the next visit, as Decide already said; asking it at once would need a second state on the row beside its receipt. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/views/Projects.tsx | 4 +-- frontend-v2/src/views/projects/Decide.tsx | 29 +++++++++++++------ .../src/views/projects/HomeCalls.test.tsx | 3 ++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx index b0c02eca..fdad01b2 100644 --- a/frontend-v2/src/views/Projects.tsx +++ b/frontend-v2/src/views/Projects.tsx @@ -239,7 +239,7 @@ export function Projects({ ariaLabel="projects view" value={nav.view} options={views} - counts={{ decide: owedCalls?.length }} + counts={{ decide: decisions ? kept.open.length : undefined }} tabs // a view's own picks (its sort, team, person, week) stay with it, // so one view never quietly narrows the next; the range, the find @@ -267,7 +267,7 @@ export function Projects({ items={allItems} plans={allPlans} calls={kept.all} - owed={kept.owed} + owed={kept.open} loadFailed={initiativesFailed || plansFailed} nav={nav} navigate={navigate} diff --git a/frontend-v2/src/views/projects/Decide.tsx b/frontend-v2/src/views/projects/Decide.tsx index 87f60067..ecf51c84 100644 --- a/frontend-v2/src/views/projects/Decide.tsx +++ b/frontend-v2/src/views/projects/Decide.tsx @@ -252,7 +252,12 @@ 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 @@ -283,7 +288,13 @@ export function keepCalls( ...owed.filter(row => !settled.has(rowKey(row))).map(row => asked.get(rowKey(row)) ?? row), ...[...settled.values()].map(m => m.row), ].sort(compareRows); - return { owed, all, settled }; + // 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 @@ -2329,7 +2340,7 @@ export function Decide({ : 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 = owed.filter(row => !kept.settled.has(rowKey(row))).length; + const toMake = kept.open.filter(inTeam).length; const heading = toMake ? `${n(toMake, 'decision')} to make` : decidedHere.length @@ -2383,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 @@ -2447,7 +2458,7 @@ export function Decide({ {(initiatives ?? []).length > 0 && ( kept.owed.filter(row => withinOf(row) === slug).length} + owedIn={slug => kept.open.filter(row => withinOf(row) === slug).length} value={nav.within} onChange={within => navigate({ within })} /> @@ -2458,7 +2469,7 @@ export function Decide({ ariaLabel="team" value={nav.team ?? ''} options={[ - ['', counted('All teams', kept.owed.length)], + ['', counted('All teams', kept.open.length)], // a team with nothing owed has nothing to show ...teams .filter(t => owedBy(t) > 0 || nav.team === t) @@ -2500,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( @@ -2510,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 diff --git a/frontend-v2/src/views/projects/HomeCalls.test.tsx b/frontend-v2/src/views/projects/HomeCalls.test.tsx index bf6ccba7..3dc47bcf 100644 --- a/frontend-v2/src/views/projects/HomeCalls.test.tsx +++ b/frontend-v2/src/views/projects/HomeCalls.test.tsx @@ -815,6 +815,9 @@ describe('a project whose closed initiative and done plan are both asked about', 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([]); }); }); From e82e8e4bf7c151c5164fb64cb597afbefcd7b20f Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 16:13:16 -0700 Subject: [PATCH 146/148] Hold Roadmap rows where they were drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Not tracked plan saved Done from its details vanished with its Undo; that lane now holds the open plan and the one with a receipt, as the initiative lanes do. Opening a plan inside the Done before fold pulled its row out of the fold, far up the page; a held plan now stays in the list the last render drew it in (the plan being moved counts as held, so a move out of the window still keeps its Undo in view). Month names that shorten keep the year on the first column and each January ("Jan ’23", then "2023"). A step past 1970 or 2999 stays put instead of jumping to the default window. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/roadmapTime.test.ts | 28 +++++++- frontend-v2/src/model/roadmapTime.ts | 24 ++++++- frontend-v2/src/views/projects/Roadmap.tsx | 26 +++++-- .../src/views/projects/RoadmapWindow.test.tsx | 69 ++++++++++++++++++- 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/frontend-v2/src/model/roadmapTime.test.ts b/frontend-v2/src/model/roadmapTime.test.ts index 83c515df..ac29260b 100644 --- a/frontend-v2/src/model/roadmapTime.test.ts +++ b/frontend-v2/src/model/roadmapTime.test.ts @@ -332,7 +332,8 @@ describe('columnNames', () => { 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); - expect(short.every(t => t && t.length === 3)).toBe(true); + // 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', @@ -353,6 +354,16 @@ describe('columnNames', () => { 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)); @@ -386,3 +397,18 @@ describe('dayAcross', () => { 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 07681eb1..abbc013a 100644 --- a/frontend-v2/src/model/roadmapTime.ts +++ b/frontend-v2/src/model/roadmapTime.ts @@ -43,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. */ @@ -273,9 +276,16 @@ export function drawnWindow( /** 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; - return { from: w.from + by * length, to: w.to + by * length }; + 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 @@ -327,9 +337,17 @@ export function columnNames(columns: readonly Column[], px: readonly 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, (_, i) => month(i), (_, i) => month(i).slice(0, 1)]; + : [ + 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; diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx index b6a60f96..04c85d99 100644 --- a/frontend-v2/src/views/projects/Roadmap.tsx +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -2348,6 +2348,9 @@ export function Roadmap({ focusResult(el, said, row?.firstElementChild as HTMLElement | null); }); const stickyRef = useRef(null); + // the done plans the last render folded: an open plan or a receipt keeps + // a row where it was drawn, never pulls one out of the fold + const foldedLast = useRef>(new Set()); const now = new Date(); const today = dayOf(now); const timeline = nav.scale !== 'now'; @@ -2608,13 +2611,21 @@ export function Roadmap({ // done plans that ended before the window rest in one fold under the // rows, closed, instead of each taking a row; one open stays in its place // one Decide asks about always shows, as the initiative lanes keep it + // the open plan, the one being moved (run: set before its save shows) and + // the one whose receipt shows stay where the last render drew them + const heldHere = (i: RoadmapItem) => + (i.id === nav.item || + receipt?.key === `plan:${i.id}` || + run.current?.key === `plan:${i.id}`) && + !foldedLast.current.has(i.id); const before = new Set( doneBefore(listed, horizon.start).flatMap(i => - i === gone || i.id === nav.item || receipt?.key === `plan:${i.id}` || planCalls.has(i.id) - ? [] - : [i.id] + i === gone || heldHere(i) || planCalls.has(i.id) ? [] : [i.id] ) ); + useEffect(() => { + foldedLast.current = before; + }); // a week picked on the load chart narrows the fold by the chart's rule // too, so its count and the fold agree const shownBefore = showPlans @@ -3339,7 +3350,14 @@ export function Roadmap({ const trackedIds = new Set(ids); const recent = (slug: string | null) => (slug && bySlug.get(slug)?.lastActivity?.at) || 0; const notTrackedPlans = (everyPlan ?? []) - .filter(i => !trackedIds.has(i.id) && isUnderWay(i.status) && found(i)) + // 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 = diff --git a/frontend-v2/src/views/projects/RoadmapWindow.test.tsx b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx index 121e3e2b..0d988702 100644 --- a/frontend-v2/src/views/projects/RoadmapWindow.test.tsx +++ b/frontend-v2/src/views/projects/RoadmapWindow.test.tsx @@ -51,6 +51,14 @@ const PLANS = [ 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', @@ -74,6 +82,22 @@ const INITIATIVES: Initiative[] = [ 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[] = []; @@ -336,8 +360,10 @@ describe('a plan just saved', () => { 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 + // 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); }); @@ -346,6 +372,12 @@ describe('the load chart', () => { 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); }); }); @@ -357,3 +389,38 @@ describe('the lane glosses', () => { 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); + }); +}); From aa8164f524db9b7dffd6aa9fa71ad131d1501471 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 16:16:21 -0700 Subject: [PATCH 147/148] Limit the write chain, not every query The previous commit gave every query on the shared pool a 60 s timeout so one hung query couldn't stall the initiative writes that run one at a time. But bin/migrate-missing and the backfill bins share that pool: a migration altering a big table past 60 s would have its connection dropped while MySQL kept running the ALTER. serially now gives each step 60 s instead: a step that hasn't finished fails its own request and lets the chain move on. No query is cut off. Co-Authored-By: Claude Opus 5.5 --- lib/db.js | 8 +------- lib/initiatives.js | 21 ++++++++++++++++----- test/db-retry.test.js | 9 ++++----- test/serially.test.js | 27 +++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 test/serially.test.js diff --git a/lib/db.js b/lib/db.js index 6d6f9af2..44ee13d6 100644 --- a/lib/db.js +++ b/lib/db.js @@ -37,18 +37,12 @@ export async function retryIfIdleClosed(run) { } } -// A query that hasn't answered in this long fails (mysql2 drops its -// connection), so a hung one can't hold up what waits on it, such as the -// initiative writes lib/initiatives.js serially runs one at a time. Far past -// any query the board makes. -export const QUERY_TIMEOUT_MS = 60 * 1000; - const db = { // the pool, for a test to see what each query is sent with pool, query: function (query, params) { return retryIfIdleClosed(() => - pool.query({ sql: query, timeout: QUERY_TIMEOUT_MS }, params) + pool.query(query, params) ).then(result => result[0]); // [rows, fields] }, // The refresh-* bins call this to close the pool so the process can exit diff --git a/lib/initiatives.js b/lib/initiatives.js index 58de57c7..53ff6e74 100644 --- a/lib/initiatives.js +++ b/lib/initiatives.js @@ -149,12 +149,23 @@ export async function deleteInitiative(id) { // 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 hung query can't stall the chain: -// lib/db.js gives every query a timeout. ponytail: one process serves the -// board and the API; a lock in MySQL if Pulldasher ever runs more than one +// 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) { - const done = writing.then(run, run); +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; } diff --git a/test/db-retry.test.js b/test/db-retry.test.js index 96b10863..d918d474 100644 --- a/test/db-retry.test.js +++ b/test/db-retry.test.js @@ -1,6 +1,6 @@ import { test, mock } from 'node:test'; import assert from 'node:assert/strict'; -import db, { QUERY_TIMEOUT_MS, retryIfIdleClosed } from '../lib/db.js'; +import db, { retryIfIdleClosed } from '../lib/db.js'; const idleClosed = Object.assign(new Error('disconnected by the server'), { errno: 4031 }); @@ -40,10 +40,9 @@ test('it retries only once', async () => { assert.equal(calls, 2); }); -test('every query is sent with a timeout, so a hung one fails', async () => { - mock.method(db.pool, 'query', async (options, params) => [{ options, params }]); +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, { options: { sql: 'SELECT ?', timeout: QUERY_TIMEOUT_MS }, params: [1] }); - assert.ok(QUERY_TIMEOUT_MS > 0); + assert.deepEqual(sent, { sql: 'SELECT ?', params: [1] }); }); 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'); +}); From 0a0d9687015a3eb0f33a2c9bb0ff67d6c146b7c4 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 20 Oct 2026 16:20:46 -0700 Subject: [PATCH 148/148] Serialize a plan edit that changes its project Final verify: a roadmap PATCH that only moved a plan to another project skipped the one-at-a-time chain, so an initiative close could pass its check while a plan under way moved into it. A project change decides whether a plan is going in an initiative as much as a status does. Co-Authored-By: Claude Opus 5.5 --- controllers/roadmap.js | 13 ++++++++++--- test/roadmap-serial.test.js | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 test/roadmap-serial.test.js diff --git a/controllers/roadmap.js b/controllers/roadmap.js index 5619951c..5cf869b1 100644 --- a/controllers/roadmap.js +++ b/controllers/roadmap.js @@ -221,6 +221,13 @@ export 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 @@ -383,9 +390,9 @@ export default { restate: req.body?.restate === true, undo: undoTimes(req.body?.undo), }); - // a status decides whether its project is going in an initiative: - // one at a time with initiative closes - const item = await (checked.fields.status ? serially(save) : save()); + // 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/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); +});