From 6205792071aa3dbc76b01aba90c4629465a88994 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 29 Sep 2026 16:32:53 -0700 Subject: [PATCH 001/116] Add a shared model for projects The team wants Pulldasher to show which projects the open PRs serve, who is on each, and backlog numbers for any date range. A job outside Pulldasher files each PR into a project with one label, `project:`, and opens one GitHub issue per project with the same label: the title is its name, the assignee its lead, the milestone its target, `parent:` labels its parents, and an `ongoing` label marks work with no end. This is the pure half the board and the server both run, the way shared/model/status.ts serves both: 1) projectSlugs and projectOf read a PR's project off its labels. A real project beats misc; otherwise the first label alphabetically. 2) buildToday groups open PRs and the last 14 days of merges into live projects, quiet ones (an open issue with nothing in flight), misc, not sorted yet, and PRs with two project labels. It flags only what is true: one person on 3 or more PRs, 2 or more open PRs all waiting on review, a lead with work in 3 or more other live projects, and a closed issue with a PR still open. 3) windowStats counts the backlog at each end, opened, merged, closed without merging, and median age for the UTC days start..end, overall, per project and per person, plus one cumulative point per day for the backlog chart. Every bucket keeps start + opened - merged - closed = end. Co-Authored-By: Claude Opus 5.5 --- frontend-v2/src/model/projects.test.ts | 274 ++++++++++++++++++ shared/index.ts | 15 + shared/model/projects.ts | 377 +++++++++++++++++++++++++ 3 files changed, 666 insertions(+) create mode 100644 frontend-v2/src/model/projects.test.ts create mode 100644 shared/model/projects.ts diff --git a/frontend-v2/src/model/projects.test.ts b/frontend-v2/src/model/projects.test.ts new file mode 100644 index 00000000..235d683f --- /dev/null +++ b/frontend-v2/src/model/projects.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; +import type { PullData } from '../../../shared/types'; +import type { DerivedPull, Status } from '../../../shared/model/status'; +import { + buildToday, + dayStart, + projectOf, + projectSlugs, + windowStats, + type Project, + type PullSpan, +} from '../../../shared/model/projects'; + +const DAY = 86400; +const NOW = 1_790_000_000; +const P = 'project:'; +const labels = (...titles: string[]) => titles.map(title => ({ title })); +const iso = (secs: number) => new Date(secs * 1000).toISOString(); + +let n = 0; +function open(over: { + labels?: string[]; + author?: string; + status?: Status; + ageDays?: number; + idleDays?: number; +}): DerivedPull { + return { + status: over.status ?? 'needs_cr', + data: { + repo: 'iFixit/ifixit', + number: ++n, + user: { login: over.author ?? 'alice' }, + labels: labels(...(over.labels ?? [])), + created_at: iso(NOW - (over.ageDays ?? 1) * DAY), + updated_at: iso(NOW - (over.idleDays ?? 0) * DAY), + }, + } as unknown as DerivedPull; +} + +function merged(over: { labels?: string[]; author?: string; daysAgo: number }): PullData { + return { + repo: 'iFixit/ifixit', + number: ++n, + user: { login: over.author ?? 'alice' }, + labels: labels(...(over.labels ?? [])), + merged_at: iso(NOW - over.daysAgo * DAY), + closed_at: iso(NOW - over.daysAgo * DAY), + } as unknown as PullData; +} + +function project(over: Partial & { slug: string }): Project { + return { + name: over.slug.toUpperCase(), + repo: 'iFixit/projects', + number: ++n, + state: 'open', + state_reason: null, + ongoing: false, + parents: [], + lead: null, + target: null, + ...over, + }; +} + +describe('projectSlugs / projectOf', () => { + it('reads project labels in sorted order and skips the bare prefix', () => { + expect(projectSlugs(labels('size: S', 'project:zeta', 'project:', 'project:alpha'), P)).toEqual([ + 'alpha', + 'zeta', + ]); + }); + + it('prefers a real project over misc, and is null with no project label', () => { + expect(projectOf(labels('project:misc', 'project:zeta'), P)).toBe('zeta'); + expect(projectOf(labels('project:misc'), P)).toBe('misc'); + expect(projectOf(labels('security'), P)).toBeNull(); + }); +}); + +describe('buildToday', () => { + it('files open PRs into projects, misc, and not-sorted, and lists double labels', () => { + const a = open({ labels: ['project:workbench'] }); + const b = open({ labels: ['project:misc'] }); + const c = open({ labels: [] }); + const d = open({ labels: ['project:workbench', 'project:search'] }); + const today = buildToday([project({ slug: 'workbench' })], [a, b, c, d], [], P, NOW); + expect(today.misc).toEqual([b]); + expect(today.unsorted).toEqual([c]); + expect(today.doubleLabeled).toEqual([d]); + // d files under its first label alphabetically ("search"), not workbench + expect(today.live.map(g => [g.slug, g.open.length])).toEqual([ + ['search', 1], + ['workbench', 1], + ]); + }); + + it('keeps a project live on a merge in the last 14 days, but not an older one', () => { + const today = buildToday( + [project({ slug: 'recent' }), project({ slug: 'old' })], + [], + [ + merged({ labels: ['project:recent'], daysAgo: 13 }), + merged({ labels: ['project:old'], daysAgo: 15 }), + ], + P, + NOW + ); + expect(today.live.map(g => g.slug)).toEqual(['recent']); + expect(today.quiet.map(g => g.slug)).toEqual(['old']); + }); + + it('drops a closed project whose only activity is merges, keeps one with an open PR', () => { + const today = buildToday( + [ + project({ slug: 'done', state: 'closed', state_reason: 'completed' }), + project({ slug: 'reopenme', state: 'closed', state_reason: 'completed' }), + ], + [open({ labels: ['project:reopenme'] })], + [merged({ labels: ['project:done'], daysAgo: 2 })], + P, + NOW + ); + expect(today.live.map(g => g.slug)).toEqual(['reopenme']); + expect(today.live[0].flags).toContain('issue_closed'); + expect(today.quiet).toEqual([]); + }); + + it('shows a label with no issue under its slug', () => { + const today = buildToday([], [open({ labels: ['project:new-thing'] })], [], P, NOW); + expect(today.live[0].project).toBeNull(); + expect(today.live[0].slug).toBe('new-thing'); + }); + + it('flags one person only past the minimum PR count', () => { + const three = buildToday( + [], + [open({ labels: ['project:solo'], author: 'bob' })], + [ + merged({ labels: ['project:solo'], author: 'bob', daysAgo: 1 }), + merged({ labels: ['project:solo'], author: 'bob', daysAgo: 2 }), + ], + P, + NOW + ); + expect(three.live[0].flags).toEqual(['one_person']); + const two = buildToday( + [], + [open({ labels: ['project:solo'], author: 'bob' })], + [merged({ labels: ['project:solo'], author: 'bob', daysAgo: 1 })], + P, + NOW + ); + expect(two.live[0].flags).toEqual([]); + }); + + it('flags a project when every open PR is waiting on review, from two PRs up', () => { + const today = buildToday( + [], + [ + open({ labels: ['project:stuck'], status: 'needs_cr', author: 'a' }), + open({ labels: ['project:stuck'], status: 'needs_qa', author: 'b' }), + open({ labels: ['project:moving'], status: 'needs_cr', author: 'a' }), + open({ labels: ['project:moving'], status: 'ready', author: 'b' }), + ], + [], + P, + NOW + ); + const flags = Object.fromEntries(today.live.map(g => [g.slug, g.flags])); + expect(flags.stuck).toEqual(['waiting_on_review']); + expect(flags.moving).toEqual([]); + }); + + it('flags a lead who has work in three other live projects', () => { + const pulls = ['one', 'two', 'three'].map(s => open({ labels: [`project:${s}`], author: 'lee' })); + const led = open({ labels: ['project:led'], author: 'sam' }); + const today = buildToday([project({ slug: 'led', lead: 'lee' })], [...pulls, led], [], P, NOW); + expect(today.live.find(g => g.slug === 'led')?.flags).toEqual(['lead_spread']); + }); + + it('reports idle days from the stalest open PR and sorts the busiest first', () => { + const today = buildToday( + [], + [ + open({ labels: ['project:small'], idleDays: 2 }), + open({ labels: ['project:big'], idleDays: 3 }), + open({ labels: ['project:big'], idleDays: 40 }), + ], + [], + P, + NOW + ); + expect(today.live.map(g => [g.slug, g.idleDays])).toEqual([ + ['big', 40], + ['small', 2], + ]); + }); +}); + +describe('dayStart', () => { + it('parses real UTC days and rejects anything else', () => { + expect(dayStart('2026-09-29')).toBe(Date.UTC(2026, 8, 29) / 1000); + expect(dayStart('2026-02-30')).toBeNull(); + expect(dayStart('9/29/2026')).toBeNull(); + }); +}); + +describe('windowStats', () => { + const start = '2026-09-01'; + const end = '2026-09-10'; + const from = dayStart(start) as number; + const to = (dayStart(end) as number) + DAY; + const span = (over: Partial): PullSpan => ({ + author: 'alice', + project: null, + opened: from - 5 * DAY, + closed: null, + merged: false, + ...over, + }); + const spans = [ + // open before, still open: backlog at both ends + span({ project: 'workbench' }), + // open before, merged inside + span({ project: 'workbench', closed: from + 2 * DAY, merged: true }), + // opened inside, closed without merging inside + span({ author: 'bob', opened: from + DAY, closed: from + 3 * DAY }), + // opened inside, still open + span({ author: 'bob', project: 'misc', opened: from + 9 * DAY + 60 }), + // entirely before the window: ignored + span({ opened: from - 20 * DAY, closed: from - 10 * DAY, merged: true }), + // opened after the window: ignored + span({ opened: to + DAY }), + ]; + const w = windowStats(spans, start, end); + + it('counts backlog and throughput with the identity holding in every bucket', () => { + expect(w.totals).toMatchObject({ + backlog_start: 2, + backlog_end: 2, + opened: 2, + merged: 1, + closed: 1, + }); + const buckets = [w.totals, w.unsorted, ...Object.values(w.projects), ...Object.values(w.people)]; + for (const c of buckets) + expect(c.backlog_start + c.opened - c.merged - c.closed).toBe(c.backlog_end); + }); + + it('splits by project and person, leaving misc out of a person’s projects', () => { + expect(w.projects.workbench).toMatchObject({ backlog_start: 2, backlog_end: 1, merged: 1 }); + expect(w.projects.misc).toMatchObject({ opened: 1, backlog_end: 1 }); + expect(w.unsorted).toMatchObject({ opened: 1, closed: 1, backlog_end: 0 }); + expect(w.people.alice.projects).toEqual(['workbench']); + expect(w.people.bob.projects).toEqual([]); + }); + + it('gives the median age at each end in days', () => { + // at the start, both open PRs are 5 days old + expect(w.totals.median_age_start_days).toBe(5); + // at the end: one is 15 days old, the other a minute short of 1 day + expect(w.totals.median_age_end_days).toBe(8); + }); + + it('draws one cumulative point per day that ends on the closing backlog', () => { + expect(w.days).toHaveLength(10); + expect(w.days[0]).toEqual({ date: '2026-09-01', arrived: 2, departed: 0, backlog: 2 }); + expect(w.days[1]).toMatchObject({ arrived: 3, departed: 0 }); + expect(w.days[3]).toMatchObject({ arrived: 3, departed: 2, backlog: 1 }); + expect(w.days[9]).toMatchObject({ arrived: 4, departed: 2, backlog: w.totals.backlog_end }); + }); +}); diff --git a/shared/index.ts b/shared/index.ts index cce7bde5..64b62594 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -34,5 +34,20 @@ export type { CheckLedger } from './model/ci'; export { isBotLogin } from './model/visibility'; +export { + buildToday, + dayStart, + projectOf, + projectSlugs, + utcDay, + windowStats, + DEFAULT_PROJECT_PREFIX, + LIVE_DAYS, + MISC_SLUG, + ONGOING_LABEL, + PARENT_PREFIX, +} from './model/projects'; +export type { Project, PullSpan, Today, WindowStats } from './model/projects'; + export { epoch } from './format'; export type { PullData, RepoSpec, Signature, CommitStatus, Label } from './types'; diff --git a/shared/model/projects.ts b/shared/model/projects.ts new file mode 100644 index 00000000..fe43aad3 --- /dev/null +++ b/shared/model/projects.ts @@ -0,0 +1,377 @@ +import { epoch } from '../format'; +import type { Label, PullData } from '../types'; +import type { DerivedPull, Status } from './status'; + +/** + * Projects: what the open PRs add up to. A PR joins a project through one + * label, `` (`project:workbench`), and a project's own record + * is a GitHub issue carrying the same label: its title is the name, its + * assignee the lead, its milestone the rough target, `parent:` labels + * its parents (any number of them), and an `ongoing` label marks work with no + * end. Open means live, closed as completed means done, closed as not planned + * means dropped or merged into another project. A job outside Pulldasher + * writes all of it; Pulldasher only reads what GitHub already has. + * + * Pure like the rest of shared/: the board runs these over the live socket + * pulls, and the server runs the same functions behind /projects-data and + * /api/v1/projects + /api/v1/people, so the tab and the API can't disagree. + */ + +export const DEFAULT_PROJECT_PREFIX = 'project:'; +export const PARENT_PREFIX = 'parent:'; +export const ONGOING_LABEL = 'ongoing'; +/** the slug for one-off PRs that fit no project */ +export const MISC_SLUG = 'misc'; +/** a merge this recent keeps a project on Today */ +export const LIVE_DAYS = 14; +/** fewest PRs before "one person" is worth saying; below it, one author is normal */ +export const ONE_PERSON_MIN_PRS = 3; +/** other live projects a lead can be on before the lead reads as spread thin */ +export const LEAD_SPREAD_MIN = 3; + +const DAY = 86400; + +/** A project as its issue describes it: the wire shape /projects-data sends. */ +export interface Project { + slug: string; + name: string; + /** where the project's issue lives */ + repo: string; + number: number; + state: 'open' | 'closed'; + /** GitHub's close reason: 'completed' is done, 'not_planned' dropped or merged */ + state_reason: string | null; + ongoing: boolean; + parents: string[]; + lead: string | null; + target: { title: string; due_on: string | null } | null; +} + +/** Every project slug a PR's labels name, sorted so every reader agrees. */ +export function projectSlugs(labels: readonly Pick[], prefix: string): string[] { + return labels + .filter(l => l.title.startsWith(prefix) && l.title.length > prefix.length) + .map(l => l.title.slice(prefix.length)) + .sort(); +} + +/** A PR's project: a real project before misc when it carries both, else + * the first label alphabetically; null with no project label at all. */ +export function projectOf(labels: readonly Pick[], prefix: string): string | null { + const slugs = projectSlugs(labels, prefix); + return slugs.find(s => s !== MISC_SLUG) ?? slugs[0] ?? null; +} + +export type ProjectFlag = 'one_person' | 'waiting_on_review' | 'lead_spread' | 'issue_closed'; + +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: DerivedPull[]; + /** merged in the last LIVE_DAYS, newest first */ + merged: PullData[]; + /** everyone with an open or recently merged PR here, most PRs first */ + people: string[]; + /** days since the stalest open PR last changed; null with nothing open */ + idleDays: number | null; + /** epoch secs of the newest PR update or merge; null with neither */ + lastActivity: number | null; + flags: ProjectFlag[]; +} + +export interface Today { + /** an open PR, or a merge in the last LIVE_DAYS on a project that isn't closed */ + live: ProjectGroup[]; + /** open projects with nothing in flight */ + quiet: ProjectGroup[]; + /** open PRs filed as one-offs */ + misc: DerivedPull[]; + /** open PRs with no project label yet */ + unsorted: DerivedPull[]; + /** open PRs carrying more than one project label */ + doubleLabeled: DerivedPull[]; +} + +/** The name to show: the issue's title, or the bare slug before an issue exists. */ +export function projectName(g: Pick): string { + return g.project?.name ?? g.slug; +} + +const WAITING_ON_REVIEW: Status[] = ['needs_cr', 'needs_recr', 'needs_qa']; + +/** + * Today: every project with work in flight, from the open PRs, the recently + * merged ones (the server keeps LIVE_DAYS of them in memory), and the + * project issues. Pass people's PRs only; bots are the caller's call. + */ +export function buildToday( + projects: readonly Project[], + open: readonly DerivedPull[], + closed: readonly PullData[], + prefix: string, + now: number = Date.now() / 1000 +): Today { + const bySlug = new Map(projects.map(p => [p.slug, p])); + const groups = new Map(); + const group = (slug: string) => { + let g = groups.get(slug); + if (!g) { + g = { + slug, + project: bySlug.get(slug) ?? null, + open: [], + merged: [], + people: [], + idleDays: null, + lastActivity: null, + flags: [], + }; + groups.set(slug, g); + } + return g; + }; + + const today: Today = { live: [], quiet: [], misc: [], unsorted: [], doubleLabeled: [] }; + for (const p of open) { + if (projectSlugs(p.data.labels, prefix).length > 1) today.doubleLabeled.push(p); + const slug = projectOf(p.data.labels, prefix); + if (slug == null) today.unsorted.push(p); + else if (slug === MISC_SLUG) today.misc.push(p); + else group(slug).open.push(p); + } + for (const p of closed) { + if (!p.merged_at || now - epoch(p.merged_at) > LIVE_DAYS * DAY) continue; + const slug = projectOf(p.labels, prefix); + if (slug != null && slug !== MISC_SLUG) group(slug).merged.push(p); + } + // an open project with nothing in flight still exists: it lands in quiet + for (const p of projects) if (p.state === 'open') group(p.slug); + + for (const g of groups.values()) { + g.open.sort((a, b) => epoch(a.data.created_at) - epoch(b.data.created_at)); + g.merged.sort((a, b) => epoch(b.merged_at ?? '') - epoch(a.merged_at ?? '')); + const prs = new Map(); + for (const login of [ + ...g.open.map(p => p.data.user.login), + ...g.merged.map(p => p.user.login), + ]) + prs.set(login, (prs.get(login) ?? 0) + 1); + g.people = [...prs.keys()].sort( + (a, b) => (prs.get(b) ?? 0) - (prs.get(a) ?? 0) || a.localeCompare(b) + ); + const updates = g.open.map(p => epoch(p.data.updated_at)); + g.idleDays = updates.length ? Math.floor((now - Math.min(...updates)) / DAY) : null; + const activity = [...updates, ...g.merged.map(p => epoch(p.merged_at ?? ''))]; + g.lastActivity = activity.length ? Math.max(...activity) : null; + } + + // a closed project only stays on Today while a PR is still open in it; + // its recent merges alone are a finished project's receipt + const isLive = (g: ProjectGroup) => + 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'); + + // how many live projects each person has work in, for the lead check + const liveCount = new Map(); + for (const g of today.live) + for (const login of g.people) liveCount.set(login, (liveCount.get(login) ?? 0) + 1); + for (const g of today.live) { + if (g.people.length === 1 && g.open.length + g.merged.length >= ONE_PERSON_MIN_PRS) + g.flags.push('one_person'); + if (g.open.length >= 2 && g.open.every(p => WAITING_ON_REVIEW.includes(p.status))) + g.flags.push('waiting_on_review'); + const lead = g.project?.lead; + if (lead) { + const elsewhere = (liveCount.get(lead) ?? 0) - (g.people.includes(lead) ? 1 : 0); + if (elsewhere >= LEAD_SPREAD_MIN) g.flags.push('lead_spread'); + } + if (g.project?.state === 'closed') g.flags.push('issue_closed'); + } + + const byName = (a: ProjectGroup, b: ProjectGroup) => projectName(a).localeCompare(projectName(b)); + const byRecent = (a: ProjectGroup, b: ProjectGroup) => + (b.lastActivity ?? 0) - (a.lastActivity ?? 0); + today.live.sort((a, b) => b.open.length - a.open.length || byRecent(a, b) || byName(a, b)); + today.quiet.sort((a, b) => byRecent(a, b) || byName(a, b)); + return today; +} + +/** One PR's lifetime, the unit the window counts are built from (epoch secs). */ +export interface PullSpan { + author: string; + /** its project (misc included); null with no project label */ + project: string | null; + opened: number; + /** when it merged or closed; null while open */ + closed: number | null; + merged: boolean; +} + +export interface WindowCounts { + /** open at the start of the first day */ + backlog_start: number; + /** open at the end of the last day */ + backlog_end: number; + opened: number; + merged: number; + /** closed without merging */ + closed: number; + /** median age in days of the PRs open at each end; null with none open */ + median_age_start_days: number | null; + median_age_end_days: number | null; +} + +export interface PersonWindow extends WindowCounts { + /** projects this person had a PR in during the window, misc left out */ + projects: string[]; +} + +export interface DayPoint { + /** a UTC day, YYYY-MM-DD */ + date: string; + /** the backlog at the start plus everything opened through this day */ + arrived: number; + /** everything merged or closed through this day */ + departed: number; + /** open at the end of this day: arrived minus departed */ + backlog: number; +} + +export interface WindowStats { + /** first and last UTC day of the window, both counted */ + start: string; + end: string; + totals: WindowCounts; + /** by project slug, misc included */ + projects: Record; + /** PRs with no project label */ + unsorted: WindowCounts; + people: Record; + /** one point per day, for the backlog chart */ + days: DayPoint[]; +} + +/** Epoch secs at the start of a YYYY-MM-DD UTC day, or null if it isn't one. */ +export function dayStart(date: string): number | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; + const ms = Date.parse(`${date}T00:00:00Z`); + // Date.parse rolls impossible days over (Feb 30 is March 2), so compare back + return Number.isNaN(ms) || new Date(ms).toISOString().slice(0, 10) !== date ? null : ms / 1000; +} + +/** The YYYY-MM-DD UTC day an epoch-secs instant falls on. */ +export function utcDay(epochSecs: number): string { + return new Date(epochSecs * 1000).toISOString().slice(0, 10); +} + +interface Tally { + counts: WindowCounts; + agesStart: number[]; + agesEnd: number[]; +} + +const tally = (): Tally => ({ + counts: { + backlog_start: 0, + backlog_end: 0, + opened: 0, + merged: 0, + closed: 0, + median_age_start_days: null, + median_age_end_days: null, + }, + agesStart: [], + agesEnd: [], +}); + +function median(xs: number[]): number | null { + if (!xs.length) return null; + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + const m = s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; + return Math.round(m * 10) / 10; +} + +const finish = ({ counts, agesStart, agesEnd }: Tally): WindowCounts => ({ + ...counts, + median_age_start_days: median(agesStart), + median_age_end_days: median(agesEnd), +}); + +/** + * Backlog and throughput for the UTC days `start` through `end`, overall, per + * project and per person. Every bucket satisfies backlog_start + opened - + * merged - closed = backlog_end, the check the history rebuild used. A PR is + * counted under its current project label, which is exact unless it moved + * between projects. Pass people's PRs only, and a valid start <= end. + */ +export function windowStats(spans: readonly PullSpan[], start: string, end: string): WindowStats { + const from = dayStart(start) ?? 0; + const to = (dayStart(end) ?? from) + DAY; + const dayCount = Math.max(1, Math.round((to - from) / DAY)); + const totals = tally(); + const unsorted = tally(); + const projects = new Map(); + const people = new Map }>(); + const openedOn = new Array(dayCount).fill(0); + const departedOn = new Array(dayCount).fill(0); + + for (const s of spans) { + // skip PRs that never overlapped the window + if (s.opened >= to || (s.closed != null && s.closed < from)) continue; + const openAt = (t: number) => s.opened < t && (s.closed == null || s.closed >= t); + const openedIn = s.opened >= from; + const departedIn = s.closed != null && s.closed < to; + let byProject = s.project == null ? unsorted : projects.get(s.project); + if (!byProject) { + byProject = tally(); + projects.set(s.project as string, byProject); + } + let person = people.get(s.author); + if (!person) { + person = { t: tally(), projects: new Set() }; + people.set(s.author, person); + } + if (s.project != null && s.project !== MISC_SLUG) person.projects.add(s.project); + for (const t of [totals, byProject, person.t]) { + if (openAt(from)) { + t.counts.backlog_start++; + t.agesStart.push((from - s.opened) / DAY); + } + if (openAt(to)) { + t.counts.backlog_end++; + t.agesEnd.push((to - s.opened) / DAY); + } + if (openedIn) t.counts.opened++; + if (departedIn) { + if (s.merged) t.counts.merged++; + else t.counts.closed++; + } + } + if (openedIn) openedOn[Math.floor((s.opened - from) / DAY)]++; + if (departedIn) departedOn[Math.floor(((s.closed as number) - from) / DAY)]++; + } + + let arrived = totals.counts.backlog_start; + let departed = 0; + const days = openedOn.map((opened, i) => { + arrived += opened; + departed += departedOn[i]; + return { date: utcDay(from + i * DAY), arrived, departed, backlog: arrived - departed }; + }); + + return { + start, + end, + totals: finish(totals), + projects: Object.fromEntries([...projects].map(([slug, t]) => [slug, finish(t)])), + unsorted: finish(unsorted), + people: Object.fromEntries( + [...people].map(([login, p]) => [login, { ...finish(p.t), projects: [...p.projects].sort() }]) + ), + days, + }; +} From 51a635f864cb1180c1091ca330b35d1a7e0e49ff Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 29 Sep 2026 16:33:21 -0700 Subject: [PATCH 002/116] Repair missed webhooks every hour A PR whose close webhook gets lost stays open on the board until the next restart, because #501 repairs it only at startup. Every backlog number counts those PRs: on 2026-09-29 the board listed 314 open PRs, and 67 of them were already merged or closed on GitHub. Now the server lists each tracked repo's open pulls once an hour and refreshes only the ones the DB has wrong: open on GitHub but not open in the DB (a lost `opened` or `reopened`), and open in the DB but missing from the listing (a lost `closed`, repaired by #501's own refreshStaleOpenPulls). The listing costs one API call per 30 open pulls in each repo, and an hour with nothing wrong refreshes nothing. This is option 1 from #501's list, which went with startup only. The startup refresh still refetches every open pull; this doesn't. Co-Authored-By: Claude Opus 5.5 --- app.js | 11 ++++++++ lib/refresh.js | 53 +++++++++++++++++++++++++++++++++++ test/stale-open-pulls.test.js | 10 ++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index 42bf37c7..beb745fd 100644 --- a/app.js +++ b/app.js @@ -99,6 +99,17 @@ dbManager }) .done(); +// Webhooks get lost, and a lost `closed` left a PR open on the board until the +// next restart (pulldasher#501 repairs it only at startup), which inflates every +// backlog number. Once an hour, list each repo's open pulls and refresh just the +// ones the DB has wrong. +const RECONCILE_MS = 60 * 60 * 1000; +setInterval(function () { + refresh.reconcileOpenPulls().catch(function (err) { + console.error('Hourly open-pull repair failed: %s', (err && err.message) || err); + }); +}, RECONCILE_MS); + //==================================================== // Socket.IO const io = new Server(httpServer); diff --git a/lib/refresh.js b/lib/refresh.js index e09fec5e..fbd78677 100644 --- a/lib/refresh.js +++ b/lib/refresh.js @@ -133,6 +133,49 @@ export function createRefresh({ pacer = noopPacer } = {}) { ) ); }, + + /** + * The cheap repair for lost webhooks: list each repo's open pulls + * (about one API call per repo, per 30 pulls) and refresh only the ones + * the DB has wrong. Open on GitHub but not in the DB means a lost + * `opened` or `reopened`; open in the DB but gone from the listing means + * a lost `closed`. openPulls repairs the same things but refetches every + * open pull on the way, which costs thousands of calls, so it runs only + * at startup. + */ + reconcileOpenPulls: async function reconcileOpenPulls(repos) { + refreshDebug('reconcile open pulls'); + const listedRepos = []; + const listing = await utils.forEachRepo( + repo => + gitManager.getOpenPulls(repo, pacer).then(pulls => { + listedRepos.push(repo); + return pulls; + }), + { repos: repos } + ); + let missing = []; + try { + missing = findMissingOpenPulls(await dbManager.getOpenPullIds(), listing.items); + } catch (err) { + console.error('Failed to load open pulls from the DB: %s', (err && err.message) || err); + } + refreshDebug('refreshing %s pulls open on GitHub but not in the DB', missing.length); + const report = await drainThrough(pullQueue)({ + items: missing, + failedRepos: listing.failedRepos, + }); + const staleFailures = await refreshStaleOpenPulls( + pullQueue, + pacer, + listing.items, + listedRepos + ); + return { + failedRepos: report.failedRepos, + failedItems: report.failedItems.concat(staleFailures), + }; + }, }; } @@ -335,6 +378,16 @@ export function findStaleOpenPulls(dbOpenPulls, listedPulls, listedRepos) { ); } +/** + * The listed open pulls the DB doesn't hold as open: new or reopened pulls + * whose webhook never arrived. Repo names compare case-insensitively. + */ +export function findMissingOpenPulls(dbOpenPulls, listedPulls) { + const key = (repo, number) => repo.toLowerCase() + '#' + number; + const known = new Set(dbOpenPulls.map(pull => key(pull.repo, pull.number))); + return listedPulls.filter(pull => !known.has(key(pull.base.repo.full_name, pull.number))); +} + /** * A closed pull never appears in the open-pulls listing, so one whose close webhook * was lost stays open in the DB until refetched. Never rejects: app.js ignores it. diff --git a/test/stale-open-pulls.test.js b/test/stale-open-pulls.test.js index fea759a6..f3876120 100644 --- a/test/stale-open-pulls.test.js +++ b/test/stale-open-pulls.test.js @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import gitManager from "../lib/git-manager.js"; import dbManager from "../lib/db-manager.js"; -import { createRefresh, findStaleOpenPulls } from "../lib/refresh.js"; +import { createRefresh, findMissingOpenPulls, findStaleOpenPulls } from "../lib/refresh.js"; const githubPull = (repo, number) => ({ number, base: { repo: { full_name: repo } } }); @@ -26,6 +26,14 @@ test("findStaleOpenPulls keeps DB-open pulls missing from a successful listing", ]); }); +test("findMissingOpenPulls keeps listed pulls the DB doesn't hold as open", () => { + const listed = [githubPull("Test/Repo-A", 1), githubPull("test/repo-a", 2)]; + + const missing = findMissingOpenPulls([{ repo: "test/repo-a", number: 1 }], listed); + + assert.deepEqual(missing, [githubPull("test/repo-a", 2)]); +}); + // A pull whose close webhook was lost stays open in the DB. openPulls refetches it, // skipping repos whose listing failed and reporting a refetch that fails. test("openPulls refetches pulls the DB holds as open that GitHub no longer lists", async (t) => { From d57c1e80406fd6c0f7e5c96d632587f08e3ad8c2 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 29 Sep 2026 16:33:33 -0700 Subject: [PATCH 003/116] Serve project issues and backlog stats by window The Projects tab and a Claude skill need the project issues and PR history, and the socket sends neither. Three read-only endpoints, each taking start and end as YYYY-MM-DD UTC days (default: the last 30 days; at most 400): 1) GET /projects-data, session-authed like /stats-history: every project issue plus the window's numbers. The tab builds Today itself from the live socket pulls, so it moves with the board. 2) GET /api/v1/projects, Bearer-authed like /api/v1/pulls: each project's issue fields, where it stands today (open PR ids, people, idle days, flags), and its numbers for the window, plus the totals and one point per day for the backlog chart. 3) GET /api/v1/people: per person, the window's numbers, the projects they had PRs in, the live projects they're on, and PRs open now. Most live projects first, which is how you spot someone spread thin. No schema change. Project issues come from the issues table and their labels from pull_labels, where updateAllIssueData already writes the labels of issues. The server syncs the projects repo's issues at startup, then once an hour asks GitHub only for the ones updated since the last clean sync. It all stays off unless config.js has a `projects` block, and the board shows its tab only when the initialize payload has projectLabelPrefix. Co-Authored-By: Claude Opus 5.5 --- app.js | 29 +++- config.example.js | 17 +++ controllers/projects.js | 222 ++++++++++++++++++++++++++++++ frontend-v2/vite.config.ts | 1 + lib/authentication.js | 1 + lib/git-manager.js | 7 +- lib/projects.js | 183 +++++++++++++++++++++++++ lib/pull-manager.js | 4 + lib/refresh.js | 15 ++ lib/review-model.js | 2 +- shared/types.ts | 4 + test/projects.test.js | 271 +++++++++++++++++++++++++++++++++++++ 12 files changed, 751 insertions(+), 5 deletions(-) create mode 100644 controllers/projects.js create mode 100644 lib/projects.js create mode 100644 test/projects.test.js diff --git a/app.js b/app.js index beb745fd..40663e1d 100644 --- a/app.js +++ b/app.js @@ -14,6 +14,8 @@ import hooksController from './controllers/githubHooks.js'; import statsController from './controllers/stats.js'; import userNamesController from './controllers/user-names.js'; import apiController from './controllers/api.js'; +import projectsController from './controllers/projects.js'; +import { projectSettings } from './lib/projects.js'; import apiAuth from './lib/api-auth.js'; import Debug from './lib/debug.js'; import { createServer } from 'http'; @@ -67,6 +69,7 @@ app.use('/', express.static(__dirname + '/frontend-v2/dist')); app.get('/token', mainController.getToken); app.get('/stats-history', statsController.getHistory); app.get('/user-names', userNamesController.getNames); +app.get('/projects-data', projectsController.getBoardData); app.post('/hooks/main', hooksController.main); // /api/v1: machine-to-machine JSON for the review skills, Bearer-authed with @@ -75,6 +78,8 @@ app.post('/hooks/main', hooksController.main); // session `auth` middleware doesn't run for them. app.get('/api/v1/me', apiAuth, apiController.getMe); app.get('/api/v1/pulls', apiAuth, apiController.getPulls); +app.get('/api/v1/projects', apiAuth, projectsController.getProjects); +app.get('/api/v1/people', apiAuth, projectsController.getPeople); // Warm the bot-login cache (used to tell a pulldasher claim apart from a // GitHub-UI self-request) before any webhook or socket traffic needs it. @@ -96,20 +101,42 @@ dbManager .then(function () { debug('Refreshing all open pulls from the API'); refresh.openPulls(); + syncProjectIssues(); }) .done(); // Webhooks get lost, and a lost `closed` left a PR open on the board until the // next restart (pulldasher#501 repairs it only at startup), which inflates every // backlog number. Once an hour, list each repo's open pulls and refresh just the -// ones the DB has wrong. +// ones the DB has wrong, and pick up project issues that changed. const RECONCILE_MS = 60 * 60 * 1000; setInterval(function () { refresh.reconcileOpenPulls().catch(function (err) { console.error('Hourly open-pull repair failed: %s', (err && err.message) || err); }); + syncProjectIssues(); }, RECONCILE_MS); +// The project issues' repo is synced whole at startup, then only what changed. +// `since` backs off a few minutes so GitHub's clock can't skip an update. +let projectIssuesSyncedAt = null; +function syncProjectIssues() { + const projects = projectSettings(); + if (!projects || !projects.repo) return; + const startedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + refresh + .issuesChangedSince(projects.repo, projectIssuesSyncedAt) + .then(function (report) { + // move the marker only past a clean run, so a failed issue is retried + if (!report.failedRepos.length && !report.failedItems.length) { + projectIssuesSyncedAt = startedAt; + } + }) + .catch(function (err) { + console.error('Project issue sync failed: %s', (err && err.message) || err); + }); +} + //==================================================== // Socket.IO const io = new Server(httpServer); diff --git a/config.example.js b/config.example.js index 48e5ba09..ad6a4535 100644 --- a/config.example.js +++ b/config.example.js @@ -110,6 +110,23 @@ module.exports = { 'size: XL': 'XL', }, + // Projects (optional; leave it out and the Projects tab stays hidden). A PR + // joins a project through one label, ``, for example + // `project:workbench`. Each project is an issue in `repo` carrying that same + // label: the title is its name, the assignee its lead, the milestone and its + // due date the rough target, `parent:` labels its parents (any number), + // and an `ongoing` label marks work with no end. Closing the issue as + // completed means done; as not planned, dropped or merged into another. + // Pulldasher only reads these; something else writes them. Give `repo` the + // same webhook as a tracked repo (Issues events). Without `repo`, PRs still + // group by label, just without names, leads or targets. Keep label names to + // 32 characters with no spaces: that's what the label table and the filter + // box hold. + projects: { + repo: 'owner/projects', + labelPrefix: 'project:', + }, + // The usual MySQL stuff. Like every other MySQL webapp, basically. // You will need to source the `schema.sql` file in the database to create // all the tables that Pulldasher expects. diff --git a/controllers/projects.js b/controllers/projects.js new file mode 100644 index 00000000..22ea2999 --- /dev/null +++ b/controllers/projects.js @@ -0,0 +1,222 @@ +import pullManager from '../lib/pull-manager.js'; +import { respondOrError } from '../lib/controller-utils.js'; +import { + loadProjects, + loadSpans, + parseWindow, + projectSettings, + todayFromBoard, +} from '../lib/projects.js'; +import { windowStats, MISC_SLUG } from '../shared/dist/index.js'; + +const key = d => `${d.repo}#${d.number}`; + +/** + * Validate the projects config and the requested window, then load the + * projects and the window's PR history. Answers the request itself (404 when + * projects aren't set up here, 400 for a bad window) and returns null, or + * returns a promise of what the handler needs. + */ +function load(req, res) { + const settings = projectSettings(); + if (!settings) { + res.status(404).json({ error: 'projects are not set up on this Pulldasher' }); + return null; + } + const window = parseWindow(req.query); + if (window.error) { + res.status(400).json({ error: window.error }); + return null; + } + return Promise.all([ + loadProjects(settings), + loadSpans(settings, window.start, window.end), + ]).then(([projects, spans]) => ({ + settings, + projects, + stats: windowStats(spans, window.start, window.end), + })); +} + +/** + * One record per project for the API: what its issue says, where it stands + * today, and its numbers for the window. Live projects first in Today's order, + * then quiet ones, then the rest (done, dropped, or only in the window's + * history) by slug. + */ +function projectRecords(projects, today, stats, prefix) { + const bySlug = new Map(projects.map(p => [p.slug, p])); + const groups = new Map(); + const standing = new Map(); + for (const [list, word] of [ + [today.live, 'live'], + [today.quiet, 'quiet'], + ]) { + for (const g of list) { + groups.set(g.slug, g); + standing.set(g.slug, word); + } + } + const rest = [...new Set([...bySlug.keys(), ...Object.keys(stats.projects)])] + .filter(slug => slug !== MISC_SLUG && !groups.has(slug)) + .sort(); + return [...groups.keys(), ...rest].map(slug => { + const p = bySlug.get(slug) || null; + const g = groups.get(slug) || null; + const closedAs = + p && p.state === 'closed' ? (p.state_reason === 'not_planned' ? 'dropped' : 'done') : null; + return { + slug, + name: p ? p.name : slug, + label: prefix + slug, + issue: p + ? { + repo: p.repo, + number: p.number, + url: `https://github.com/${p.repo}/issues/${p.number}`, + state: p.state, + state_reason: p.state_reason, + } + : null, + ongoing: p ? p.ongoing : false, + parents: p ? p.parents : [], + lead: p ? p.lead : null, + target: p ? p.target : null, + // live and quiet come from Today; a closed issue with no open PR is + // done or dropped; a slug seen only in the window's history is null + standing: standing.get(slug) || closedAs, + open: g ? g.open.map(d => key(d.data)) : [], + merged_recently: g ? g.merged.map(key) : [], + people: g ? g.people : [], + idle_days: g ? g.idleDays : null, + flags: g ? g.flags : [], + window: stats.projects[slug] || null, + }; + }); +} + +export default { + /** + * GET /projects-data?start=&end= -- the Projects tab's fetch: every project + * issue plus the window's numbers. The tab builds Today itself from the live + * socket pulls, so it moves with the board. Session-authed like + * /stats-history. + */ + getBoardData: function (req, res) { + const loaded = load(req, res); + if (!loaded) return; + respondOrError( + res, + loaded.then(({ settings, projects, stats }) => ({ + label_prefix: settings.prefix, + projects_repo: settings.repo, + projects, + window: stats, + })), + 'projects-data query failed' + ); + }, + + /** + * GET /api/v1/projects?start=&end= -- every project with its issue fields, + * where it stands today (open PR ids, people, idle days, flags), and its + * numbers for the window (default: the last 30 days), plus the totals and + * one point per day for the backlog chart. Bearer-authed (lib/api-auth). + * Dates are YYYY-MM-DD UTC days, both counted. + */ + getProjects: function (req, res) { + const loaded = load(req, res); + if (!loaded) return; + respondOrError( + res, + loaded.then(({ settings, projects, stats }) => { + const now = Date.now() / 1000; + const today = todayFromBoard(pullManager.getPulls(), projects, settings.prefix, now); + return { + server_time: Math.floor(now), + start: stats.start, + end: stats.end, + label_prefix: settings.prefix, + projects: projectRecords(projects, today, stats, settings.prefix), + misc: { + open: today.misc.map(d => key(d.data)), + window: stats.projects[MISC_SLUG] || null, + }, + unsorted: { open: today.unsorted.map(d => key(d.data)), window: stats.unsorted }, + double_labeled: today.doubleLabeled.map(d => key(d.data)), + totals: stats.totals, + days: stats.days, + }; + }), + 'projects query failed' + ); + }, + + /** + * GET /api/v1/people?start=&end= -- per person: the window's numbers, the + * projects they had PRs in during it, the live projects they're on today, + * and how many PRs they have open now. Most live projects first, which is + * how a reader spots someone spread thin. Bots are left out. + */ + getPeople: function (req, res) { + const loaded = load(req, res); + if (!loaded) return; + respondOrError( + res, + loaded.then(({ settings, projects, stats }) => { + const now = Date.now() / 1000; + const today = todayFromBoard(pullManager.getPulls(), projects, settings.prefix, now); + const live = new Map(); + for (const g of today.live) { + for (const login of g.people) { + if (!live.has(login)) live.set(login, []); + live.get(login).push(g.slug); + } + } + const openNow = new Map(); + for (const d of [ + ...today.live.flatMap(g => g.open), + ...today.misc, + ...today.unsorted, + ]) { + const login = d.data.user.login; + openNow.set(login, (openNow.get(login) || 0) + 1); + } + const logins = new Set([...Object.keys(stats.people), ...live.keys()]); + const people = [...logins].map(login => { + const w = stats.people[login]; + return { + login, + window: w + ? { + backlog_start: w.backlog_start, + backlog_end: w.backlog_end, + opened: w.opened, + merged: w.merged, + closed: w.closed, + median_age_start_days: w.median_age_start_days, + median_age_end_days: w.median_age_end_days, + } + : null, + projects_in_window: w ? w.projects : [], + live_projects: (live.get(login) || []).sort(), + open_now: openNow.get(login) || 0, + }; + }); + people.sort( + (a, b) => + b.live_projects.length - a.live_projects.length || + b.open_now - a.open_now || + a.login.localeCompare(b.login) + ); + return { + server_time: Math.floor(now), + start: stats.start, + end: stats.end, + people, + }; + }), + 'people query failed' + ); + }, +}; diff --git a/frontend-v2/vite.config.ts b/frontend-v2/vite.config.ts index c47161c3..f6056a96 100644 --- a/frontend-v2/vite.config.ts +++ b/frontend-v2/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ '/socket.io': { target: 'http://localhost:3000', ws: true }, '/stats-history': 'http://localhost:3000', '/user-names': 'http://localhost:3000', + '/projects-data': 'http://localhost:3000', }, }, }); diff --git a/lib/authentication.js b/lib/authentication.js index 87c66c94..79e93321 100644 --- a/lib/authentication.js +++ b/lib/authentication.js @@ -131,5 +131,6 @@ export default { // registration order), so the query never runs for an unauthenticated request. app.get('/stats-history', auth); app.get('/user-names', auth); + app.get('/projects-data', auth); }, }; diff --git a/lib/git-manager.js b/lib/git-manager.js index 722d9b11..5526cd59 100644 --- a/lib/git-manager.js +++ b/lib/git-manager.js @@ -266,12 +266,13 @@ export default { }, /** - * Get *all* issues for a repo. + * Get *all* issues for a repo, or only those updated at or after `since` + * (an ISO timestamp) when one is given. * * Returns a promise which resolves to an array of all issues */ - getAllIssues: function (repo, pacer = noopPacer) { - const searchParams = params({ state: 'all' }, repo); + getAllIssues: function (repo, pacer = noopPacer, since = null) { + const searchParams = params(since ? { state: 'all', since } : { state: 'all' }, repo); return logErrors( pacedPaginate(githubRest.issues.listForRepo, searchParams, pacer) .then(filterOutPulls) diff --git a/lib/projects.js b/lib/projects.js new file mode 100644 index 00000000..2016b7d2 --- /dev/null +++ b/lib/projects.js @@ -0,0 +1,183 @@ +import config from './config-loader.js'; +import db from './db.js'; +import { deriveAll, isBot, toWire } from './review-model.js'; +import { + buildToday, + dayStart, + projectOf, + projectSlugs, + utcDay, + DEFAULT_PROJECT_PREFIX, + ONGOING_LABEL, + PARENT_PREFIX, +} from '../shared/dist/index.js'; + +/** + * The server half of Projects (the model itself is shared/model/projects.ts): + * reads project issues and PR history out of the tables Pulldasher already + * keeps, and builds Today from the live board. Nothing here writes to GitHub; + * the job that labels PRs and opens project issues runs outside Pulldasher. + */ + +const DAY = 86400; +/** the longest window a request may ask for, which bounds the history scan */ +export const MAX_WINDOW_DAYS = 400; +/** the window when a request names no start: 30 days ending on `end` */ +export const DEFAULT_WINDOW_DAYS = 30; + +/** + * This install's projects config, or null when it doesn't use projects (the + * board then hides the tab). `repo` may be null too: PRs still group by + * their labels, just without the names, leads and targets an issue gives. + */ +export function projectSettings() { + if (!config.projects) return null; + return { + repo: config.projects.repo || null, + prefix: config.projects.labelPrefix || DEFAULT_PROJECT_PREFIX, + }; +} + +/** + * Every project issue, open and closed, from the issues table plus the labels + * Pulldasher stores for issues in pull_labels (issue and PR numbers share one + * space per repo, so the two can't collide). + */ +export async function loadProjects(settings) { + if (!settings || !settings.repo) return []; + const [issues, labels] = await Promise.all([ + db.query('SELECT * FROM issues WHERE repo = ?', [settings.repo]), + db.query('SELECT number, title FROM pull_labels WHERE repo = ?', [settings.repo]), + ]); + return projectsFromRows(issues, labels, settings.prefix); +} + +/** + * Issue rows + label rows -> Projects. An issue without a project label isn't + * a project and is skipped. Two issues carrying one label shouldn't happen; + * if it does, the open one wins, then the newer. + */ +export function projectsFromRows(issues, labelRows, prefix) { + const titles = new Map(); + for (const { number, title } of labelRows) { + if (!titles.has(number)) titles.set(number, []); + titles.get(number).push(title); + } + const bySlug = new Map(); + for (const issue of issues) { + const names = titles.get(issue.number) || []; + const slug = projectSlugs(names.map(title => ({ title })), prefix)[0]; + if (!slug) continue; + const project = { + slug, + name: issue.title || slug, + repo: issue.repo, + number: issue.number, + state: issue.status === 'closed' ? 'closed' : 'open', + state_reason: issue.state_reason || null, + ongoing: names.includes(ONGOING_LABEL), + parents: names + .filter(n => n.startsWith(PARENT_PREFIX) && n.length > PARENT_PREFIX.length) + .map(n => n.slice(PARENT_PREFIX.length)) + .sort(), + lead: issue.assignee || null, + target: issue.milestone_title + ? { + title: issue.milestone_title, + due_on: issue.milestone_due_on + ? new Date(issue.milestone_due_on * 1000).toISOString() + : null, + } + : null, + }; + const prev = bySlug.get(slug); + const openFirst = prev && (project.state === 'open') - (prev.state === 'open'); + if (!prev || openFirst > 0 || (openFirst === 0 && project.number > prev.number)) { + bySlug.set(slug, project); + } + } + return [...bySlug.values()]; +} + +/** + * People's PRs in the tracked repos whose lifetime overlaps the UTC days + * start..end, as PullSpans. Two plain queries (the pulls, then every project + * label) joined here rather than a GROUP_CONCAT in SQL. + */ +export async function loadSpans(settings, start, end) { + const from = dayStart(start); + const to = dayStart(end) + DAY; + const repos = config.repos.map(r => r.name); + const [pulls, labels] = await Promise.all([ + db.query( + 'SELECT repo, number, owner, date, date_closed, date_merged FROM pulls ' + + 'WHERE repo IN (?) AND date < ? AND (date_closed IS NULL OR date_closed >= ?)', + [repos, to, from] + ), + db.query('SELECT repo, number, title FROM pull_labels WHERE title LIKE ?', [ + // LIKE's own wildcards in a prefix must match literally + settings.prefix.replace(/[\\%_]/g, '\\$&') + '%', + ]), + ]); + return spansFromRows(pulls, labels, settings.prefix); +} + +/** pulls rows + project-label rows -> PullSpans, bots left out. */ +export function spansFromRows(pulls, labelRows, prefix) { + const key = (repo, number) => `${String(repo).toLowerCase()}#${number}`; + const titles = new Map(); + for (const { repo, number, title } of labelRows) { + const k = key(repo, number); + if (!titles.has(k)) titles.set(k, []); + titles.get(k).push({ title }); + } + return pulls + .filter(p => p.date != null && !isBot(p.owner)) + .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 + const closed = p.date_closed ?? p.date_merged ?? null; + return { + author: p.owner, + project: projectOf(titles.get(key(p.repo, p.number)) || [], prefix), + opened: Number(p.date), + closed: closed == null ? null : Number(closed), + merged: p.date_merged != null, + }; + }); +} + +/** + * The window a request asks for: `start` and `end` as YYYY-MM-DD UTC days, + * both counted. end defaults to today, start to 29 days before end. Returns + * { start, end } or { error } with a message fit for a 400. + */ +export function parseWindow(query, now = Date.now() / 1000) { + for (const name of ['start', 'end']) { + if (query[name] !== undefined && typeof query[name] !== 'string') { + return { error: `${name} must be one YYYY-MM-DD day` }; + } + } + const end = query.end || utcDay(now); + const endAt = dayStart(end); + if (endAt == null) return { error: 'end must be a YYYY-MM-DD day' }; + const start = query.start || utcDay(endAt - (DEFAULT_WINDOW_DAYS - 1) * DAY); + const startAt = dayStart(start); + if (startAt == null) return { error: 'start must be a YYYY-MM-DD day' }; + if (startAt > endAt) return { error: 'start must not be after end' }; + if ((endAt - startAt) / DAY + 1 > MAX_WINDOW_DAYS) { + return { error: `a window can span at most ${MAX_WINDOW_DAYS} days` }; + } + return { start, end }; +} + +/** + * Today from the live board: the same open + recently closed pulls the socket + * ships (Pull models), people's only, run through the shared buildToday. + */ +export function todayFromBoard(pulls, projects, prefix, now = Date.now() / 1000) { + const people = pulls.filter(p => !isBot(p.data.user.login)); + const open = deriveAll(people.filter(p => p.isOpen()), now); + const closed = people.filter(p => !p.isOpen()).map(toWire); + return buildToday(projects, open, closed, prefix, now); +} diff --git a/lib/pull-manager.js b/lib/pull-manager.js index 7a35db7d..8ba1e22b 100644 --- a/lib/pull-manager.js +++ b/lib/pull-manager.js @@ -4,6 +4,7 @@ import config from './config-loader.js'; import _ from 'underscore'; import dbManager from './db-manager.js'; import Pull from '../models/pull.js'; +import { projectSettings } from './projects.js'; const pmDebug = debug('pulldasher:pull-manager'); @@ -56,6 +57,9 @@ function sendInitialData(socket) { // a separate static config.json fetch (see shared/types InitializePayload) bots: config.bots || [], weightLabels: config.weightLabels || {}, + // the project label prefix, sent only when this install uses projects + // (config.js `projects`); the board shows its Projects tab only then + projectLabelPrefix: projectSettings()?.prefix, }); } diff --git a/lib/refresh.js b/lib/refresh.js index fbd78677..f4eac263 100644 --- a/lib/refresh.js +++ b/lib/refresh.js @@ -68,6 +68,21 @@ export function createRefresh({ pacer = noopPacer } = {}) { .then(drainThrough(issueQueue)); }, + /** + * Refresh the issues in `repo` updated at or after `since` (an ISO + * timestamp), or every issue when `since` is null. The Projects tab + * reads a project issue's labels, assignee and state, so this is how + * the server keeps them right when an `issues` webhook goes missing. + */ + issuesChangedSince: function refreshIssuesChangedSince(repo, since) { + refreshDebug('refresh issues in %s changed since %s', repo, since || 'forever'); + return utils + .forEachRepo(r => gitManager.getAllIssues(r, pacer, since), { + repos: [{ name: repo }], + }) + .then(drainThrough(issueQueue)); + }, + openIssues: function refreshOpenIssues(repos) { refreshDebug('refresh all open issues'); return utils diff --git a/lib/review-model.js b/lib/review-model.js index 9045ff75..2414b735 100644 --- a/lib/review-model.js +++ b/lib/review-model.js @@ -31,7 +31,7 @@ function specForRepo(repo) { * the server-side derive sees byte-identical input to the board's client-side * derive. This equivalence is what the api parity test asserts. */ -function toWire(pull) { +export function toWire(pull) { return JSON.parse(JSON.stringify(pull.toObject())); } diff --git a/shared/types.ts b/shared/types.ts index 062542d4..1da8877d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -143,6 +143,10 @@ export interface InitializePayload { * that omits them just means suffix-only bots and heuristic-only weights. */ bots?: string[]; weightLabels?: Record; + /** the label prefix that files a PR into a project (`project:`), present + * only when the server is set up for projects; the Projects tab shows only + * then. See shared/model/projects.ts. */ + projectLabelPrefix?: string; } export interface TokenResponse { diff --git a/test/projects.test.js b/test/projects.test.js new file mode 100644 index 00000000..337b73f7 --- /dev/null +++ b/test/projects.test.js @@ -0,0 +1,271 @@ +import { test, before, after, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +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 { + parseWindow, + projectsFromRows, + spansFromRows, + MAX_WINDOW_DAYS, +} from '../lib/projects.js'; +import projectsController from '../controllers/projects.js'; + +const DAY = 86400; +// 2026-09-29 12:00 UTC +const NOW = Date.UTC(2026, 8, 29, 12) / 1000; + +test('parseWindow defaults to the 30 days ending today', () => { + assert.deepEqual(parseWindow({}, NOW), { start: '2026-08-31', end: '2026-09-29' }); + assert.deepEqual(parseWindow({ end: '2026-09-10' }, NOW), { + start: '2026-08-12', + end: '2026-09-10', + }); +}); + +test('parseWindow rejects bad days, backwards and oversized windows, repeats', () => { + assert.ok(parseWindow({ start: '9/1/2026' }, NOW).error); + assert.ok(parseWindow({ start: '2026-09-20', end: '2026-09-10' }, NOW).error); + assert.ok(parseWindow({ start: '2020-01-01', end: '2026-09-10' }, NOW).error); + assert.ok(parseWindow({ start: ['2026-09-01', '2026-09-02'] }, NOW).error); + // the longest allowed window still passes + const start = new Date((NOW - (MAX_WINDOW_DAYS - 1) * DAY) * 1000).toISOString().slice(0, 10); + assert.equal(parseWindow({ start }, NOW).error, undefined); +}); + +const issueRow = over => ({ + repo: 'test/projects', + number: 1, + title: 'Alpha', + status: 'open', + state_reason: null, + assignee: null, + milestone_title: null, + milestone_due_on: null, + ...over, +}); + +test('projectsFromRows reads name, lead, target, parents and ongoing off the issue', () => { + const projects = projectsFromRows( + [ + issueRow({ number: 1, assignee: 'lee', milestone_title: 'October', milestone_due_on: NOW }), + issueRow({ number: 2, title: 'Not a project' }), + ], + [ + { number: 1, title: 'project:alpha' }, + { number: 1, title: 'parent:store' }, + { number: 1, title: 'parent:checkout' }, + { number: 1, title: 'ongoing' }, + { number: 2, title: 'bug' }, + ], + 'project:' + ); + assert.deepEqual(projects, [ + { + slug: 'alpha', + name: 'Alpha', + repo: 'test/projects', + number: 1, + state: 'open', + state_reason: null, + ongoing: true, + parents: ['checkout', 'store'], + lead: 'lee', + target: { title: 'October', due_on: '2026-09-29T12:00:00.000Z' }, + }, + ]); +}); + +test('projectsFromRows keeps the open issue when two carry one label', () => { + const projects = projectsFromRows( + [ + issueRow({ number: 5, title: 'Old', status: 'closed', state_reason: 'completed' }), + issueRow({ number: 3, title: 'Current' }), + ], + [ + { number: 5, title: 'project:alpha' }, + { number: 3, title: 'project:alpha' }, + ], + 'project:' + ); + assert.equal(projects.length, 1); + assert.equal(projects[0].name, 'Current'); +}); + +test('spansFromRows files PRs by label, leaves bots out, falls back to the merge time', () => { + const spans = spansFromRows( + [ + { repo: 'Test/Repo-A', number: 1, owner: 'alice', date: 100, date_closed: 200, date_merged: 200 }, + { repo: 'test/repo-a', number: 2, owner: 'fixture-bot', date: 100, date_closed: null, date_merged: null }, + { repo: 'test/repo-a', number: 3, owner: 'bob', date: 150, date_closed: null, date_merged: 300 }, + ], + [{ repo: 'test/repo-a', number: 1, title: 'project:alpha' }], + 'project:' + ); + assert.deepEqual(spans, [ + { author: 'alice', project: 'alpha', opened: 100, closed: 200, merged: true }, + { author: 'bob', project: null, opened: 150, closed: 300, merged: true }, + ]); +}); + +// ---- the endpoints, over HTTP, with the DB stubbed ---- + +function pullRow(number, owner, over = {}) { + return { + repo: 'test/repo-a', + number, + state: 'open', + title: `PR ${number}`, + body: '', + draft: 0, + date: NOW - 3 * DAY, + date_updated: NOW - DAY, + date_closed: null, + mergeable: 1, + date_merged: null, + difficulty: null, + additions: 10, + deletions: 1, + changed_files: 1, + milestone_title: null, + milestone_due_on: null, + head_branch: `b${number}`, + head_sha: `sha${number}`, + base_branch: 'main', + owner, + assignees: [], + requested_reviewers: [], + cr_req: 1, + qa_req: 1, + ...over, + }; +} + +const label = (title, number) => new Label({ name: title }, number, 'test/repo-a', 'job-bot'); + +function makeApp() { + const app = express(); + app.get('/projects-data', projectsController.getBoardData); + app.get('/api/v1/projects', projectsController.getProjects); + app.get('/api/v1/people', projectsController.getPeople); + return app; +} + +function listen(app) { + return new Promise(resolve => { + const server = app.listen(0, () => resolve({ port: server.address().port, server })); + }); +} + +before(() => { + config.projects = { repo: 'test/projects' }; + const now = Math.floor(Date.now() / 1000); + // two open PRs in alpha (one person), one unsorted, one bot PR in alpha + const seed = (row, labels) => + pullManager.updatePull(Pull.getFromDB(row, [], [], [], [], labels)); + seed(pullRow(11, 'alice'), [label('project:alpha', 11)]); + seed(pullRow(12, 'alice'), [label('project:alpha', 12)]); + seed(pullRow(13, 'bob'), []); + seed(pullRow(14, 'fixture-bot'), [label('project:alpha', 14)]); + // merged yesterday in alpha: alice's third PR there + seed(pullRow(15, 'alice', { state: 'closed', date_closed: now - DAY, date_merged: now - DAY }), [ + label('project:alpha', 15), + ]); + mock.method(db, 'query', async sql => { + if (sql.startsWith('SELECT * FROM issues')) { + return [ + issueRow({ number: 7, title: 'Alpha work', assignee: 'alice' }), + issueRow({ number: 8, title: 'Beta', status: 'closed', state_reason: 'not_planned' }), + ]; + } + if (sql.startsWith('SELECT number, title FROM pull_labels')) { + return [ + { number: 7, title: 'project:alpha' }, + { number: 8, title: 'project:beta' }, + ]; + } + if (sql.startsWith('SELECT repo, number, owner')) { + return [ + { repo: 'test/repo-a', number: 11, owner: 'alice', date: now - 3 * DAY, date_closed: null, date_merged: null }, + { repo: 'test/repo-a', number: 13, owner: 'bob', date: now - 3 * DAY, date_closed: null, date_merged: null }, + { repo: 'test/repo-a', number: 15, owner: 'alice', date: now - 5 * DAY, date_closed: now - DAY, date_merged: now - DAY }, + ]; + } + if (sql.startsWith('SELECT repo, number, title FROM pull_labels')) { + return [ + { repo: 'test/repo-a', number: 11, title: 'project:alpha' }, + { repo: 'test/repo-a', number: 15, title: 'project:alpha' }, + ]; + } + throw new Error(`unexpected query: ${sql}`); + }); +}); + +after(() => { + mock.restoreAll(); + delete config.projects; +}); + +async function get(path) { + const { port, server } = await listen(makeApp()); + const res = await fetch(`http://127.0.0.1:${port}${path}`); + const body = await res.json(); + server.close(); + return { status: res.status, body }; +} + +test('/api/v1/projects lists each project with today and the window', async () => { + const { status, body } = await get('/api/v1/projects'); + assert.equal(status, 200); + const alpha = body.projects.find(p => p.slug === 'alpha'); + assert.equal(alpha.name, 'Alpha work'); + assert.equal(alpha.standing, 'live'); + assert.equal(alpha.lead, 'alice'); + // the bot PR is left out; alice alone has three PRs here, and both open + // 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.flags, ['one_person', 'waiting_on_review']); + assert.equal(alpha.window.merged, 1); + const beta = body.projects.find(p => p.slug === 'beta'); + assert.equal(beta.standing, 'dropped'); + assert.deepEqual(body.unsorted.open, ['test/repo-a#13']); + assert.equal(body.totals.backlog_end, 2); + assert.equal(body.days.length, 30); +}); + +test('/api/v1/people puts the live projects and open PRs beside the window', async () => { + const { status, body } = await get('/api/v1/people'); + assert.equal(status, 200); + const alice = body.people.find(p => p.login === 'alice'); + assert.deepEqual(alice.live_projects, ['alpha']); + assert.equal(alice.open_now, 2); + assert.deepEqual(alice.projects_in_window, ['alpha']); + assert.equal(alice.window.merged, 1); + assert.equal(body.people[0].login, 'alice'); + assert.ok(!body.people.some(p => p.login === 'fixture-bot')); +}); + +test('/projects-data sends the registry and the window, and 400s a bad window', async () => { + const ok = await get('/projects-data?start=2026-09-01&end=2026-09-29'); + assert.equal(ok.status, 200); + assert.equal(ok.body.label_prefix, 'project:'); + assert.deepEqual( + ok.body.projects.map(p => p.slug), + ['alpha', 'beta'] + ); + assert.equal(ok.body.window.start, '2026-09-01'); + const bad = await get('/projects-data?start=2026-09-29&end=2026-09-01'); + assert.equal(bad.status, 400); +}); + +test('the endpoints 404 when this install has no projects config', async () => { + const saved = config.projects; + delete config.projects; + const { status } = await get('/api/v1/projects'); + config.projects = saved; + assert.equal(status, 404); +}); From 47eb44b6564dab5d374c82ad9004397403ce676c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 29 Sep 2026 17:35:58 -0700 Subject: [PATCH 004/116] Split project work by developer team and count reviews A project manager needs to see who does the work, not just how much. When people outside the developer teams open a large share of PRs, every one of those needs a developer's review. The window numbers couldn't show either. Now config.js `projects.developerTeams` names the teams (team to logins); anyone else with a PR counts as a non-developer. windowStats takes the team lookup and the window's CR/QA stamps, and adds: 1) per bucket: the median days from opened to merged, and how many developers and non-developers had PRs; 2) per project: its first PR's opening day and its last close; 3) per person: their team, the reviews they gave, and how many of those were on a non-developer's PR; 4) one point per Monday-start week: opened and merged by each group, merged PRs by project, and reviews by whose PR they were on. The server loads the stamps with one join of pull_signatures to pulls, leaves out bots on either side, and narrows them with `project=` the same way it narrows PRs. /projects-data sends the teams, and /api/v1/people gains each person's team and review counts. Project records carry their issue's opened and closed dates. Co-Authored-By: Claude Opus 5.5 --- config.example.js | 6 + controllers/projects.js | 52 ++++-- frontend-v2/src/model/projects.test.ts | 58 +++++++ lib/projects.js | 110 ++++++++++--- shared/index.ts | 2 +- shared/model/projects.ts | 217 ++++++++++++++++++++----- test/projects.test.js | 84 +++++++++- 7 files changed, 441 insertions(+), 88 deletions(-) diff --git a/config.example.js b/config.example.js index ad6a4535..c6add6e7 100644 --- a/config.example.js +++ b/config.example.js @@ -125,6 +125,12 @@ module.exports = { projects: { repo: 'owner/projects', labelPrefix: 'project:', + // Team name -> the GitHub logins on it. The Projects tab splits people + // into these teams; anyone who opens PRs without being listed here + // counts as a non-developer. + developerTeams: { + Store: ['login1', 'login2'], + }, }, // The usual MySQL stuff. Like every other MySQL webapp, basically. diff --git a/controllers/projects.js b/controllers/projects.js index 22ea2999..d3bd13ce 100644 --- a/controllers/projects.js +++ b/controllers/projects.js @@ -2,7 +2,7 @@ import pullManager from '../lib/pull-manager.js'; import { respondOrError } from '../lib/controller-utils.js'; import { loadProjects, - loadSpans, + loadWindow, parseWindow, projectSettings, todayFromBoard, @@ -13,7 +13,9 @@ const key = d => `${d.repo}#${d.number}`; /** * Validate the projects config and the requested window, then load the - * projects and the window's PR history. Answers the request itself (404 when + * projects, the window's PR history and its CR/QA stamps. An optional + * `project` slug narrows the window's numbers and stamps to that project's + * PRs (the project list stays whole). Answers the request itself (404 when * projects aren't set up here, 400 for a bad window) and returns null, or * returns a promise of what the handler needs. */ @@ -24,17 +26,22 @@ function load(req, res) { return null; } const window = parseWindow(req.query); - if (window.error) { - res.status(400).json({ error: window.error }); + const only = req.query.project; + const badProject = only !== undefined && (typeof only !== 'string' || !only || only.length > 64); + if (window.error || badProject) { + res.status(400).json({ error: window.error || 'project must be one project slug' }); return null; } return Promise.all([ loadProjects(settings), - loadSpans(settings, window.start, window.end), - ]).then(([projects, spans]) => ({ + loadWindow(settings, window.start, window.end, only), + ]).then(([projects, { spans, reviews }]) => ({ settings, projects, - stats: windowStats(spans, window.start, window.end), + stats: windowStats(spans, window.start, window.end, { + teamOf: settings.teamOf, + reviews, + }), })); } @@ -97,10 +104,10 @@ function projectRecords(projects, today, stats, prefix) { export default { /** - * GET /projects-data?start=&end= -- the Projects tab's fetch: every project - * issue plus the window's numbers. The tab builds Today itself from the live - * socket pulls, so it moves with the board. Session-authed like - * /stats-history. + * GET /projects-data?start=&end=&project= -- the Projects tab's fetch: + * every project issue plus the window's numbers (one project's, with + * `project`). The tab builds Today itself from the live socket pulls, so + * it moves with the board. Session-authed like /stats-history. */ getBoardData: function (req, res) { const loaded = load(req, res); @@ -110,6 +117,7 @@ export default { loaded.then(({ settings, projects, stats }) => ({ label_prefix: settings.prefix, projects_repo: settings.repo, + teams: settings.teams, projects, window: stats, })), @@ -120,9 +128,11 @@ export default { /** * GET /api/v1/projects?start=&end= -- every project with its issue fields, * where it stands today (open PR ids, people, idle days, flags), and its - * numbers for the window (default: the last 30 days), plus the totals and - * one point per day for the backlog chart. Bearer-authed (lib/api-auth). - * Dates are YYYY-MM-DD UTC days, both counted. + * numbers for the window (default: the last 30 days) -- backlog, throughput, + * developers vs. non-developers, and median days to merge -- plus the + * totals and one point per day for the backlog chart. Bearer-authed + * (lib/api-auth). Dates are YYYY-MM-DD UTC days, both counted; + * `project=` narrows the window's numbers to that project's PRs. */ getProjects: function (req, res) { const loaded = load(req, res); @@ -153,10 +163,13 @@ export default { }, /** - * GET /api/v1/people?start=&end= -- per person: the window's numbers, the - * projects they had PRs in during it, the live projects they're on today, - * and how many PRs they have open now. Most live projects first, which is - * how a reader spots someone spread thin. Bots are left out. + * GET /api/v1/people?start=&end= -- per person: their developer team (from + * config, null if unlisted), the window's numbers, the CR/QA stamps they + * gave in it and how many landed on a non-developer's PR, the projects + * they had PRs in during it, the live projects they're on today, and how + * many PRs they have open now. Someone who only reviewed in the window + * still gets a row. Most live projects first, which is how a reader spots + * someone spread thin. Bots are left out. */ getPeople: function (req, res) { const loaded = load(req, res); @@ -187,6 +200,7 @@ export default { const w = stats.people[login]; return { login, + team: w ? w.team : settings.teamOf(login), window: w ? { backlog_start: w.backlog_start, @@ -201,6 +215,8 @@ export default { projects_in_window: w ? w.projects : [], live_projects: (live.get(login) || []).sort(), open_now: openNow.get(login) || 0, + reviews: w ? w.reviews : 0, + reviews_on_non_dev: w ? w.reviews_on_non_dev : 0, }; }); people.sort( diff --git a/frontend-v2/src/model/projects.test.ts b/frontend-v2/src/model/projects.test.ts index 235d683f..bbbb7c03 100644 --- a/frontend-v2/src/model/projects.test.ts +++ b/frontend-v2/src/model/projects.test.ts @@ -60,6 +60,8 @@ function project(over: Partial & { slug: string }): Project { parents: [], lead: null, target: null, + created_at: null, + closed_at: null, ...over, }; } @@ -272,3 +274,59 @@ describe('windowStats', () => { expect(w.days[9]).toMatchObject({ arrived: 4, departed: 2, backlog: w.totals.backlog_end }); }); }); + +describe('windowStats: teams, reviews, weeks', () => { + // Tue 2026-09-01 to Sun 2026-09-13: the weeks start Mon Aug 31, Sep 7 + const start = '2026-09-01'; + const end = '2026-09-13'; + const from = dayStart(start) as number; + const teams: Record = { dana: 'Store' }; + const teamOf = (login: string) => teams[login] ?? null; + const w = windowStats( + [ + // dana (a developer) opens and merges inside the first week: 2 days + { author: 'dana', project: 'alpha', opened: from, closed: from + 2 * DAY, merged: true }, + // kyle (not a developer) opens in the second week, merged 4 days later + { author: 'kyle', project: 'alpha', opened: from + 7 * DAY, closed: from + 11 * DAY, merged: true }, + // kyle again, with no project label, still open + { author: 'kyle', project: null, opened: from + 8 * DAY, closed: null, merged: false }, + ], + start, + end, + { + teamOf, + reviews: [ + { reviewer: 'dana', author: 'kyle', at: from + 9 * DAY }, + { reviewer: 'dana', author: 'dana', at: from + DAY }, // her own PR: not a review + { reviewer: 'erin', author: 'dana', at: from + 3 * DAY }, + { reviewer: 'dana', author: 'kyle', at: from - DAY }, // before the window + ], + } + ); + + it('buckets opened and merged by developer or not, week by week from Monday', () => { + expect(w.weeks.map(x => x.week)).toEqual(['2026-08-31', '2026-09-07']); + expect(w.weeks[0].opened).toEqual({ developers: 1, non_developers: 0 }); + expect(w.weeks[1].opened).toEqual({ developers: 0, non_developers: 2 }); + expect(w.weeks[0].merged_by_project).toEqual({ alpha: 1 }); + expect(w.weeks[1].merged_by_project).toEqual({ alpha: 1 }); + }); + + it('gives the median days to merge and the people split per bucket', () => { + expect(w.totals.median_days_to_merge).toBe(3); + expect(w.projects.alpha).toMatchObject({ + developers: 1, + non_developers: 1, + first_opened: '2026-09-01', + last_closed: '2026-09-12', + }); + }); + + it('counts reviews given in the window on other people’s PRs, by whose PR', () => { + expect(w.people.dana).toMatchObject({ team: 'Store', reviews: 1, reviews_on_non_dev: 1 }); + // erin reviewed but opened nothing: she still gets a row + expect(w.people.erin).toMatchObject({ team: null, reviews: 1, reviews_on_non_dev: 0, opened: 0 }); + expect(w.weeks[0].reviews).toEqual({ on_developers: 1, on_non_developers: 0 }); + expect(w.weeks[1].reviews).toEqual({ on_developers: 0, on_non_developers: 1 }); + }); +}); diff --git a/lib/projects.js b/lib/projects.js index 2016b7d2..c7072d8b 100644 --- a/lib/projects.js +++ b/lib/projects.js @@ -29,12 +29,22 @@ export const DEFAULT_WINDOW_DAYS = 30; * This install's projects config, or null when it doesn't use projects (the * board then hides the tab). `repo` may be null too: PRs still group by * their labels, just without the names, leads and targets an issue gives. + * `teams` is the configured developerTeams object (`{}` when absent); `teamOf` + * looks a login up in it case-insensitively, returning null for anyone not + * listed (which windowStats treats as a non-developer). */ export function projectSettings() { if (!config.projects) return null; + const teams = config.projects.developerTeams || {}; + const byLogin = new Map(); + for (const [team, logins] of Object.entries(teams)) { + for (const login of logins) byLogin.set(login.toLowerCase(), team); + } return { repo: config.projects.repo || null, prefix: config.projects.labelPrefix || DEFAULT_PROJECT_PREFIX, + teams, + teamOf: login => byLogin.get(login.toLowerCase()) ?? null, }; } @@ -89,6 +99,12 @@ export function projectsFromRows(issues, labelRows, prefix) { : null, } : null, + created_at: issue.date_created != null + ? new Date(issue.date_created * 1000).toISOString() + : null, + closed_at: issue.date_closed != null + ? new Date(issue.date_closed * 1000).toISOString() + : null, }; const prev = bySlug.get(slug); const openFirst = prev && (project.state === 'open') - (prev.state === 'open'); @@ -99,38 +115,24 @@ export function projectsFromRows(issues, labelRows, prefix) { return [...bySlug.values()]; } -/** - * People's PRs in the tracked repos whose lifetime overlaps the UTC days - * start..end, as PullSpans. Two plain queries (the pulls, then every project - * label) joined here rather than a GROUP_CONCAT in SQL. - */ -export async function loadSpans(settings, start, end) { - const from = dayStart(start); - const to = dayStart(end) + DAY; - const repos = config.repos.map(r => r.name); - const [pulls, labels] = await Promise.all([ - db.query( - 'SELECT repo, number, owner, date, date_closed, date_merged FROM pulls ' + - 'WHERE repo IN (?) AND date < ? AND (date_closed IS NULL OR date_closed >= ?)', - [repos, to, from] - ), - db.query('SELECT repo, number, title FROM pull_labels WHERE title LIKE ?', [ - // LIKE's own wildcards in a prefix must match literally - settings.prefix.replace(/[\\%_]/g, '\\$&') + '%', - ]), - ]); - return spansFromRows(pulls, labels, settings.prefix); -} +const pullKey = (repo, number) => `${String(repo).toLowerCase()}#${number}`; -/** pulls rows + project-label rows -> PullSpans, bots left out. */ -export function spansFromRows(pulls, labelRows, prefix) { - const key = (repo, number) => `${String(repo).toLowerCase()}#${number}`; +/** repo+number (case-insensitive on repo) -> its project slug, from + * project-label rows. One lookup shared by spansFromRows and the review + * narrowing in loadWindow, so a PR's project can never disagree between them. */ +function pullProjects(labelRows, prefix) { const titles = new Map(); for (const { repo, number, title } of labelRows) { - const k = key(repo, number); + const k = pullKey(repo, number); if (!titles.has(k)) titles.set(k, []); titles.get(k).push({ title }); } + return (repo, number) => projectOf(titles.get(pullKey(repo, number)) || [], prefix); +} + +/** pulls rows + project-label rows -> PullSpans, bots left out. */ +export function spansFromRows(pulls, labelRows, prefix) { + const projectOfPull = pullProjects(labelRows, prefix); return pulls .filter(p => p.date != null && !isBot(p.owner)) .map(p => { @@ -139,7 +141,7 @@ export function spansFromRows(pulls, labelRows, prefix) { const closed = p.date_closed ?? p.date_merged ?? null; return { author: p.owner, - project: projectOf(titles.get(key(p.repo, p.number)) || [], prefix), + project: projectOfPull(p.repo, p.number), opened: Number(p.date), closed: closed == null ? null : Number(closed), merged: p.date_merged != null, @@ -147,6 +149,60 @@ export function spansFromRows(pulls, labelRows, prefix) { }); } +/** pull_signatures rows (joined to pulls for the author) -> ReviewSpans, + * dropping a stamp given by a bot or given on a bot's PR. Carries repo and + * number too, past what ReviewSpan itself needs, so a caller can narrow the + * stamps to one project the same way spansFromRows does for pulls. */ +export function reviewsFromRows(rows) { + return rows + .filter(r => !isBot(r.reviewer) && !isBot(r.author)) + .map(({ reviewer, author, at, repo, number }) => ({ + reviewer, + author, + at: Number(at), + repo, + number, + })); +} + +/** + * People's PRs and the CR/QA stamps given on them, in the tracked repos, + * touching the UTC days start..end, as { spans, reviews }. With a `project` + * slug, both are narrowed to that project's PRs (spans by their own .project, + * reviews by looking their repo+number up in the same label rows). Three + * plain queries, joined here rather than in SQL. + */ +export async function loadWindow(settings, start, end, project) { + const from = dayStart(start); + const to = dayStart(end) + DAY; + 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 ' + + 'WHERE repo IN (?) AND date < ? AND (date_closed IS NULL OR date_closed >= ?)', + [repos, to, from] + ), + db.query('SELECT repo, number, title FROM pull_labels WHERE title LIKE ?', [ + // LIKE's own wildcards in a prefix must match literally + settings.prefix.replace(/[\\%_]/g, '\\$&') + '%', + ]), + db.query( + 'SELECT s.user AS reviewer, s.repo, s.number, p.owner AS author, s.date AS at ' + + 'FROM pull_signatures s JOIN pulls p ON p.repo = s.repo AND p.number = s.number ' + + "WHERE s.type IN ('CR', 'QA') AND s.date >= ? AND s.date < ? AND p.repo IN (?)", + [from, to, repos] + ), + ]); + const spans = spansFromRows(pulls, labels, settings.prefix); + const reviews = reviewsFromRows(reviewRows); + if (project == null) return { spans, reviews }; + const projectOfPull = pullProjects(labels, settings.prefix); + return { + spans: spans.filter(s => s.project === project), + reviews: reviews.filter(r => projectOfPull(r.repo, r.number) === project), + }; +} + /** * The window a request asks for: `start` and `end` as YYYY-MM-DD UTC days, * both counted. end defaults to today, start to 29 days before end. Returns diff --git a/shared/index.ts b/shared/index.ts index 64b62594..14cc2ecd 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -47,7 +47,7 @@ export { ONGOING_LABEL, PARENT_PREFIX, } from './model/projects'; -export type { Project, PullSpan, Today, WindowStats } from './model/projects'; +export type { Project, PullSpan, ReviewSpan, Today, WindowStats } from './model/projects'; export { epoch } from './format'; export type { PullData, RepoSpec, Signature, CommitStatus, Label } from './types'; diff --git a/shared/model/projects.ts b/shared/model/projects.ts index fe43aad3..679ea618 100644 --- a/shared/model/projects.ts +++ b/shared/model/projects.ts @@ -45,6 +45,9 @@ export interface Project { parents: string[]; lead: string | null; target: { title: string; due_on: string | null } | null; + /** when the issue was opened and closed (ISO); null when not known or open */ + created_at: string | null; + closed_at: string | null; } /** Every project slug a PR's labels name, sorted so every reader agrees. */ @@ -210,6 +213,15 @@ export interface PullSpan { merged: boolean; } +/** One CR or QA stamp, for counting who reviews whose work. */ +export interface ReviewSpan { + reviewer: string; + /** who wrote the PR the stamp is on */ + author: string; + /** epoch secs the stamp was given */ + at: number; +} + export interface WindowCounts { /** open at the start of the first day */ backlog_start: number; @@ -222,11 +234,29 @@ export interface WindowCounts { /** median age in days of the PRs open at each end; null with none open */ median_age_start_days: number | null; median_age_end_days: number | null; + /** median days from opened to merged, over the PRs merged in the window */ + median_days_to_merge: number | null; + /** distinct people with a PR in the window: on a developer team, or not */ + developers: number; + non_developers: number; +} + +export interface ProjectWindow extends WindowCounts { + /** the day its earliest PR in the window opened, which can be before the window */ + first_opened: string | null; + /** the day of its latest merge or close in the window; null with none */ + last_closed: string | null; } export interface PersonWindow extends WindowCounts { /** projects this person had a PR in during the window, misc left out */ projects: string[]; + /** their developer team from config; null for everyone else */ + team: string | null; + /** CR and QA stamps they gave in the window, on other people's PRs */ + reviews: number; + /** of those, stamps on a non-developer's PR */ + reviews_on_non_dev: number; } export interface DayPoint { @@ -240,18 +270,39 @@ export interface DayPoint { backlog: number; } +/** One week of the window, Monday first; the first and last can be partial. */ +export interface WeekPoint { + /** the Monday that starts it, YYYY-MM-DD */ + week: string; + opened: { developers: number; non_developers: number }; + merged: { developers: number; non_developers: number }; + /** merged PRs by project slug (misc included); '' for no project label */ + merged_by_project: Record; + /** stamps given that week, by who wrote the PR */ + reviews: { on_developers: number; on_non_developers: number }; +} + export interface WindowStats { /** first and last UTC day of the window, both counted */ start: string; end: string; totals: WindowCounts; /** by project slug, misc included */ - projects: Record; + projects: Record; /** PRs with no project label */ unsorted: WindowCounts; people: Record; /** one point per day, for the backlog chart */ days: DayPoint[]; + /** one point per week, for the charts that split work by project or by who did it */ + weeks: WeekPoint[]; +} + +export interface WindowOptions { + /** a developer's team, from config; null for anyone else. Default: nobody is a developer. */ + teamOf?: (login: string) => string | null; + /** CR and QA stamps to count; the caller leaves out bots and bots' PRs */ + reviews?: readonly ReviewSpan[]; } /** Epoch secs at the start of a YYYY-MM-DD UTC day, or null if it isn't one. */ @@ -268,23 +319,26 @@ export function utcDay(epochSecs: number): string { } interface Tally { - counts: WindowCounts; + counts: Omit< + WindowCounts, + | 'median_age_start_days' + | 'median_age_end_days' + | 'median_days_to_merge' + | 'developers' + | 'non_developers' + >; agesStart: number[]; agesEnd: number[]; + toMerge: number[]; + authors: Set; } const tally = (): Tally => ({ - counts: { - backlog_start: 0, - backlog_end: 0, - opened: 0, - merged: 0, - closed: 0, - median_age_start_days: null, - median_age_end_days: null, - }, + counts: { backlog_start: 0, backlog_end: 0, opened: 0, merged: 0, closed: 0 }, agesStart: [], agesEnd: [], + toMerge: [], + authors: new Set(), }); function median(xs: number[]): number | null { @@ -295,29 +349,53 @@ function median(xs: number[]): number | null { return Math.round(m * 10) / 10; } -const finish = ({ counts, agesStart, agesEnd }: Tally): WindowCounts => ({ - ...counts, - median_age_start_days: median(agesStart), - median_age_end_days: median(agesEnd), -}); - /** * Backlog and throughput for the UTC days `start` through `end`, overall, per - * project and per person. Every bucket satisfies backlog_start + opened - - * merged - closed = backlog_end, the check the history rebuild used. A PR is - * counted under its current project label, which is exact unless it moved - * between projects. Pass people's PRs only, and a valid start <= end. + * project and per person, plus a point per day and per week for the charts. + * Every bucket satisfies backlog_start + opened - merged - closed = + * backlog_end, the check the history rebuild used. A PR is counted under its + * current project label, which is exact unless it moved between projects. + * Pass people's PRs only, and a valid start <= end. With `teamOf`, people + * split into developers (on a team in config) and everyone else; with + * `reviews`, each person's stamps given in the window are counted too. */ -export function windowStats(spans: readonly PullSpan[], start: string, end: string): WindowStats { +export function windowStats( + spans: readonly PullSpan[], + start: string, + end: string, + { teamOf = () => null, reviews = [] }: WindowOptions = {} +): WindowStats { const from = dayStart(start) ?? 0; const to = (dayStart(end) ?? from) + DAY; const dayCount = Math.max(1, Math.round((to - from) / DAY)); + const isDev = (login: string) => teamOf(login) != null; const totals = tally(); const unsorted = tally(); - const projects = new Map(); - const people = new Map }>(); + const projects = new Map(); + const people = new Map; reviews: number; onNonDev: number }>(); + const person = (login: string) => { + let p = people.get(login); + if (!p) { + p = { t: tally(), projects: new Set(), reviews: 0, onNonDev: 0 }; + people.set(login, p); + } + return p; + }; const openedOn = new Array(dayCount).fill(0); const departedOn = new Array(dayCount).fill(0); + // weeks start on the Monday on or before the first day + const monday = from - ((new Date(from * 1000).getUTCDay() + 6) % 7) * DAY; + const weeks: WeekPoint[] = Array.from( + { length: Math.max(1, Math.ceil((to - monday) / (7 * DAY))) }, + (_, i) => ({ + week: utcDay(monday + i * 7 * DAY), + opened: { developers: 0, non_developers: 0 }, + merged: { developers: 0, non_developers: 0 }, + merged_by_project: {}, + reviews: { on_developers: 0, on_non_developers: 0 }, + }) + ); + const weekOf = (t: number) => weeks[Math.floor((t - monday) / (7 * DAY))]; for (const s of spans) { // skip PRs that never overlapped the window @@ -325,18 +403,22 @@ export function windowStats(spans: readonly PullSpan[], start: string, end: stri const openAt = (t: number) => s.opened < t && (s.closed == null || s.closed >= t); const openedIn = s.opened >= from; const departedIn = s.closed != null && s.closed < to; - let byProject = s.project == null ? unsorted : projects.get(s.project); - if (!byProject) { - byProject = tally(); - projects.set(s.project as string, byProject); - } - let person = people.get(s.author); - if (!person) { - person = { t: tally(), projects: new Set() }; - people.set(s.author, person); + const group = isDev(s.author) ? 'developers' : 'non_developers'; + let byProject: Tally = unsorted; + if (s.project != null) { + let p = projects.get(s.project); + if (!p) { + p = { ...tally(), first: s.opened, last: null }; + projects.set(s.project, p); + } + p.first = Math.min(p.first, s.opened); + if (departedIn) p.last = Math.max(p.last ?? 0, s.closed as number); + byProject = p; } - if (s.project != null && s.project !== MISC_SLUG) person.projects.add(s.project); - for (const t of [totals, byProject, person.t]) { + const who = person(s.author); + if (s.project != null && s.project !== MISC_SLUG) who.projects.add(s.project); + for (const t of [totals, byProject, who.t]) { + t.authors.add(s.author); if (openAt(from)) { t.counts.backlog_start++; t.agesStart.push((from - s.opened) / DAY); @@ -347,14 +429,48 @@ export function windowStats(spans: readonly PullSpan[], start: string, end: stri } if (openedIn) t.counts.opened++; if (departedIn) { - if (s.merged) t.counts.merged++; - else t.counts.closed++; + if (s.merged) { + t.counts.merged++; + t.toMerge.push(((s.closed as number) - s.opened) / DAY); + } else t.counts.closed++; + } + } + if (openedIn) { + openedOn[Math.floor((s.opened - from) / DAY)]++; + weekOf(s.opened).opened[group]++; + } + if (departedIn) { + departedOn[Math.floor(((s.closed as number) - from) / DAY)]++; + if (s.merged) { + const w = weekOf(s.closed as number); + w.merged[group]++; + const key = s.project ?? ''; + w.merged_by_project[key] = (w.merged_by_project[key] ?? 0) + 1; } } - if (openedIn) openedOn[Math.floor((s.opened - from) / DAY)]++; - if (departedIn) departedOn[Math.floor(((s.closed as number) - from) / DAY)]++; } + for (const r of reviews) { + if (r.at < from || r.at >= to || r.reviewer === r.author) continue; + const who = person(r.reviewer); + const onDev = isDev(r.author); + who.reviews++; + if (!onDev) who.onNonDev++; + weekOf(r.at).reviews[onDev ? 'on_developers' : 'on_non_developers']++; + } + + const finish = ({ counts, agesStart, agesEnd, toMerge, authors }: Tally): WindowCounts => { + const devs = [...authors].filter(isDev).length; + return { + ...counts, + median_age_start_days: median(agesStart), + median_age_end_days: median(agesEnd), + median_days_to_merge: median(toMerge), + developers: devs, + non_developers: authors.size - devs, + }; + }; + let arrived = totals.counts.backlog_start; let departed = 0; const days = openedOn.map((opened, i) => { @@ -367,11 +483,30 @@ export function windowStats(spans: readonly PullSpan[], start: string, end: stri start, end, totals: finish(totals), - projects: Object.fromEntries([...projects].map(([slug, t]) => [slug, finish(t)])), + projects: Object.fromEntries( + [...projects].map(([slug, p]) => [ + slug, + { + ...finish(p), + first_opened: utcDay(p.first), + last_closed: p.last == null ? null : utcDay(p.last), + }, + ]) + ), unsorted: finish(unsorted), people: Object.fromEntries( - [...people].map(([login, p]) => [login, { ...finish(p.t), projects: [...p.projects].sort() }]) + [...people].map(([login, p]) => [ + login, + { + ...finish(p.t), + projects: [...p.projects].sort(), + team: teamOf(login), + reviews: p.reviews, + reviews_on_non_dev: p.onNonDev, + }, + ]) ), days, + weeks, }; } diff --git a/test/projects.test.js b/test/projects.test.js index 337b73f7..027ba50e 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -8,7 +8,9 @@ import Label from '../models/label.js'; import pullManager from '../lib/pull-manager.js'; import { parseWindow, + projectSettings, projectsFromRows, + reviewsFromRows, spansFromRows, MAX_WINDOW_DAYS, } from '../lib/projects.js'; @@ -75,10 +77,22 @@ test('projectsFromRows reads name, lead, target, parents and ongoing off the iss parents: ['checkout', 'store'], lead: 'lee', target: { title: 'October', due_on: '2026-09-29T12:00:00.000Z' }, + created_at: null, + closed_at: null, }, ]); }); +test('projectsFromRows fills created_at and closed_at from the issue dates', () => { + const projects = projectsFromRows( + [issueRow({ number: 9, date_created: 1700000000, date_closed: 1700100000 })], + [{ number: 9, title: 'project:gamma' }], + 'project:' + ); + assert.equal(projects[0].created_at, new Date(1700000000 * 1000).toISOString()); + assert.equal(projects[0].closed_at, new Date(1700100000 * 1000).toISOString()); +}); + test('projectsFromRows keeps the open issue when two carry one label', () => { const projects = projectsFromRows( [ @@ -111,6 +125,19 @@ test('spansFromRows files PRs by label, leaves bots out, falls back to the merge ]); }); +test('reviewsFromRows keeps repo and number, drops a bot on either side, leaves a self-stamp for windowStats to ignore', () => { + const reviews = reviewsFromRows([ + { reviewer: 'carol', repo: 'test/repo-a', number: 11, author: 'alice', at: 100 }, + { reviewer: 'fixture-bot', repo: 'test/repo-a', number: 12, author: 'alice', at: 100 }, + { reviewer: 'carol', repo: 'test/repo-a', number: 14, author: 'fixture-bot', at: 100 }, + { reviewer: 'alice', repo: 'test/repo-a', number: 11, author: 'alice', at: 100 }, + ]); + assert.deepEqual(reviews, [ + { reviewer: 'carol', author: 'alice', at: 100, repo: 'test/repo-a', number: 11 }, + { reviewer: 'alice', author: 'alice', at: 100, repo: 'test/repo-a', number: 11 }, + ]); +}); + // ---- the endpoints, over HTTP, with the DB stubbed ---- function pullRow(number, owner, over = {}) { @@ -161,7 +188,11 @@ function listen(app) { } before(() => { - config.projects = { repo: 'test/projects' }; + // Alice is on a developer team; Bob and Carol (a reviewer with no PRs of + // her own) aren't, so the window's dev/non-dev and review splits have + // something real to split. 'Alice' is capitalized on purpose: teamOf must + // still match the lowercase 'alice' pulls are seeded with. + config.projects = { repo: 'test/projects', developerTeams: { Store: ['Alice'] } }; const now = Math.floor(Date.now() / 1000); // two open PRs in alpha (one person), one unsorted, one bot PR in alpha const seed = (row, labels) => @@ -200,6 +231,18 @@ before(() => { { repo: 'test/repo-a', number: 15, title: 'project:alpha' }, ]; } + if (sql.startsWith('SELECT s.user AS reviewer')) { + return [ + // carol CRs alice's PR in alpha: a developer's PR + { reviewer: 'carol', repo: 'test/repo-a', number: 11, author: 'alice', at: now - 2 * DAY }, + // carol QAs bob's unsorted PR: a non-developer's PR + { reviewer: 'carol', repo: 'test/repo-a', number: 13, author: 'bob', at: now - DAY }, + // a bot's stamp, dropped by reviewsFromRows + { reviewer: 'fixture-bot', repo: 'test/repo-a', number: 11, author: 'alice', at: now - DAY }, + // alice reviewing her own PR, dropped by windowStats (not reviewsFromRows) + { reviewer: 'alice', repo: 'test/repo-a', number: 11, author: 'alice', at: now - DAY }, + ]; + } throw new Error(`unexpected query: ${sql}`); }); }); @@ -217,6 +260,14 @@ async function get(path) { return { status: res.status, body }; } +test('projectSettings.teamOf matches a listed login case-insensitively', () => { + const settings = projectSettings(); + assert.equal(settings.teamOf('alice'), 'Store'); + assert.equal(settings.teamOf('ALICE'), 'Store'); + assert.equal(settings.teamOf('bob'), null); + assert.deepEqual(settings.teams, { Store: ['Alice'] }); +}); + test('/api/v1/projects lists each project with today and the window', async () => { const { status, body } = await get('/api/v1/projects'); assert.equal(status, 200); @@ -249,6 +300,22 @@ test('/api/v1/people puts the live projects and open PRs beside the window', asy assert.ok(!body.people.some(p => p.login === 'fixture-bot')); }); +test('/api/v1/people carries team and review counts, and a reviewer with no PRs still appears', async () => { + const { status, body } = await get('/api/v1/people'); + assert.equal(status, 200); + const alice = body.people.find(p => p.login === 'alice'); + assert.equal(alice.team, 'Store'); + const bob = body.people.find(p => p.login === 'bob'); + assert.equal(bob.team, null); + const carol = body.people.find(p => p.login === 'carol'); + assert.ok(carol, 'carol only reviewed in the window and has no PRs, but still gets a row'); + assert.equal(carol.team, null); + // one CR on alice (a developer) and one QA on bob (not one); the bot's + // stamp and alice's self-stamp are both left out of the count + assert.equal(carol.reviews, 2); + assert.equal(carol.reviews_on_non_dev, 1); +}); + test('/projects-data sends the registry and the window, and 400s a bad window', async () => { const ok = await get('/projects-data?start=2026-09-01&end=2026-09-29'); assert.equal(ok.status, 200); @@ -262,6 +329,21 @@ test('/projects-data sends the registry and the window, and 400s a bad window', assert.equal(bad.status, 400); }); +test('project= narrows the window to one project and rejects a repeated one', async () => { + const one = await get('/projects-data?project=alpha'); + assert.equal(one.status, 200); + // alpha holds #11 (open) and #15 (merged); bob's unsorted #13 drops out + assert.equal(one.body.window.totals.backlog_end, 1); + assert.equal(one.body.window.totals.merged, 1); + assert.deepEqual(Object.keys(one.body.window.people), ['alice', 'carol']); + // carol's CR on alice's #11 is in alpha and stays; her QA on bob's #13 + // (unsorted) drops out along with the PR itself + assert.equal(one.body.window.people.carol.reviews, 1); + assert.equal(one.body.window.people.carol.reviews_on_non_dev, 0); + const bad = await get('/projects-data?project=a&project=b'); + assert.equal(bad.status, 400); +}); + test('the endpoints 404 when this install has no projects config', async () => { const saved = config.projects; delete config.projects; From 1cd633aa53001df75b9b163dc0998ba14494fa9c Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 29 Sep 2026 17:36:10 -0700 Subject: [PATCH 005/116] Keep a roadmap: planned projects in their own table A project manager wants to lay out the next months by hand: which work comes first, when it starts, and roughly how long it takes. GitHub has nowhere to keep a plan's start, length and order, so this is the one part of the Projects tab Pulldasher stores itself, in a new roadmap_items table (schema.sql creates it on the next start, and 0022 records it). The column is lead_login because LEAD is a reserved word in MySQL 8. An item has a name, a first week (always a Monday), a length in weeks, a status (planned, active, done, dropped), an optional team and lead, notes, and an optional project label slug that links it to that project's PRs. Its place in one shared order is its priority. 1) GET /roadmap (session) and GET /api/v1/roadmap (Bearer) list it. 2) POST /roadmap adds at the bottom, PATCH /roadmap/:id edits any subset, DELETE /roadmap/:id removes. shared/model/roadmap.ts checks every write, so the board and the server word a mistake the same. 3) PUT /roadmap/order rewrites every priority in one UPDATE, and only when the ids name every item exactly once; otherwise it answers 409 with the current list, so two people dragging at once can't drop or repeat an item. Writes need a signed-in session and a JSON body. A JSON write from another site needs a CORS preflight this server never answers, which keeps cross-site form posts out without a token. Each row records who changed it and when. Co-Authored-By: Claude Opus 5.5 --- app.js | 9 + controllers/roadmap.js | 112 +++++++++++ frontend-v2/src/model/roadmap.test.ts | 55 +++++ lib/authentication.js | 1 + lib/roadmap.js | 125 ++++++++++++ migrations/0022-roadmap-items--add-table.sql | 18 ++ migrations/schema.sql | 25 +++ shared/index.ts | 11 + shared/model/roadmap.ts | 151 ++++++++++++++ test/roadmap.test.js | 201 +++++++++++++++++++ 10 files changed, 708 insertions(+) create mode 100644 controllers/roadmap.js create mode 100644 frontend-v2/src/model/roadmap.test.ts create mode 100644 lib/roadmap.js create mode 100644 migrations/0022-roadmap-items--add-table.sql create mode 100644 shared/model/roadmap.ts create mode 100644 test/roadmap.test.js diff --git a/app.js b/app.js index 40663e1d..28935d16 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,7 @@ import statsController from './controllers/stats.js'; import userNamesController from './controllers/user-names.js'; import apiController from './controllers/api.js'; import projectsController from './controllers/projects.js'; +import roadmapController, { canWrite } from './controllers/roadmap.js'; import { projectSettings } from './lib/projects.js'; import apiAuth from './lib/api-auth.js'; import Debug from './lib/debug.js'; @@ -70,6 +71,13 @@ app.get('/token', mainController.getToken); app.get('/stats-history', statsController.getHistory); app.get('/user-names', userNamesController.getNames); app.get('/projects-data', projectsController.getBoardData); +// the roadmap is the one part of the Projects tab people edit here: reads are +// gated like the other board data (lib/authentication.js), writes by canWrite +app.get('/roadmap', roadmapController.list); +app.post('/roadmap', canWrite, roadmapController.create); +app.put('/roadmap/order', canWrite, roadmapController.reorder); +app.patch('/roadmap/:id', canWrite, roadmapController.update); +app.delete('/roadmap/:id', canWrite, roadmapController.remove); app.post('/hooks/main', hooksController.main); // /api/v1: machine-to-machine JSON for the review skills, Bearer-authed with @@ -80,6 +88,7 @@ app.get('/api/v1/me', apiAuth, apiController.getMe); app.get('/api/v1/pulls', apiAuth, apiController.getPulls); app.get('/api/v1/projects', apiAuth, projectsController.getProjects); app.get('/api/v1/people', apiAuth, projectsController.getPeople); +app.get('/api/v1/roadmap', apiAuth, roadmapController.list); // Warm the bot-login cache (used to tell a pulldasher claim apart from a // GitHub-UI self-request) before any webhook or socket traffic needs it. diff --git a/controllers/roadmap.js b/controllers/roadmap.js new file mode 100644 index 00000000..8fd42ef2 --- /dev/null +++ b/controllers/roadmap.js @@ -0,0 +1,112 @@ +import { respondOrError } from '../lib/controller-utils.js'; +import { createItem, deleteItem, listItems, reorderItems, updateItem } from '../lib/roadmap.js'; +import { checkRoadmapFields } from '../shared/dist/index.js'; + +const FAKE_USER = process.env.MOCK_AUTH_AS_USER; + +/** + * The gate on every roadmap write. A signed-in session is the only way in + * (401 otherwise), and the body must be JSON (415 otherwise): a JSON write + * from another site needs a CORS preflight, which this server never answers, + * so a cross-site form post can't change the roadmap on someone's behalf. + */ +export function canWrite(req, res, next) { + const signedIn = typeof req.isAuthenticated === 'function' && req.isAuthenticated(); + if (!signedIn && !FAKE_USER) { + return res.status(401).json({ error: 'sign in to change the roadmap' }); + } + if (req.method !== 'DELETE' && !req.is('application/json')) { + return res.status(415).json({ error: 'send the change as JSON' }); + } + req.roadmapLogin = signedIn ? req.user.username : FAKE_USER; + next(); +} + +/** 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; +} + +export default { + /** + * GET /roadmap (session) and GET /api/v1/roadmap (Bearer) -- every + * roadmap item in priority order: the plan a project manager laid out, + * each with its first week, length in weeks, and an optional project label + * slug linking it to that project's PRs. + */ + list: function (req, res) { + respondOrError( + res, + listItems().then(items => ({ items })), + 'roadmap query failed' + ); + }, + + /** + * POST /roadmap {name, project?, team?, lead?, status?, start?, weeks?, + * notes?} -- a new item at the bottom of the order. 201 with the item. + */ + create: function (req, res) { + const checked = checkRoadmapFields(req.body, { partial: false }); + if (checked.error) return res.status(400).json({ error: checked.error }); + createItem(checked.fields, req.roadmapLogin) + .then(item => res.status(201).json({ item })) + .catch(err => { + console.error('roadmap create failed:', err); + res.status(500).json({ error: 'roadmap create failed' }); + }); + }, + + /** PATCH /roadmap/:id with any of the item's fields -- changes just those. */ + 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 checked = checkRoadmapFields(req.body, { partial: true }); + if (checked.error) return res.status(400).json({ error: checked.error }); + updateItem(id, checked.fields, req.roadmapLogin) + .then(item => + item ? res.json({ item }) : res.status(404).json({ error: 'no such roadmap item' }) + ) + .catch(err => { + console.error('roadmap update failed:', err); + res.status(500).json({ error: 'roadmap update failed' }); + }); + }, + + /** DELETE /roadmap/:id -- removes the item. */ + remove: function (req, res) { + const id = idOf(req); + if (!id) return res.status(400).json({ error: 'the id must be a positive whole number' }); + deleteItem(id) + .then(gone => + gone ? res.json({ ok: true }) : res.status(404).json({ error: 'no such roadmap item' }) + ) + .catch(err => { + console.error('roadmap delete failed:', err); + res.status(500).json({ error: 'roadmap delete failed' }); + }); + }, + + /** + * PUT /roadmap/order {ids} -- the whole order, top first. It has to name + * every item once; if the list changed since the person loaded it, 409 + * with the list as it is now, so their board can catch up before retrying. + */ + reorder: function (req, res) { + const ids = req.body && req.body.ids; + if (!Array.isArray(ids) || !ids.every(id => Number.isInteger(id) && id > 0)) { + return res.status(400).json({ error: 'send ids as a list of item ids, top first' }); + } + reorderItems(ids) + .then(result => + result.conflict + ? res.status(409).json({ error: 'the roadmap changed; here it is again', items: result.items }) + : res.json({ items: result.items }) + ) + .catch(err => { + console.error('roadmap reorder failed:', err); + res.status(500).json({ error: 'roadmap reorder failed' }); + }); + }, +}; diff --git a/frontend-v2/src/model/roadmap.test.ts b/frontend-v2/src/model/roadmap.test.ts new file mode 100644 index 00000000..19f37cd2 --- /dev/null +++ b/frontend-v2/src/model/roadmap.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + addWeeks, + checkRoadmapFields, + mondayOf, + moveBefore, + planEnd, +} from '../../../shared/model/roadmap'; + +describe('roadmap dates', () => { + it('moves any day to its week’s Monday and counts whole weeks from it', () => { + expect(mondayOf('2026-10-01')).toBe('2026-09-28'); // a Thursday + expect(mondayOf('2026-09-28')).toBe('2026-09-28'); + expect(mondayOf('2026-10-04')).toBe('2026-09-28'); // a Sunday + expect(addWeeks('2026-09-28', 2)).toBe('2026-10-12'); + expect(planEnd({ start: '2026-09-28', weeks: 2 })).toBe('2026-10-11'); + }); +}); + +describe('checkRoadmapFields', () => { + it('needs a name on a new item, trims it, and snaps the start to Monday', () => { + expect(checkRoadmapFields({}, { partial: false })).toEqual({ error: 'a project needs a name' }); + expect( + checkRoadmapFields({ name: ' Checkout ', start: '2026-10-01' }, { partial: false }) + ).toEqual({ fields: { name: 'Checkout', start: '2026-09-28' } }); + }); + + it('lets an edit send any subset, and clears optional fields with null or empty', () => { + expect(checkRoadmapFields({ weeks: 6 }, { partial: true })).toEqual({ fields: { weeks: 6 } }); + expect(checkRoadmapFields({ lead: '', project: null }, { partial: true })).toEqual({ + fields: { lead: null, project: null }, + }); + }); + + it('says what is wrong in words', () => { + const error = (input: unknown) => + (checkRoadmapFields(input, { partial: true }) as { error: string }).error; + expect(error({ weeks: 0 })).toMatch(/weeks/); + expect(error({ weeks: 2.5 })).toMatch(/weeks/); + expect(error({ status: 'someday' })).toMatch(/status/); + expect(error({ project: 'Not A Slug' })).toMatch(/slug/); + expect(error({ lead: 'two words' })).toMatch(/login/); + expect(error({ start: 'next week' })).toMatch(/YYYY-MM-DD/); + expect(error([])).toMatch(/object/); + }); +}); + +describe('moveBefore', () => { + it('drops an id in front of another, or at the end', () => { + expect(moveBefore([1, 2, 3, 4], 4, 2)).toEqual([1, 4, 2, 3]); + expect(moveBefore([1, 2, 3, 4], 1, 4)).toEqual([2, 3, 1, 4]); + expect(moveBefore([1, 2, 3], 1, null)).toEqual([2, 3, 1]); + expect(moveBefore([1, 2, 3], 9, 1)).toEqual([1, 2, 3]); + }); +}); diff --git a/lib/authentication.js b/lib/authentication.js index 79e93321..a8849e47 100644 --- a/lib/authentication.js +++ b/lib/authentication.js @@ -132,5 +132,6 @@ export default { app.get('/stats-history', auth); app.get('/user-names', auth); app.get('/projects-data', auth); + app.get('/roadmap', auth); }, }; diff --git a/lib/roadmap.js b/lib/roadmap.js new file mode 100644 index 00000000..94f0d3c9 --- /dev/null +++ b/lib/roadmap.js @@ -0,0 +1,125 @@ +import db from './db.js'; +import { mondayOf, utcDay } from '../shared/dist/index.js'; + +/** + * The roadmap's storage: one row per planned item in `roadmap_items`, the + * one thing in the Projects tab Pulldasher keeps itself rather than reading + * off GitHub (a plan's start, length and order have nowhere to live there). + * The shared model (shared/model/roadmap.ts) owns the item's shape and the + * checks on what a person sends; this file only reads and writes rows. + */ + +// DATE_FORMAT keeps `start` a plain YYYY-MM-DD string: mysql2 would hand a +// DATE back as a Date at the server's local midnight, and every later step +// would have to undo that +const COLUMNS = + "`id`, `name`, `project`, `team`, `lead_login`, `status`, DATE_FORMAT(`start`, '%Y-%m-%d') AS `start`, " + + '`weeks`, `priority`, `notes`, `updated_by`, `updated_at`'; + +/** A roadmap_items row as the wire's RoadmapItem. */ +export function itemFromRow(row) { + return { + id: Number(row.id), + name: row.name, + project: row.project || null, + team: row.team || null, + lead: row.lead_login || null, + status: row.status, + start: row.start, + weeks: Number(row.weeks), + priority: Number(row.priority), + notes: row.notes || '', + updated_by: row.updated_by || null, + updated_at: row.updated_at == null ? null : Number(row.updated_at), + }; +} + +// the wire's field names to the table's (only the lead differs: LEAD is a +// reserved word in MySQL 8) +const COLUMN_OF = { + name: 'name', + project: 'project', + team: 'team', + lead: 'lead_login', + status: 'status', + start: 'start', + weeks: 'weeks', + notes: 'notes', +}; + +/** Every item, in priority order (lowest first), oldest first on a tie. */ +export async function listItems() { + const rows = await db.query( + `SELECT ${COLUMNS} FROM \`roadmap_items\` ORDER BY \`priority\`, \`id\`` + ); + return rows.map(itemFromRow); +} + +async function getItem(id) { + const rows = await db.query(`SELECT ${COLUMNS} FROM \`roadmap_items\` WHERE \`id\` = ?`, [id]); + return rows.length ? itemFromRow(rows[0]) : null; +} + +/** + * Add an item at the bottom of the order. Fields the person didn't send get + * the plan a blank item starts with: planned, four weeks from this week's + * Monday. + */ +export async function createItem(fields, login, now = Math.floor(Date.now() / 1000)) { + const item = { + project: null, + team: null, + lead: null, + status: 'planned', + start: mondayOf(utcDay(now)), + weeks: 4, + notes: '', + ...fields, + }; + const [{ top }] = await db.query('SELECT MAX(`priority`) AS `top` FROM `roadmap_items`'); + const row = {}; + for (const [key, column] of Object.entries(COLUMN_OF)) row[column] = item[key]; + row.priority = top == null ? 0 : Number(top) + 1; + row.created_by = row.updated_by = login; + row.created_at = row.updated_at = now; + const result = await db.query('INSERT INTO `roadmap_items` SET ?', [row]); + return getItem(result.insertId); +} + +/** Change only the fields sent, and say who did. Null when there's no such item. */ +export async function updateItem(id, fields, login, now = Math.floor(Date.now() / 1000)) { + const row = { updated_by: login, updated_at: now }; + for (const [key, value] of Object.entries(fields)) { + if (COLUMN_OF[key]) row[COLUMN_OF[key]] = value; + } + const result = await db.query('UPDATE `roadmap_items` SET ? WHERE `id` = ?', [row, id]); + return result.affectedRows ? getItem(id) : null; +} + +/** Remove an item; false when there was none. */ +export async function deleteItem(id) { + const result = await db.query('DELETE FROM `roadmap_items` WHERE `id` = ?', [id]); + return result.affectedRows > 0; +} + +/** + * Put the items in the order given. `ids` must name every item exactly + * once: when it doesn't, someone added or removed an item since this person + * loaded the roadmap, so nothing changes and they get the list as it is now. + * One UPDATE rewrites every priority, so a half-done reorder can't happen. + */ +export async function reorderItems(ids) { + const current = await listItems(); + const known = new Set(current.map(i => i.id)); + const same = ids.length === known.size && new Set(ids).size === ids.length && ids.every(id => known.has(id)); + if (!same) return { conflict: true, items: current }; + if (ids.length) { + const cases = ids.map(() => 'WHEN ? THEN ?').join(' '); + const params = ids.flatMap((id, i) => [id, i]); + await db.query( + `UPDATE \`roadmap_items\` SET \`priority\` = CASE \`id\` ${cases} END WHERE \`id\` IN (?)`, + [...params, ids] + ); + } + return { items: await listItems() }; +} diff --git a/migrations/0022-roadmap-items--add-table.sql b/migrations/0022-roadmap-items--add-table.sql new file mode 100644 index 00000000..77909e99 --- /dev/null +++ b/migrations/0022-roadmap-items--add-table.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `roadmap_items` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(120) COLLATE utf8mb4_general_ci NOT NULL, + `project` varchar(24) COLLATE utf8mb4_general_ci DEFAULT NULL, + `team` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `lead_login` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `status` enum('planned','active','done','dropped') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'planned', + `start` date NOT NULL, + `weeks` smallint unsigned NOT NULL DEFAULT '4', + `priority` int NOT NULL DEFAULT '0', + `notes` text COLLATE utf8mb4_general_ci, + `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`), + KEY `roadmap_items_priority` (`priority`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/migrations/schema.sql b/migrations/schema.sql index 824677a0..7904119a 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -182,6 +182,31 @@ CREATE TABLE IF NOT EXISTS `reviews` ( KEY `pull` (`number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `roadmap_items` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE IF NOT EXISTS `roadmap_items` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(120) COLLATE utf8mb4_general_ci NOT NULL, + `project` varchar(24) COLLATE utf8mb4_general_ci DEFAULT NULL, + `team` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `lead_login` varchar(39) COLLATE utf8mb4_general_ci DEFAULT NULL, + `status` enum('planned','active','done','dropped') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'planned', + `start` date NOT NULL, + `weeks` smallint unsigned NOT NULL DEFAULT '4', + `priority` int NOT NULL DEFAULT '0', + `notes` text COLLATE utf8mb4_general_ci, + `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`), + KEY `roadmap_items_priority` (`priority`) +) 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/shared/index.ts b/shared/index.ts index 14cc2ecd..2badddc5 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -49,5 +49,16 @@ export { } from './model/projects'; export type { Project, PullSpan, ReviewSpan, Today, WindowStats } from './model/projects'; +export { + addWeeks, + checkRoadmapFields, + mondayOf, + moveBefore, + planEnd, + MAX_WEEKS, + ROADMAP_STATUSES, +} from './model/roadmap'; +export type { RoadmapFields, RoadmapItem, RoadmapStatus } from './model/roadmap'; + export { epoch } from './format'; export type { PullData, RepoSpec, Signature, CommitStatus, Label } from './types'; diff --git a/shared/model/roadmap.ts b/shared/model/roadmap.ts new file mode 100644 index 00000000..f4bff20c --- /dev/null +++ b/shared/model/roadmap.ts @@ -0,0 +1,151 @@ +import { dayStart, utcDay } from './projects'; + +/** + * The roadmap: the plan a project manager lays out by hand, kept in + * Pulldasher's own table (roadmap_items) rather than read off PRs. Each item + * has a planned first week, a length in weeks, and a place in one shared + * order (its priority). Linking an item to a project label lets the PRs' + * real activity draw over the plan; an item with no link is a plan with no + * code yet. Pure, so the server's checks and the board's are the same code. + */ + +export type RoadmapStatus = 'planned' | 'active' | 'done' | 'dropped'; +export const ROADMAP_STATUSES: RoadmapStatus[] = ['planned', 'active', 'done', 'dropped']; + +/** the longest plan an item can carry, in weeks: two years */ +export const MAX_WEEKS = 104; +const NAME_MAX = 120; +const NOTES_MAX = 2000; +const TEAM_MAX = 64; +const DAY = 86400; + +export interface RoadmapItem { + id: number; + name: string; + /** the project label slug this item tracks; null for a plan with no PRs yet */ + project: string | null; + /** a developer team, for the roadmap's team lanes; null for none */ + team: string | null; + /** a GitHub login */ + lead: string | null; + status: RoadmapStatus; + /** the Monday of the planned first week, YYYY-MM-DD */ + start: string; + /** the planned length in whole weeks */ + weeks: number; + /** its place in the order: lower comes first */ + priority: number; + notes: string; + /** who last changed it and when (epoch secs); null before anyone has */ + updated_by: string | null; + updated_at: number | null; +} + +/** The fields a person can set; the server owns id, priority and the audit fields. */ +export type RoadmapFields = Pick< + RoadmapItem, + 'name' | 'project' | 'team' | 'lead' | 'status' | 'start' | 'weeks' | 'notes' +>; + +/** The Monday on or before a YYYY-MM-DD day: plans move in whole weeks. */ +export function mondayOf(day: string): string { + const t = dayStart(day) as number; + return utcDay(t - ((new Date(t * 1000).getUTCDay() + 6) % 7) * DAY); +} + +/** A YYYY-MM-DD day plus some weeks. */ +export function addWeeks(day: string, weeks: number): string { + return utcDay((dayStart(day) as number) + weeks * 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); +} + +const SLUG = /^[a-z0-9][a-z0-9-]{0,23}$/; +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; + +type Check = { fields: Partial } | { error: string }; + +/** + * Check what a person sent for a roadmap item, and clean it: names and notes + * trimmed, the start moved to its Monday. A new item (`partial: false`) needs + * a name, and gets defaults for the rest from the caller; an edit + * (`partial: true`) may send any subset. Returns the clean fields, or the + * first problem in words fit to show the person. + */ +export function checkRoadmapFields(input: unknown, { partial }: { partial: boolean }): Check { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { error: 'send the item as a JSON object' }; + } + const raw = input as Record; + const fields: Partial = {}; + const has = (key: string) => Object.prototype.hasOwnProperty.call(raw, key); + // null or an empty string clears an optional field + const optional = (key: string) => raw[key] == null || raw[key] === ''; + + if (has('name') || !partial) { + const name = typeof raw.name === 'string' ? raw.name.trim() : ''; + if (!name) return { error: 'a project needs a name' }; + if (name.length > NAME_MAX) return { error: `keep the name to ${NAME_MAX} characters` }; + fields.name = name; + } + if (has('project')) { + if (optional('project')) fields.project = null; + else if (typeof raw.project !== 'string' || !SLUG.test(raw.project)) { + return { error: 'the linked project must be a label slug, like ups-access-points' }; + } else fields.project = raw.project; + } + if (has('team')) { + if (optional('team')) fields.team = null; + else if (typeof raw.team !== 'string' || raw.team.trim().length > TEAM_MAX) { + return { error: `a team name is at most ${TEAM_MAX} characters` }; + } else fields.team = raw.team.trim(); + } + if (has('lead')) { + if (optional('lead')) fields.lead = null; + else if (typeof raw.lead !== 'string' || !LOGIN.test(raw.lead.trim())) { + return { error: 'the lead must be a GitHub login' }; + } else fields.lead = raw.lead.trim(); + } + if (has('status')) { + if (!ROADMAP_STATUSES.includes(raw.status as RoadmapStatus)) { + return { error: `status is one of ${ROADMAP_STATUSES.join(', ')}` }; + } + fields.status = raw.status as RoadmapStatus; + } + if (has('start')) { + if (typeof raw.start !== 'string' || dayStart(raw.start) == null) { + return { error: 'start must be a YYYY-MM-DD day' }; + } + fields.start = mondayOf(raw.start); + } + if (has('weeks')) { + const weeks = raw.weeks; + if (typeof weeks !== 'number' || !Number.isInteger(weeks) || weeks < 1 || weeks > MAX_WEEKS) { + return { error: `the length is a whole number of weeks, 1 to ${MAX_WEEKS}` }; + } + fields.weeks = weeks; + } + if (has('notes')) { + if (optional('notes')) fields.notes = ''; + else if (typeof raw.notes !== 'string' || raw.notes.length > NOTES_MAX) { + return { error: `keep the notes to ${NOTES_MAX} characters` }; + } else fields.notes = raw.notes.trim(); + } + return { fields }; +} + +/** + * Move `id` to just before `beforeId` in an order of ids (to the end with + * null), for a drag that drops one row onto another. Unknown ids leave the + * order as it was. + */ +export function moveBefore(order: readonly number[], id: number, beforeId: number | null): number[] { + if (!order.includes(id) || id === beforeId) return [...order]; + const rest = order.filter(x => x !== id); + const at = beforeId == null ? -1 : rest.indexOf(beforeId); + if (at < 0) return [...rest, id]; + return [...rest.slice(0, at), id, ...rest.slice(at)]; +} diff --git a/test/roadmap.test.js b/test/roadmap.test.js new file mode 100644 index 00000000..a91ca58c --- /dev/null +++ b/test/roadmap.test.js @@ -0,0 +1,201 @@ +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 { itemFromRow } from '../lib/roadmap.js'; +import roadmapController, { canWrite } from '../controllers/roadmap.js'; + +// An in-memory roadmap_items table behind a stubbed db.query, so each write +// is visible to the next read the way it would be in MySQL. It answers only +// the statements lib/roadmap.js sends. +let rows = []; +let nextId = 1; +function fakeQuery(sql, params = []) { + const byOrder = () => + [...rows].sort((a, b) => a.priority - b.priority || a.id - b.id); + if (sql.startsWith('SELECT MAX(`priority`)')) { + return [{ top: rows.length ? Math.max(...rows.map(r => r.priority)) : null }]; + } + if (sql.startsWith('SELECT') && sql.includes('WHERE `id` = ?')) { + return rows.filter(r => r.id === params[0]); + } + if (sql.startsWith('SELECT')) return byOrder(); + if (sql.startsWith('INSERT INTO `roadmap_items` SET ?')) { + const row = { id: nextId++, ...params[0] }; + rows.push(row); + return { insertId: row.id, affectedRows: 1 }; + } + if (sql.startsWith('UPDATE `roadmap_items` SET ? WHERE `id` = ?')) { + const row = rows.find(r => r.id === params[1]); + if (row) Object.assign(row, params[0]); + return { affectedRows: row ? 1 : 0 }; + } + if (sql.startsWith('UPDATE `roadmap_items` SET `priority` = CASE')) { + const pairs = params.slice(0, -1); + for (let i = 0; i < pairs.length; i += 2) { + rows.find(r => r.id === pairs[i]).priority = pairs[i + 1]; + } + return { affectedRows: pairs.length / 2 }; + } + if (sql.startsWith('DELETE FROM `roadmap_items`')) { + const before = rows.length; + rows = rows.filter(r => r.id !== params[0]); + return { affectedRows: before - rows.length }; + } + throw new Error(`unexpected query: ${sql}`); +} + +let signedIn = true; +function makeApp() { + const app = express(); + app.use(bodyParser.json()); + app.use((req, res, next) => { + req.isAuthenticated = () => signedIn; + req.user = { username: 'alice' }; + next(); + }); + app.get('/roadmap', roadmapController.list); + app.post('/roadmap', canWrite, roadmapController.create); + app.put('/roadmap/order', canWrite, roadmapController.reorder); + app.patch('/roadmap/:id', canWrite, roadmapController.update); + app.delete('/roadmap/:id', canWrite, roadmapController.remove); + 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(() => { + rows = []; + nextId = 1; + signedIn = true; +}); + +async function call(method, path, body, headers = { 'Content-Type': 'application/json' }) { + const res = await fetch(base + path, { + method, + headers, + body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +test('itemFromRow renames the lead and fills the blanks', () => { + assert.deepEqual( + itemFromRow({ + id: '3', + name: 'Store picker', + project: null, + team: 'Store', + lead_login: 'dana', + status: 'active', + start: '2026-09-28', + weeks: '6', + priority: '2', + notes: null, + updated_by: 'alice', + updated_at: '1790000000', + }), + { + id: 3, + name: 'Store picker', + project: null, + team: 'Store', + lead: 'dana', + status: 'active', + start: '2026-09-28', + weeks: 6, + priority: 2, + notes: '', + updated_by: 'alice', + updated_at: 1790000000, + } + ); +}); + +test('a new item starts planned, four weeks from a Monday, at the bottom', async () => { + const first = await call('POST', '/roadmap', { name: ' Checkout redesign ' }); + assert.equal(first.status, 201); + assert.equal(first.body.item.name, 'Checkout redesign'); + assert.equal(first.body.item.status, 'planned'); + assert.equal(first.body.item.weeks, 4); + assert.equal(new Date(`${first.body.item.start}T00:00:00Z`).getUTCDay(), 1); + assert.equal(first.body.item.updated_by, 'alice'); + const second = await call('POST', '/roadmap', { + name: 'Search reindex', + start: '2026-10-01', + weeks: 8, + project: 'search-reindex', + }); + assert.equal(second.body.item.priority, first.body.item.priority + 1); + // any day moves to its week's Monday + assert.equal(second.body.item.start, '2026-09-28'); + assert.equal(rows.find(r => r.id === second.body.item.id).created_by, 'alice'); +}); + +test('a bad item is a 400 that says why', async () => { + const noName = await call('POST', '/roadmap', { weeks: 3 }); + assert.equal(noName.status, 400); + assert.match(noName.body.error, /name/); + const noWeeks = await call('POST', '/roadmap', { name: 'X', weeks: 0 }); + assert.equal(noWeeks.status, 400); + assert.match(noWeeks.body.error, /weeks/); +}); + +test('an edit changes only what was sent, and 404s an unknown item', async () => { + const { body } = await call('POST', '/roadmap', { name: 'Grafana', weeks: 2 }); + const edited = await call('PATCH', `/roadmap/${body.item.id}`, { weeks: 5, lead: 'dana' }); + assert.equal(edited.status, 200); + assert.equal(edited.body.item.weeks, 5); + assert.equal(edited.body.item.lead, 'dana'); + assert.equal(edited.body.item.name, 'Grafana'); + assert.equal((await call('PATCH', '/roadmap/99', { weeks: 5 })).status, 404); + assert.equal((await call('PATCH', '/roadmap/abc', { weeks: 5 })).status, 400); +}); + +test('delete removes the item', async () => { + const { body } = await call('POST', '/roadmap', { name: 'Gone soon' }); + const res = await call('DELETE', `/roadmap/${body.item.id}`, undefined, {}); + assert.equal(res.status, 200); + assert.equal((await call('GET', '/roadmap', undefined, {})).body.items.length, 0); + assert.equal((await call('DELETE', `/roadmap/${body.item.id}`, undefined, {})).status, 404); +}); + +test('an order naming every item rewrites the priorities; a stale one is a 409', async () => { + const ids = []; + for (const name of ['A', 'B', 'C']) ids.push((await call('POST', '/roadmap', { name })).body.item.id); + const moved = await call('PUT', '/roadmap/order', { ids: [ids[2], ids[0], ids[1]] }); + assert.equal(moved.status, 200); + assert.deepEqual( + moved.body.items.map(i => i.name), + ['C', 'A', 'B'] + ); + const stale = await call('PUT', '/roadmap/order', { ids: [ids[0], ids[1]] }); + assert.equal(stale.status, 409); + assert.equal(stale.body.items.length, 3); + assert.equal((await call('PUT', '/roadmap/order', { ids: ['x'] })).status, 400); +}); + +test('writes need a signed-in person and a JSON body', async () => { + signedIn = false; + assert.equal((await call('POST', '/roadmap', { name: 'Nope' })).status, 401); + signedIn = true; + const form = await call('POST', '/roadmap', 'name=Nope', { + 'Content-Type': 'application/x-www-form-urlencoded', + }); + assert.equal(form.status, 415); + assert.equal(rows.length, 0); +}); From cca71bc61e41cd95b21b01a6ca477b9f7055c12a Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Tue, 29 Sep 2026 17:39:32 -0700 Subject: [PATCH 006/116] Add a Projects tab for whoever plans the work The review board answers "what should I review next?" but nobody could answer "what are we working on, who is on it, and is it moving?" without reading every PR. The server now knows each PR's project and has a roadmap table, so this is the tab that shows them. 1) Overview: headline numbers for a picked date range, each compared with the days just before. Below them, the backlog chart, where merged work went week by week, and one list of every project to sort, group, search, or download as CSV. 2) Roadmap: the plan by month or quarter. Drag a row to set priority, a bar to move it, its edge to change its length; each drag has a keyboard twin. A linked project's real PR activity shows under its bar. 3) People: developers by team and everyone else, with the reviews each gave, including reviews of non-developers' PRs. 4) A page per project with its open PRs, people, flags and numbers. Roadmap changes show at once and roll back if the server says no. The tab, Recharts and react-day-picker are lazy chunks, so the review board's main bundle grows by 5 KB gzipped (144.8 to 149.7) and never downloads the charts. The dummy board files its PRs into projects, teams and a roadmap that show every state. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 38 + README.md | 55 ++ frontend-v2/README.md | 16 + frontend-v2/package-lock.json | 448 ++++++++- frontend-v2/package.json | 2 + frontend-v2/src/app.tsx | 127 ++- frontend-v2/src/backend/dummy.ts | 204 +++- frontend-v2/src/components/Settings.tsx | 6 +- frontend-v2/src/lens.ts | 5 +- frontend-v2/src/model/portfolio.test.ts | 168 ++++ frontend-v2/src/model/portfolio.ts | 299 ++++++ frontend-v2/src/model/projectData.test.ts | 65 ++ frontend-v2/src/model/projectData.ts | 239 +++++ frontend-v2/src/model/roadmapData.ts | 210 ++++ frontend-v2/src/store.ts | 8 + frontend-v2/src/styles.css | 13 + frontend-v2/src/views/Projects.tsx | 177 ++++ .../src/views/projects/DateRangePicker.tsx | 77 ++ frontend-v2/src/views/projects/Overview.tsx | 234 +++++ frontend-v2/src/views/projects/People.tsx | 280 ++++++ frontend-v2/src/views/projects/Portfolio.tsx | 402 ++++++++ .../src/views/projects/ProjectPage.tsx | 272 ++++++ .../src/views/projects/RangeCalendar.tsx | 89 ++ frontend-v2/src/views/projects/Roadmap.tsx | 913 ++++++++++++++++++ frontend-v2/src/views/projects/charts.tsx | 487 ++++++++++ frontend-v2/src/views/projects/lazyCharts.tsx | 19 + frontend-v2/src/views/projects/parts.tsx | 309 ++++++ frontend-v2/vite.config.ts | 1 + 28 files changed, 5143 insertions(+), 20 deletions(-) create mode 100644 frontend-v2/src/model/portfolio.test.ts create mode 100644 frontend-v2/src/model/portfolio.ts create mode 100644 frontend-v2/src/model/projectData.test.ts create mode 100644 frontend-v2/src/model/projectData.ts create mode 100644 frontend-v2/src/model/roadmapData.ts create mode 100644 frontend-v2/src/views/Projects.tsx create mode 100644 frontend-v2/src/views/projects/DateRangePicker.tsx create mode 100644 frontend-v2/src/views/projects/Overview.tsx create mode 100644 frontend-v2/src/views/projects/People.tsx create mode 100644 frontend-v2/src/views/projects/Portfolio.tsx create mode 100644 frontend-v2/src/views/projects/ProjectPage.tsx create mode 100644 frontend-v2/src/views/projects/RangeCalendar.tsx create mode 100644 frontend-v2/src/views/projects/Roadmap.tsx create mode 100644 frontend-v2/src/views/projects/charts.tsx create mode 100644 frontend-v2/src/views/projects/lazyCharts.tsx create mode 100644 frontend-v2/src/views/projects/parts.tsx diff --git a/DESIGN.md b/DESIGN.md index 21bc1c4d..d842c66a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -306,6 +306,44 @@ icon with a class bolted on. refires only after you reclaim and lose the spot again); dedupe keys name the subject, not the tick. +## Charts and dates + +- **Charts are Recharts, drawn in SVG, and colored only with the tokens.** + SVG takes `var(--ok)` in a fill, so dark mode costs no redraw, the same + rule the hand-drawn Stats charts follow. Canvas libraries (uPlot, Chart.js, + ECharts) were set aside for exactly that: every theme switch would need a + redraw with colors read out of CSS. Recharts is the heaviest thing on the + board, so it loads in its own chunk with the views that chart; the review + board never downloads it. +- **Charts don't animate.** Motion on this board means something changed, + and a chart drawing itself in on load says nothing. +- **One color vocabulary across Stats and Projects**: ink for opened, green + for merged or finished, brand for what is still open, a paler ink for + closed without merging. +- **One date range per page, picked with one control**: the button that + names the range, opening the presets an analytics tool offers beside a + two-month calendar (react-day-picker, lazy with its stylesheet). A preset + applies on click; a hand-picked range applies with Apply. Rejected: a row + of segment buttons plus two date inputs (shaped for developers, not the + product manager reading the numbers), and a second control for the + chart's own window (two knobs on one concept). A chart instead shows at + least 90 days ending on the range's last day and pales the days before the + range, so a short range still shows its trend. +- **The roadmap moves by direct manipulation, in whole weeks.** Drag a + row's grip to change its priority (the order is the priority; there's no + separate number to keep in sync), drag a bar to move the plan, and drag + its right edge to change the length. Every drag has a keyboard twin: + arrows on the grip reorder, arrows on a focused bar move it a week, and + Shift with them changes the length. A plan's bar says its status by form + (an outline while planned, a fill once under way, green when done, faint + when dropped); a linked project's real PR activity is a thin line under + it, amber only where a live project has run past its plan. Editing opens + inline under the row, not in a popover, since a half-typed plan shouldn't + vanish on a stray click. +- **A comparison is words, not color.** Each count says how it compares with + the same number of days just before ("4 more than the 30 days before"). + More merged isn't always good news, so no green or red. + ## Copy rules (static text is part of the visual system) - **Say the thing, don't be clever.** Lane names state their contents diff --git a/README.md b/README.md index 0837f5ab..7892f603 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,61 @@ Two query string parameters are available to filter the displayed pulls: `ex. https://pulldasher.example.com?milestone=site-redesign,12/5,12/19` +### Projects + +An optional tab for whoever plans the work: which projects the open PRs +serve, who is on each, how they are moving, and a roadmap to plan them on. +Everything but the roadmap is read from GitHub; something else (a person, +or a job) files the PRs: + +- A PR joins a project through one label, `project:`. PRs that fit no + project get `project:misc`. Keep a label to 32 characters with no spaces: + that's what Pulldasher's label table and filter box hold. +- Each project is an issue in one repo, carrying the same label. The issue's + title is the project's name, its assignee the lead, its milestone (and due + date) the target, `parent:` labels its parents (any number), and an + `ongoing` label marks work with no end. Close it as completed when it's + done, or as not planned when it's dropped or merged into another. + +Turn it on with the `projects` block in `config.js` (see +`config.example.js`): the projects repo, and `developerTeams`, the teams +whose members count as developers (anyone else with a PR is a +non-developer, whose work needs a developer's review). Give the projects +repo the same webhook as a tracked repo (Issues events). Without a projects +repo, PRs still group by label, just without names, leads or targets. + +The tab has three views and a page per project: + +- **Overview**: headline numbers for a date range (each compared with the + same number of days before), the backlog chart, where the merged work + went week by week, and every project on one list you can sort, group by + parent, lead or team, search, and download as CSV. +- **Roadmap**: the plan, by month or by quarter. Drag rows to set priority, + drag a bar to move it and its edge to change its length; group into team + lanes. An item linked to a project label draws what its PRs actually did + under the plan. This is the one thing the tab stores itself, in the + `roadmap_items` table. +- **People**: developers by team and everyone else: live projects each, + open PRs, PRs opened and merged, and reviews given, including how many + went to non-developers' PRs. + +The same data is in the API, Bearer-authed like `/api/v1/pulls`. The first +two take `start` and `end` as `YYYY-MM-DD` days (default: the last 30 days, +at most 400), and `project=` narrows the window's numbers to one +project: + +- `GET /api/v1/projects`: each project's issue fields, where it stands today + (open PR ids, people, idle days, flags), and its numbers for the window, + plus the totals and one point per day for the backlog chart. +- `GET /api/v1/people`: per person, their team, the window's numbers, + reviews given, the projects they had PRs in, the live projects they're on + today, and their open PRs. +- `GET /api/v1/roadmap`: every roadmap item in priority order. + +Pulldasher also re-lists every tracked repo's open PRs once an hour and +refreshes only the ones its database has wrong, so a lost webhook can't leave +a merged PR counted as open. + ## Architecture When first started, the Pulldasher server fetches information about the current diff --git a/frontend-v2/README.md b/frontend-v2/README.md index 08d6d16c..2df0e75a 100644 --- a/frontend-v2/README.md +++ b/frontend-v2/README.md @@ -106,6 +106,22 @@ seven-column layout.) first payload after load just primes the baseline, so it never alerts for the backlog already there, and a "send a test" button confirms the setup. `src/notifications.ts`. +- **Projects.** An optional tab, shown when the server's `config.js` has a + `projects` block, for whoever plans the work: an Overview (headline + numbers against the period before, the backlog chart, where merged work + went, and every project on one sortable, groupable list), a Roadmap (the + plan by month or quarter; drag to set priority, timing and length, with + linked projects' real PR activity drawn under each bar), People + (developers by team and everyone else, with the reviews each gave), and a + page per project. Today is built on the board from the live socket pulls + by the same `shared/model/projects.ts` the server's `/api/v1/projects` + runs; the project issues and history come from `GET /projects-data`, the + roadmap from `/roadmap` (`model/roadmapData.ts` shows each change at once + and rolls it back if the server refuses). The tab, its charts (Recharts) + and its date picker (react-day-picker) are lazy chunks, so the review + board never downloads them (DESIGN.md § Charts and dates). The dummy board + files its pulls into projects, teams and a roadmap that show every state + (`backend/dummy.ts`). - **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the focused row's branch. diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json index b1e9c032..6121be14 100644 --- a/frontend-v2/package-lock.json +++ b/frontend-v2/package-lock.json @@ -12,7 +12,9 @@ "lucide-react": "^1.25.0", "marked": "^18.0.7", "react": "^18.3.1", + "react-day-picker": "^10.0.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "socket.io-client": "^4.8.3" }, "devDependencies": { @@ -513,6 +515,12 @@ "node": ">=20.19.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1023,6 +1031,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1386,6 +1420,18 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1703,6 +1749,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1714,14 +1823,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1745,6 +1854,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1977,6 +2092,15 @@ "node": ">= 16" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2002,9 +2126,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2019,6 +2264,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2043,6 +2298,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2135,6 +2396,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2197,6 +2470,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2270,6 +2549,25 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2834,6 +3132,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -2847,6 +3171,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2857,6 +3211,51 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2867,6 +3266,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -3031,6 +3436,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3193,6 +3604,37 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/frontend-v2/package.json b/frontend-v2/package.json index 060c149a..aa846abf 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -16,7 +16,9 @@ "lucide-react": "^1.25.0", "marked": "^18.0.7", "react": "^18.3.1", + "react-day-picker": "^10.0.1", "react-dom": "^18.3.1", + "recharts": "^3.10.1", "socket.io-client": "^4.8.3" }, "devDependencies": { diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx index 3f3f463c..81db6cd4 100644 --- a/frontend-v2/src/app.tsx +++ b/frontend-v2/src/app.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useDeferredValue, useEffect, @@ -54,12 +56,18 @@ import { Team } from './views/Team'; import { Classic } from './views/Classic'; import { Ci } from './views/Ci'; import { Stats } from './views/Stats'; +import type { ProjectsNav } from './views/Projects'; +import { DEFAULT_RANGE } from './model/projectData'; import { Search } from './views/Search'; import { Settings } from './components/Settings'; export type { Lens }; -const LENSES: Lens[] = ['review', 'mine', 'team', 'classic', 'ci', 'stats']; +// The Projects tab is for whoever plans the work; reviewers never open it, so +// its code (and the chart and calendar chunks under it) loads only on demand. +const Projects = lazy(() => import('./views/Projects').then(m => ({ default: m.Projects }))); + +const LENSES: Lens[] = ['review', 'mine', 'team', 'projects', 'classic', 'ci', 'stats']; /** every actionState bucket, for validating the `state=` hash param against */ const ACTION_STATE_KEYS: ActionStateKey[] = [ @@ -94,6 +102,9 @@ interface HashState { reveal: string[]; /** session override of the drafts default (null = use the durable default) */ drafts: 'mine' | 'all' | null; + /** the Projects tab's own state: the view, an open project page, the + * date range, and the list's and roadmap's controls */ + projects: ProjectsNav; } /** @@ -127,6 +138,16 @@ function readHash(): HashState { hidden: p.get('hidden') === '1', reveal: p.get('show')?.split(',').filter(Boolean) ?? [], drafts: p.get('drafts') === 'all' ? 'all' : p.get('drafts') === 'mine' ? 'mine' : null, + projects: { + view: p.get('view') === 'roadmap' ? 'roadmap' : p.get('view') === 'people' ? 'people' : 'overview', + project: p.get('project') || null, + range: p.get('range') || DEFAULT_RANGE, + status: p.get('status') || 'live', + group: p.get('group') || 'none', + sort: p.get('sort') || 'open', + find: p.get('find') ?? '', + scale: p.get('scale') === 'month' ? 'month' : 'quarter', + }, }; } @@ -142,6 +163,14 @@ function buildHash(s: HashState): string { if (s.hidden) p.set('hidden', '1'); if (s.reveal.length) p.set('show', s.reveal.join(',')); if (s.drafts) p.set('drafts', s.drafts); + if (s.projects.view !== 'overview') p.set('view', s.projects.view); + if (s.projects.project) p.set('project', s.projects.project); + if (s.projects.range !== DEFAULT_RANGE) p.set('range', s.projects.range); + if (s.projects.status !== 'live') p.set('status', s.projects.status); + if (s.projects.group !== 'none') p.set('group', s.projects.group); + if (s.projects.sort !== 'open') 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); return p.toString(); } @@ -232,6 +261,7 @@ export function App() { lastSeen, refreshProgress, snoozed, + projectLabelPrefix, } = usePulldasher(); // per-repo reviewer pools and whose turn each starved, unclaimed pull is — // computed ONCE over the whole board and shared by the rows (rowOpts), @@ -306,6 +336,11 @@ export function App() { const [draftsMode, setDraftsMode] = useState<'mine' | 'all'>( () => urlState.drafts ?? getSettings().draftsMode ); + const [projectsNav, setProjectsNav] = useState(() => urlState.projects); + const navigateProjects = useCallback( + (patch: Partial) => setProjectsNav(cur => ({ ...cur, ...patch })), + [] + ); // login-level twin of isBot, for row-level consumers (the avatar's square // bot tile) that hold a login rather than a DerivedPull const isBotAuthor = useCallback((login: string) => isBotLogin(login, extraBots), [extraBots]); @@ -350,19 +385,37 @@ export function App() { hidden: showAll, reveal, drafts: draftsMode !== settings.draftsMode ? draftsMode : null, + projects: projectsNav, }), - [lens, query, scope, weightSel, stateSel, showAll, reveal, draftsMode, settings.draftsMode] + [ + lens, + query, + scope, + weightSel, + stateSel, + showAll, + reveal, + draftsMode, + settings.draftsMode, + projectsNav, + ] ); - // View changes (lens switches) earn a history entry so the back button + // View changes (lens switches, and opening or leaving a project page or + // the Projects tab's Date range) earn a history entry so the back button // navigates between boards; filter tweaks replace in place so typing a // query doesn't bury history under keystrokes. - const prevView = useRef({ lens }); + const view = { lens, project: projectsNav.project, projectsView: projectsNav.view }; + const prevView = useRef(view); useEffect(() => { const next = buildHash(hashState); if (next === location.hash.slice(1)) return; const prev = prevView.current; - prevView.current = { lens }; - if (prev.lens !== lens) { + prevView.current = view; + if ( + prev.lens !== view.lens || + prev.project !== view.project || + prev.projectsView !== view.projectsView + ) { if (next) { // fires hashchange; the listener below re-reads idempotently location.hash = next; @@ -377,7 +430,8 @@ export function App() { } // buildHash's omission rule reads settings.defaultLens too (via // defaultLensFallback) even though it's not one of hashState's own - // fields, so it stays a separate dependency here. + // fields, so it stays a separate dependency here. `view` is built from + // hashState's own fields, so hashState covers it. }, [hashState, lens, settings.defaultLens]); useEffect(() => { const onHash = () => { @@ -389,6 +443,7 @@ export function App() { setShowAll(h.hidden); setReveal(h.reveal); setDraftsMode(h.drafts ?? getSettings().draftsMode); + setProjectsNav(h.projects); // the hash is the whole view, scope included: applying a saved // search (or walking history) must land its repos/authors too. // Primed, not persisted — same rule as opening a shared link. @@ -679,6 +734,29 @@ export function App() { extraBots, ]); + // The Projects tab counts every person's PR, drafts and hidden repos + // included: a project's size can't depend on one viewer's review + // preferences. The repo and people picks in the filter bar still narrow + // it, the way they narrow Stats. Bots are left out, as in the API. + const inProjectScope = useCallback( + (repo: string, login: string) => + (!scope.repos.length || scope.repos.includes(repo)) && + (!scope.authors.length || scope.authors.includes(login)) && + !scope.notAuthors.includes(login), + [scope] + ); + const projectPulls = useMemo( + () => pulls.filter(p => !isBot(p) && inProjectScope(p.data.repo, p.data.user.login)), + [pulls, isBot, inProjectScope] + ); + const projectClosed = useMemo( + () => + closed.filter( + p => !isBotLogin(p.user.login, extraBots) && inProjectScope(p.repo, p.user.login) + ), + [closed, extraBots, inProjectScope] + ); + // the hidden-PR ledger's numbers: stable per-category sizes (what each // rule covers, whether or not a session reveal currently shows it) plus // the live currently-hidden total for the trigger label @@ -733,6 +811,8 @@ export function App() { const goToLens = useCallback((id: Lens) => { setQuery(''); setLens(id); + // the Projects tab, clicked from a project page, goes back to the list + if (id === 'projects') setProjectsNav(cur => ({ ...cur, project: null })); }, []); // and the Team lens shows the result const onPerson = useCallback( @@ -1028,6 +1108,7 @@ export function App() { {tab('review', LENS_LABELS.review)} {tab('mine', LENS_LABELS.mine, mineCount)} {tab('team', LENS_LABELS.team)} + {projectLabelPrefix && tab('projects', LENS_LABELS.projects)} {tab('classic', LENS_LABELS.classic)} {tab('ci', LENS_LABELS.ci)} {tab('stats', LENS_LABELS.stats)} @@ -1036,11 +1117,13 @@ export function App() { className="sm:hidden" lens={lens} setLens={goToLens} - options={LENSES.map(id => ({ - id, - label: LENS_LABELS[id], - count: id === 'mine' ? mineCount : undefined, - }))} + options={LENSES.filter(id => id !== 'projects' || projectLabelPrefix).map( + id => ({ + id, + label: LENS_LABELS[id], + count: id === 'mine' ? mineCount : undefined, + }) + )} /> @@ -1220,6 +1303,26 @@ export function App() { )} {initialized && !searching && lens === 'ci' && } + {initialized && !searching && lens === 'projects' && ( + + Loading projects… + + } + > + + + )} {initialized && !searching && lens === 'stats' && ( )} diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts index 61d62b62..76d86469 100644 --- a/frontend-v2/src/backend/dummy.ts +++ b/frontend-v2/src/backend/dummy.ts @@ -1,4 +1,6 @@ import type { InitializePayload, PullData } from '../../../shared/types'; +import { utcDay, type Project } from '../../../shared/model/projects'; +import { addWeeks, mondayOf, type RoadmapItem } from '../../../shared/model/roadmap'; /** * Dummy mode: run the whole UI without a backend. @@ -43,7 +45,9 @@ export async function loadDummy(): Promise { ]; const withBody = (p: PullData, i: number): PullData => p.body === 'pull request dummy body' ? { ...p, body: BODIES[i % BODIES.length] } : p; - const pulls = withSyntheticStacks(raw).map((p, i) => withBody(withSizes(redate(p, i), i), i)); + const pulls = withSyntheticStacks(raw).map((p, i) => + withProject(withBody(withSizes(redate(p, i), i), i), i) + ); return { repos: [{ name: 'iFixit/ifixit' }], // config the live server delivers here too — a bot login and the weight @@ -56,6 +60,7 @@ export async function loadDummy(): Promise { 'size: L': 'L', 'size: XL': 'XL', }, + projectLabelPrefix: DUMMY_PROJECT_PREFIX, // The fixture carries almost no closed/merged pulls and no diff sizes, so // the Stats lens (merge-time-by-size, leaderboards over shipped work) has // nothing to show. Synthesize a fortnight of merged PRs across the size @@ -64,6 +69,203 @@ export async function loadDummy(): Promise { }; } +export const DUMMY_PROJECT_PREFIX = 'project:'; + +// The Projects tab reads project labels off the pulls, and the fixture +// predates them, so each pull gets one here by the theme its title already +// has (the Grafana dashboards, the webdriver deflakes...), keyed by index like +// everything else in this file. The picks keep every Today state on the bench: +// one person on a project (the Grafana four), a project whose PRs all wait on +// review (store-picker, its sign-offs cleared below), a lead with work in many +// other projects (type-refresh), a closed issue with a PR still open +// (akeneo-4), a label with no issue yet (core-primitives, the synthetic fork), +// one-offs, PRs not sorted yet, and one PR with two project labels. +// DUMMY_PROJECTS holds the issues behind them. synthMerged clones indexes +// 0-15, labels included, so those projects get recent merges too. +const DUMMY_PROJECT_PULLS: Record = { + 'webdriver-deflake': [0, 9, 17, 19, 32, 35, 48, 55], + 'grafana-dashboards': [21, 30, 31, 41], + 'training-periods': [8, 27, 37], + 'release-gate-sso': [5, 6, 7], + // 10-12 are the viewer's stacked chain: one chain, one project + 'shopify-sync': [10, 11, 12, 40], + 'core-primitives': [13, 14, 15], + 'shipping-shelf-weight': [26], + 'mysql-8': [23, 34, 50], + 'akeneo-4': [25, 56, 57], + 'newsletter-promo': [44, 45], + 'store-picker': [16, 36], + 'type-refresh': [1, 24, 42], + misc: [3, 18, 20, 29, 43, 47, 53], +}; +const SECOND_PROJECT: Record = { 50: 'webdriver-deflake' }; +const ALL_WAITING_ON_REVIEW = new Set(DUMMY_PROJECT_PULLS['store-picker']); +const projectsByIndex = new Map(); +for (const [slug, indexes] of Object.entries(DUMMY_PROJECT_PULLS)) + for (const i of indexes) projectsByIndex.set(i, [...(projectsByIndex.get(i) ?? []), slug]); +for (const [i, slug] of Object.entries(SECOND_PROJECT)) + projectsByIndex.set(Number(i), [...(projectsByIndex.get(Number(i)) ?? []), slug]); + +function withProject(pull: PullData, i: number): PullData { + const out: PullData = ALL_WAITING_ON_REVIEW.has(i) + ? { + ...pull, + draft: false, + status: { ...pull.status, allCR: [], allQA: [], dev_block: [], deploy_block: [] }, + } + : pull; + const slugs = projectsByIndex.get(i); + if (!slugs) return out; + const labels = slugs.map(slug => ({ + title: DUMMY_PROJECT_PREFIX + slug, + number: pull.number, + repo: pull.repo, + user: 'projects-bot[bot]', + created_at: pull.created_at, + })); + return { ...out, labels: [...out.labels, ...labels] }; +} + +const inDays = (days: number) => new Date(Date.now() + days * 86400_000).toISOString(); + +/** Developer teams, as the server's config.js `projects.developerTeams` sends + * them. Everyone else in the fixture shows as a non-developer, so the People + * view and the developer split on every project demo both kinds. */ +export const DUMMY_TEAMS: Record = { + Store: ['danielbeardsley', 'jarstelfox', 'sctice', 'zdmitchell'], + FixBot: ['mlahargou', 'ardelato', 'BaseInfinity'], + Community: ['rjmccluskey', 'sivadnor', 'hackalot805', 'djmetzle'], +}; +const dummyProject = ( + number: number, + slug: string, + name: string, + over: Partial = {} +): Project => ({ + slug, + name, + repo: 'iFixit/projects', + number, + state: 'open', + state_reason: null, + ongoing: false, + parents: [], + lead: null, + target: null, + created_at: inDays(-60 + number * 3), + closed_at: over.state === 'closed' ? inDays(-number) : null, + ...over, +}); + +/** The project issues behind the dummy labels, as /projects-data sends them. + * translations and onboarding-emails have no PRs (they land in quiet), and + * old-checkout was dropped, so Today never shows it. */ +export const DUMMY_PROJECTS: Project[] = [ + dummyProject(1, 'webdriver-deflake', 'Deflake the webdriver tests', { + ongoing: true, + lead: 'mlahargou', + parents: ['ci'], + }), + dummyProject(2, 'grafana-dashboards', 'Grafana dashboards for content', { lead: 'sivadnor' }), + dummyProject(3, 'training-periods', 'Training periods', { + lead: 'hackalot805', + target: { title: 'October', due_on: inDays(30) }, + }), + dummyProject(4, 'release-gate-sso', 'SSO approvals for releases', { + parents: ['security'], + lead: 'rjmccluskey', + target: { title: 'Security review', due_on: inDays(-5) }, + }), + dummyProject(5, 'shopify-sync', 'Shopify product and order sync', { + lead: 'zdmitchell', + parents: ['store'], + }), + dummyProject(6, 'shipping-shelf-weight', 'Ship by shelf weight', { + parents: ['store', 'warehouse'], + }), + dummyProject(7, 'mysql-8', 'MySQL 8 upgrade', { + lead: 'evannoronha', + target: { title: 'Q4 infrastructure', due_on: inDays(75) }, + }), + dummyProject(8, 'akeneo-4', 'Akeneo 4 migration', { + state: 'closed', + state_reason: 'completed', + }), + dummyProject(9, 'newsletter-promo', 'Newsletter promo page'), + dummyProject(10, 'store-picker', 'Store picker', { parents: ['store'] }), + dummyProject(11, 'type-refresh', 'Type and spacing refresh', { lead: 'danielbeardsley' }), + dummyProject(12, 'translations', 'Translations upkeep', { ongoing: true }), + dummyProject(13, 'onboarding-emails', 'Onboarding emails'), + dummyProject(14, 'old-checkout', 'Old checkout cleanup', { + state: 'closed', + state_reason: 'not_planned', + }), +]; + +/** + * The roadmap a project manager might have laid out, relative to this week: + * linked items whose PRs draw over the plan (SSO approvals ran past its + * plan, Akeneo 4 is done), plans with no PRs yet (Checkout redesign, Search + * relevance), and several live projects left off it, so the "not on the + * roadmap" list demos too. + */ +export const DUMMY_ROADMAP: RoadmapItem[] = (() => { + const monday = mondayOf(utcDay(Date.now() / 1000)); + const at = (weeks: number) => addWeeks(monday, weeks); + const item = ( + id: number, + name: string, + start: number, + weeks: number, + over: Partial + ): RoadmapItem => ({ + id, + name, + project: null, + team: null, + lead: null, + status: 'planned', + start: at(start), + weeks, + priority: id - 1, + notes: '', + updated_by: 'danielbeardsley', + updated_at: Math.floor(Date.now() / 1000) - id * 3600, + ...over, + }); + return [ + item(1, 'SSO approvals for releases', -8, 6, { + project: 'release-gate-sso', + team: 'Community', + lead: 'rjmccluskey', + status: 'active', + notes: 'Security asked for this before the audit.', + }), + item(2, 'Shopify product and order sync', -4, 7, { + project: 'shopify-sync', + team: 'Store', + lead: 'zdmitchell', + status: 'active', + }), + item(3, 'Deflake the webdriver tests', -6, 12, { + project: 'webdriver-deflake', + team: 'FixBot', + lead: 'mlahargou', + status: 'active', + }), + item(4, 'Checkout redesign', 3, 8, { team: 'Store', lead: 'jarstelfox' }), + item(5, 'MySQL 8 upgrade', -2, 12, { + project: 'mysql-8', + team: 'Community', + lead: 'evannoronha', + status: 'active', + }), + item(6, 'Search relevance', 7, 6, { team: 'FixBot' }), + item(7, 'Translations upkeep', -1, 26, { project: 'translations', team: 'Community' }), + item(8, 'Akeneo 4 migration', -11, 8, { project: 'akeneo-4', team: 'Store', status: 'done' }), + ]; +})(); + // The fixture's signature timestamps are as frozen as its pulls; spread them // across the given window so stamp-timeline stats (review pulse, first-CR // latency) exercise realistically instead of reading a decade-old wall. diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx index 177b0549..13e8a642 100644 --- a/frontend-v2/src/components/Settings.tsx +++ b/frontend-v2/src/components/Settings.tsx @@ -19,6 +19,8 @@ import { useArmedConfirm } from './useArmedConfirm'; import { commitKeyHandler } from './useCommitOnEnter'; const LENS_OPTIONS: [string, string][] = Object.entries(LENS_LABELS); +// the Projects tab exists only on a server set up for projects +const LENS_OPTIONS_NO_PROJECTS = LENS_OPTIONS.filter(([id]) => id !== 'projects'); // Stats has no lanes, so it's left out of the per-lens lane-length list // (unlike LENS_OPTIONS above, which covers every tab including Stats) — the @@ -116,7 +118,7 @@ export function Settings({ }) { const [open, setOpen] = useState(false); const s = useSettings(); - const { refreshProgress } = usePulldasher(); + const { refreshProgress, projectLabelPrefix } = usePulldasher(); const panelRef = useRef(null); const triggerRef = useRef(null); const [refreshNote, setRefreshNote] = useState(''); @@ -252,7 +254,7 @@ export function Settings({ set({ defaultLens })} /> diff --git a/frontend-v2/src/lens.ts b/frontend-v2/src/lens.ts index b48a54b3..547531db 100644 --- a/frontend-v2/src/lens.ts +++ b/frontend-v2/src/lens.ts @@ -1,7 +1,7 @@ -export type Lens = 'review' | 'mine' | 'team' | 'classic' | 'ci' | 'stats'; +export type Lens = 'review' | 'mine' | 'team' | 'projects' | 'classic' | 'ci' | 'stats'; /** Canonical label for each lens — the single source every surface naming the - * six views reads from: the tab strip and phone dropdown (app.tsx / + * views reads from: the tab strip and phone dropdown (app.tsx / * LensMenu), saved-filter descriptions (model/savedFilters.ts), and the * Settings panel's lens pickers (components/Settings.tsx). One restated copy * (model/savedFilters.ts's old LENS_LABEL) drifted and was missing `ci`, @@ -10,6 +10,7 @@ export const LENS_LABELS: Record = { review: 'Review', mine: 'My work', team: 'Team', + projects: 'Projects', classic: 'Classic', ci: 'CI', stats: 'Stats', diff --git a/frontend-v2/src/model/portfolio.test.ts b/frontend-v2/src/model/portfolio.test.ts new file mode 100644 index 00000000..9d78f035 --- /dev/null +++ b/frontend-v2/src/model/portfolio.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import type { DerivedPull } from '../../../shared/model/status'; +import type { Project, ProjectGroup, ProjectWindow, Today } from '../../../shared/model/projects'; +import { + groupItems, + mainTeam, + matchesFind, + matchesStatus, + parseSort, + portfolioCsv, + portfolioItems, + sortItems, +} from './portfolio'; + +const NOW = Date.UTC(2026, 8, 29, 12); +const teams: Record = { dana: 'Store', erin: 'FixBot', finn: 'Store' }; +const teamOf = (login: string) => teams[login] ?? null; + +function project(slug: string, over: Partial = {}): Project { + return { + slug, + name: slug.toUpperCase(), + repo: 'iFixit/projects', + number: 1, + state: 'open', + state_reason: null, + ongoing: false, + parents: [], + lead: null, + target: null, + created_at: null, + closed_at: null, + ...over, + }; +} + +function group(slug: string, people: string[], statuses: string[] = [], p?: Project): ProjectGroup { + return { + slug, + project: p ?? null, + open: statuses.map(status => ({ status }) as unknown as DerivedPull), + merged: [], + people, + idleDays: statuses.length ? 3 : null, + lastActivity: null, + flags: [], + }; +} + +const w = (merged: number, toMerge: number | null = null): ProjectWindow => ({ + backlog_start: 0, + backlog_end: 0, + opened: 0, + merged, + closed: 0, + median_age_start_days: null, + median_age_end_days: null, + median_days_to_merge: toMerge, + developers: 0, + non_developers: 0, + first_opened: null, + last_closed: null, +}); + +const alpha = project('alpha', { + lead: 'dana', + parents: ['store'], + target: { title: 'October', due_on: new Date(NOW + 5 * 86_400_000).toISOString() }, +}); +const today: Today = { + live: [group('alpha', ['dana', 'kyle'], ['needs_cr', 'ready'], alpha), group('label-only', ['erin'], ['draft'])], + quiet: [group('beta', [], [], project('beta', { parents: ['store', 'warehouse'] }))], + misc: [], + unsorted: [], + doubleLabeled: [], +}; +const projects = [ + alpha, + project('beta', { parents: ['store', 'warehouse'] }), + project('gone', { state: 'closed', state_reason: 'not_planned' }), + project('shipped', { state: 'closed', state_reason: 'completed' }), +]; +const items = portfolioItems( + projects, + today, + { alpha: w(4, 2.5), shipped: w(7, 1), misc: w(9) }, + teamOf, + NOW +); +const bySlug = Object.fromEntries(items.map(i => [i.slug, i])); + +describe('portfolioItems', () => { + it('joins every source into one row per project, misc left out', () => { + expect(Object.keys(bySlug).sort()).toEqual(['alpha', 'beta', 'gone', 'label-only', 'shipped']); + expect(bySlug.alpha).toMatchObject({ + status: 'live', + lead: 'dana', + dueInDays: 5, + open: 2, + waiting: 1, + developers: ['dana'], + nonDevelopers: ['kyle'], + }); + expect(bySlug.alpha.window?.merged).toBe(4); + }); + + it('reads status off Today first, then the issue', () => { + expect(bySlug.beta.status).toBe('quiet'); + expect(bySlug.gone.status).toBe('dropped'); + expect(bySlug.shipped.status).toBe('done'); + expect(bySlug['label-only'].status).toBe('live'); + expect(bySlug['label-only'].name).toBe('label-only'); + }); +}); + +describe('filters', () => { + it('matches the status tabs', () => { + const pick = (f: string) => items.filter(i => matchesStatus(i, f)).map(i => i.slug).sort(); + expect(pick('live')).toEqual(['alpha', 'label-only']); + expect(pick('quiet')).toEqual(['beta']); + expect(pick('closed')).toEqual(['gone', 'shipped']); + expect(pick('all')).toHaveLength(5); + }); + + it('finds by name, slug, parent, or lead', () => { + expect(items.filter(i => matchesFind(i, 'WARE')).map(i => i.slug)).toEqual(['beta']); + expect(items.filter(i => matchesFind(i, 'dana')).map(i => i.slug)).toEqual(['alpha']); + }); +}); + +describe('sortItems', () => { + it('sorts by a column, and reverses with a minus', () => { + expect(sortItems(items, 'merged').map(i => i.slug).slice(0, 2)).toEqual(['shipped', 'alpha']); + expect(sortItems(items, '-merged').at(-1)?.slug).toBe('shipped'); + expect(parseSort('nonsense')).toEqual({ key: 'open', reversed: false }); + }); + + it('puts the soonest target first and projects without one last', () => { + expect(sortItems(items, 'target')[0].slug).toBe('alpha'); + }); +}); + +describe('groupItems', () => { + it('shows a two-parent project under both parents, with no parent last', () => { + const groups = groupItems(sortItems(items, 'name'), 'parent', teamOf); + expect(groups.map(g => [g.title, g.items.map(i => i.slug)])).toEqual([ + ['store', ['alpha', 'beta']], + ['warehouse', ['beta']], + ['No parent', ['gone', 'label-only', 'shipped']], + ]); + }); + + it('groups by the team most of a project’s developers are on', () => { + expect(mainTeam(bySlug.alpha, teamOf)).toBe('Store'); + expect(mainTeam(bySlug.beta, teamOf)).toBeNull(); + const groups = groupItems(items, 'team', teamOf); + expect(groups.at(-1)?.title).toBe('Only non-developers'); + }); +}); + +describe('portfolioCsv', () => { + it('quotes cells with commas and writes one row per project', () => { + const csv = portfolioCsv([{ ...bySlug.alpha, name: 'Alpha, the first' }]); + const [head, row] = csv.trim().split('\n'); + expect(head.startsWith('Project,Label slug,Status,Lead')).toBe(true); + expect(row.startsWith('"Alpha, the first",alpha,Live,dana,October,')).toBe(true); + }); +}); diff --git a/frontend-v2/src/model/portfolio.ts b/frontend-v2/src/model/portfolio.ts new file mode 100644 index 00000000..42ba3615 --- /dev/null +++ b/frontend-v2/src/model/portfolio.ts @@ -0,0 +1,299 @@ +import { + MISC_SLUG, + projectName, + type Project, + type ProjectFlag, + type ProjectGroup, + type ProjectWindow, + type Today, +} from '../../../shared/model/projects'; +import type { Status } from '../../../shared/model/status'; + +/** + * The portfolio: one row per project, for the person who plans the work + * rather than reviews it. It joins three sources the Projects tab already + * has: the project issues (name, lead, target, parents, open or closed), + * Today (open PRs, people, flags, from the live board), and the picked + * range's numbers (merged, days to merge, developers and non-developers). + * Pure, so the list, the timeline and the CSV export can't disagree. + */ + +export type ProjectStatus = 'live' | 'quiet' | 'done' | 'dropped'; + +export interface PortfolioItem { + slug: string; + name: string; + project: Project | null; + /** Today's group when the project is live or quiet */ + group: ProjectGroup | null; + status: ProjectStatus; + lead: string | null; + target: Project['target']; + /** whole days until the target's due date, negative once it's past */ + dueInDays: number | null; + /** parent slugs, from the issue's parent: labels */ + parents: string[]; + open: number; + /** open PRs waiting on a CR or QA */ + waiting: number; + /** people with an open PR or a merge in the last 14 days, split by team */ + developers: string[]; + nonDevelopers: string[]; + /** the picked range's numbers; null when nothing of it overlaps the range */ + window: ProjectWindow | null; + idleDays: number | null; + flags: ProjectFlag[]; +} + +const WAITING: Status[] = ['needs_cr', 'needs_recr', 'needs_qa']; +const DAY_MS = 86_400_000; + +/** Every project the tab knows about: issues, live or quiet groups, and + * labels seen in the range's history. Misc is one-offs, not a project. */ +export function portfolioItems( + projects: readonly Project[], + today: Today, + window: Record, + teamOf: (login: string) => string | null, + now: number = Date.now() +): PortfolioItem[] { + const bySlug = new Map(projects.map(p => [p.slug, p])); + const groups = new Map(); + for (const g of today.live) groups.set(g.slug, [g, 'live']); + for (const g of today.quiet) groups.set(g.slug, [g, 'quiet']); + const slugs = new Set([...bySlug.keys(), ...groups.keys(), ...Object.keys(window)]); + slugs.delete(MISC_SLUG); + return [...slugs].map(slug => { + const project = bySlug.get(slug) ?? null; + const [group, live] = groups.get(slug) ?? [null, null]; + const status: ProjectStatus = + live ?? + (project?.state === 'closed' + ? project.state_reason === 'not_planned' + ? 'dropped' + : 'done' + : 'quiet'); + const people = group?.people ?? []; + const due = project?.target?.due_on ? Date.parse(project.target.due_on) : NaN; + return { + slug, + name: group ? projectName(group) : (project?.name ?? slug), + project, + group, + status, + lead: project?.lead ?? null, + target: project?.target ?? null, + dueInDays: Number.isNaN(due) ? null : Math.ceil((due - now) / DAY_MS), + parents: project?.parents ?? [], + open: group?.open.length ?? 0, + waiting: group?.open.filter(p => WAITING.includes(p.status)).length ?? 0, + developers: people.filter(login => teamOf(login) != null), + nonDevelopers: people.filter(login => teamOf(login) == null), + window: window[slug] ?? null, + idleDays: group?.idleDays ?? null, + flags: group?.flags ?? [], + }; + }); +} + +/** The status tabs over the list, in the order they show. */ +export const STATUS_FILTERS: [string, string][] = [ + ['live', 'Live'], + ['quiet', 'Quiet'], + ['closed', 'Done or dropped'], + ['all', 'All'], +]; + +export function matchesStatus(item: PortfolioItem, filter: string): boolean { + if (filter === 'all') return true; + if (filter === 'closed') return item.status === 'done' || item.status === 'dropped'; + return item.status === filter; +} + +/** A search over the words a planner would type: the name, the slug, a + * parent, or the lead. */ +export function matchesFind(item: PortfolioItem, find: string): boolean { + const q = find.trim().toLowerCase(); + if (!q) return true; + return [item.name, item.slug, item.lead ?? '', ...item.parents].some(s => + s.toLowerCase().includes(q) + ); +} + +export type SortKey = + | 'name' + | 'status' + | 'lead' + | 'target' + | 'people' + | 'open' + | 'waiting' + | 'merged' + | 'toMerge' + | 'idle'; + +const STATUS_RANK: Record = { live: 0, quiet: 1, done: 2, dropped: 3 }; + +/** Each column's natural order: names A to Z, the soonest target first, + * and the biggest number first everywhere else. Nulls always sink. */ +const SORTS: Record number> = { + name: (a, b) => a.name.localeCompare(b.name), + status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status], + lead: (a, b) => nullsLast(a.lead, b.lead, (x, y) => x.localeCompare(y)), + target: (a, b) => nullsLast(a.dueInDays, b.dueInDays, (x, y) => x - y), + people: (a, b) => + b.developers.length + b.nonDevelopers.length - (a.developers.length + a.nonDevelopers.length), + open: (a, b) => b.open - a.open, + waiting: (a, b) => b.waiting - a.waiting, + merged: (a, b) => (b.window?.merged ?? 0) - (a.window?.merged ?? 0), + toMerge: (a, b) => + nullsLast( + a.window?.median_days_to_merge ?? null, + b.window?.median_days_to_merge ?? null, + (x, y) => y - x + ), + idle: (a, b) => nullsLast(a.idleDays, b.idleDays, (x, y) => y - x), +}; + +function nullsLast(a: T | null, b: T | null, cmp: (x: T, y: T) => number): number { + if (a == null) return b == null ? 0 : 1; + if (b == null) return -1; + return cmp(a, b); +} + +/** Parse a sort param: a column key, `-` in front to reverse it. */ +export function parseSort(raw: string | null): { key: SortKey; reversed: boolean } { + const reversed = !!raw?.startsWith('-'); + const key = (reversed ? (raw as string).slice(1) : raw) as SortKey; + return key in SORTS ? { key, reversed } : { key: 'open', reversed: false }; +} + +/** Sort a copy: the chosen column, then live before closed, then more open + * PRs, then the name, so ties never shuffle between renders. */ +export function sortItems(items: readonly PortfolioItem[], sort: string): PortfolioItem[] { + const { key, reversed } = parseSort(sort); + const by = SORTS[key]; + return [...items].sort( + (a, b) => + (reversed ? -by(a, b) : by(a, b)) || + SORTS.status(a, b) || + SORTS.open(a, b) || + SORTS.name(a, b) + ); +} + +export const GROUPINGS: [string, string][] = [ + ['none', 'No grouping'], + ['parent', 'Parent'], + ['lead', 'Lead'], + ['team', 'Team'], +]; + +/** + * The team a project mostly belongs to: the team most of its developers + * are on (ties to the name first alphabetically), or null when only + * non-developers have work in it. + */ +export function mainTeam( + item: PortfolioItem, + teamOf: (login: string) => string | null +): string | null { + const counts = new Map(); + for (const login of item.developers) { + const team = teamOf(login) as string; + counts.set(team, (counts.get(team) ?? 0) + 1); + } + return ( + [...counts].sort(([a, x], [b, y]) => y - x || a.localeCompare(b)).map(([team]) => team)[0] ?? + null + ); +} + +/** + * Split sorted items into titled groups, keeping each group's order. A + * project with two parents shows under both, since it serves both. Groups + * come in name order, with the no-value group last. + */ +export function groupItems( + items: readonly PortfolioItem[], + by: string, + teamOf: (login: string) => string | null, + nameOf: (slug: string) => string = slug => slug +): { title: string; items: PortfolioItem[] }[] { + if (by !== 'parent' && by !== 'lead' && by !== 'team') return [{ title: '', items: [...items] }]; + const none = { parent: 'No parent', lead: 'No lead', team: 'Only non-developers' }[by]; + const groups = new Map(); + const add = (title: string, item: PortfolioItem) => { + if (!groups.has(title)) groups.set(title, []); + groups.get(title)?.push(item); + }; + for (const item of items) { + if (by === 'parent') { + if (item.parents.length) for (const p of item.parents) add(nameOf(p), item); + else add(none, item); + } else if (by === 'lead') add(item.lead ?? none, item); + else add(mainTeam(item, teamOf) ?? none, item); + } + return [...groups] + .sort(([a], [b]) => (a === none ? 1 : b === none ? -1 : a.localeCompare(b))) + .map(([title, list]) => ({ title, items: list })); +} + +const STATUS_WORD: Record = { + live: 'Live', + quiet: 'Quiet', + done: 'Done', + dropped: 'Dropped', +}; + +export function statusWord(status: ProjectStatus): string { + return STATUS_WORD[status]; +} + +function csvCell(value: string | number | null): string { + const text = value == null ? '' : String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** The list as CSV, the columns a planner pastes into a sheet. */ +export function portfolioCsv(items: readonly PortfolioItem[]): string { + const head = [ + 'Project', + 'Label slug', + 'Status', + 'Lead', + 'Target', + 'Target due', + 'Parents', + 'Developers', + 'Non-developers', + 'Open PRs', + 'Waiting on review', + 'Opened in range', + 'Merged in range', + 'Closed in range', + 'Median days to merge', + 'Days the stalest PR sat', + 'Issue', + ]; + const rows = items.map(i => [ + i.name, + i.slug, + STATUS_WORD[i.status], + i.lead, + i.target?.title ?? null, + i.target?.due_on?.slice(0, 10) ?? null, + i.parents.join(' '), + i.developers.join(' '), + i.nonDevelopers.join(' '), + i.open, + i.waiting, + i.window?.opened ?? 0, + i.window?.merged ?? 0, + i.window?.closed ?? 0, + i.window?.median_days_to_merge ?? null, + i.idleDays, + i.project ? `https://github.com/${i.project.repo}/issues/${i.project.number}` : null, + ]); + return [head, ...rows].map(r => r.map(csvCell).join(',')).join('\n') + '\n'; +} diff --git a/frontend-v2/src/model/projectData.test.ts b/frontend-v2/src/model/projectData.test.ts new file mode 100644 index 00000000..5336b037 --- /dev/null +++ b/frontend-v2/src/model/projectData.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { chartWindow, previousRange, rangeDays, resolveRange } from './projectData'; + +// noon on 2026-09-29 where the test runs: presets count the reader's own days +const NOW = new Date(2026, 8, 29, 12).getTime() / 1000; + +describe('resolveRange', () => { + it('counts the last N days through today', () => { + expect(resolveRange('30d', NOW)).toEqual({ start: '2026-08-31', end: '2026-09-29' }); + expect(resolveRange('7d', NOW)).toEqual({ start: '2026-09-23', end: '2026-09-29' }); + }); + + it('covers the calendar presets an analytics tool offers', () => { + expect(resolveRange('month', NOW)).toEqual({ start: '2026-09-01', end: '2026-09-29' }); + expect(resolveRange('last-month', NOW)).toEqual({ start: '2026-08-01', end: '2026-08-31' }); + expect(resolveRange('quarter', NOW)).toEqual({ start: '2026-07-01', end: '2026-09-29' }); + expect(resolveRange('last-quarter', NOW)).toEqual({ + start: '2026-04-01', + end: '2026-06-30', + }); + expect(resolveRange('ytd', NOW)).toEqual({ start: '2026-01-01', end: '2026-09-29' }); + }); + + it('reaches back across a year boundary', () => { + const jan = new Date(2027, 0, 10, 12).getTime() / 1000; + expect(resolveRange('last-month', jan)).toEqual({ start: '2026-12-01', end: '2026-12-31' }); + expect(resolveRange('last-quarter', jan)).toEqual({ + start: '2026-10-01', + end: '2026-12-31', + }); + }); + + it('takes a custom window the server would take, and nothing else', () => { + expect(resolveRange('2026-09-01..2026-09-10', NOW)).toEqual({ + start: '2026-09-01', + end: '2026-09-10', + }); + expect(resolveRange('2026-09-10..2026-09-01', NOW)).toBeNull(); + expect(resolveRange('2020-01-01..2026-09-10', NOW)).toBeNull(); + expect(resolveRange('2026-02-30..2026-03-10', NOW)).toBeNull(); + expect(resolveRange('500d', NOW)).toBeNull(); + expect(resolveRange('soon', NOW)).toBeNull(); + }); +}); + +describe('previousRange', () => { + it('is the same number of days just before', () => { + const range = { start: '2026-08-31', end: '2026-09-29' }; + expect(rangeDays(range)).toBe(30); + expect(previousRange(range)).toEqual({ start: '2026-08-01', end: '2026-08-30' }); + }); +}); + +describe('chartWindow', () => { + it('stretches a short range back to 90 days and leaves a long one alone', () => { + expect(chartWindow({ start: '2026-09-01', end: '2026-09-29' })).toEqual({ + start: '2026-07-02', + end: '2026-09-29', + }); + expect(chartWindow({ start: '2026-03-01', end: '2026-09-29' })).toEqual({ + start: '2026-03-01', + end: '2026-09-29', + }); + }); +}); diff --git a/frontend-v2/src/model/projectData.ts b/frontend-v2/src/model/projectData.ts new file mode 100644 index 00000000..2e40b5ba --- /dev/null +++ b/frontend-v2/src/model/projectData.ts @@ -0,0 +1,239 @@ +import { useEffect, useState } from 'react'; +import { DUMMY_PROJECTS, DUMMY_TEAMS, isDummy, loadDummy } from '../backend/dummy'; +import { epoch } from '../../../shared/format'; +import { + dayStart, + projectOf, + utcDay, + windowStats, + type Project, + type PullSpan, + type ReviewSpan, + type WindowStats, +} from '../../../shared/model/projects'; +import { isSuffixBot } from '../../../shared/model/visibility'; + +/** + * The Projects tab's server data: the project issues and one window's + * numbers, from GET /projects-data. Only what the socket doesn't carry: Today + * itself is built from the live socket pulls. + */ + +export interface ProjectsData { + label_prefix: string; + projects_repo: string | null; + projects: Project[]; + /** developer teams from the server's config: team name to logins. + * Anyone not listed is a non-developer. */ + teams: Record; + window: WindowStats; +} + +/** A login's developer team, or null for a non-developer. GitHub logins + * compare case-insensitively, as on GitHub. */ +export function teamLookup(teams: Record): (login: string) => string | null { + const byLogin = new Map(); + for (const [team, logins] of Object.entries(teams)) + for (const login of logins) byLogin.set(login.toLowerCase(), team); + return login => byLogin.get(login.toLowerCase()) ?? null; +} + +const DAY = 86400; +/** the server's cap on one window (lib/projects.js MAX_WINDOW_DAYS) */ +export const MAX_RANGE_DAYS = 400; + +/** The presets a date picker in any analytics tool offers, in its order. */ +export const RANGE_PRESETS: [string, string][] = [ + ['7d', 'Last 7 days'], + ['30d', 'Last 30 days'], + ['90d', 'Last 90 days'], + ['month', 'This month'], + ['last-month', 'Last month'], + ['quarter', 'This quarter'], + ['last-quarter', 'Last quarter'], + ['ytd', 'Year to date'], +]; +export const DEFAULT_RANGE = '30d'; + +export interface Range { + /** first and last day, both counted */ + start: string; + end: string; +} + +/** A local Date's calendar day, YYYY-MM-DD. */ +export function dayOf(date: Date): string { + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${m}-${d}`; +} + +/** A YYYY-MM-DD day as a local Date at midnight, for the calendar. */ +export function dateOf(day: string): Date { + const [y, m, d] = day.split('-').map(Number); + return new Date(y, m - 1, d); +} + +/** How many days a range covers, both ends counted. */ +export function rangeDays({ start, end }: Range): number { + return Math.round(((dayStart(end) as number) - (dayStart(start) as number)) / DAY) + 1; +} + +/** The same number of days just before a range: what a tile compares with. */ +export function previousRange(range: Range): Range { + const n = rangeDays(range); + const end = (dayStart(range.start) as number) - DAY; + return { start: utcDay(end - (n - 1) * DAY), end: utcDay(end) }; +} + +/** + * A range key's days. Presets count calendar days where the reader is, so + * "Last 30 days" ends on the day their own calendar shows; the server counts + * the same labels as UTC days, a few hours off at each edge. A custom key is + * `YYYY-MM-DD..YYYY-MM-DD`. Null for anything else, including a window the + * server would refuse (more than 400 days). + */ +export function resolveRange(key: string, now: number = Date.now() / 1000): Range | null { + const t = new Date(now * 1000); + const [y, m, d] = [t.getFullYear(), t.getMonth(), t.getDate()]; + const today = dayOf(t); + const q = Math.floor(m / 3) * 3; + const lastDays = /^(\d+)d$/.exec(key); + if (lastDays) { + const n = Number(lastDays[1]); + if (n < 1 || n > MAX_RANGE_DAYS) return null; + return { start: dayOf(new Date(y, m, d - (n - 1))), end: today }; + } + switch (key) { + case 'month': + return { start: dayOf(new Date(y, m, 1)), end: today }; + case 'last-month': + return { start: dayOf(new Date(y, m - 1, 1)), end: dayOf(new Date(y, m, 0)) }; + case 'quarter': + return { start: dayOf(new Date(y, q, 1)), end: today }; + case 'last-quarter': + return { start: dayOf(new Date(y, q - 3, 1)), end: dayOf(new Date(y, q, 0)) }; + case 'ytd': + return { start: dayOf(new Date(y, 0, 1)), end: today }; + } + const custom = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/.exec(key); + if (custom) { + const [, start, end] = custom; + const from = dayStart(start); + const to = dayStart(end); + if (from == null || to == null || from > to || (to - from) / DAY + 1 > MAX_RANGE_DAYS) + return null; + return { start, end }; + } + return null; +} + +/** "Last 30 days" for a preset, "Custom range" for anything picked by hand. */ +export function rangeName(key: string): string { + return RANGE_PRESETS.find(([k]) => k === key)?.[1] ?? 'Custom range'; +} + +/** "Aug 31 to Sep 29", with the year only when the range leaves this one. */ +export function rangeWords({ start, end }: Range, now: number = Date.now() / 1000): string { + const thisYear = String(new Date(now * 1000).getFullYear()); + const word = (day: string) => + dateOf(day).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: day.slice(0, 4) === thisYear ? undefined : 'numeric', + }); + return start === end ? word(start) : `${word(start)} to ${word(end)}`; +} + +/** A trend wants more days than the numbers do, so a chart never shows less. */ +export const CHART_MIN_DAYS = 90; + +/** + * The days a chart draws for a picked range: the range itself, stretched back + * to CHART_MIN_DAYS so a short range still shows its trend. The charts dim the + * days outside the range, so the numbers' days stay the bright part. + */ +export function chartWindow(range: Range): Range { + const earliest = utcDay((dayStart(range.end) as number) - (CHART_MIN_DAYS - 1) * DAY); + return { start: range.start < earliest ? range.start : earliest, end: range.end }; +} + +// the dummy board has no server: run the real windowStats over its own +// pulls, with their sign-offs standing in for the review history +async function dummyData({ start, end }: Range, project: string | null): Promise { + const { pulls, bots, projectLabelPrefix } = await loadDummy(); + const prefix = projectLabelPrefix ?? 'project:'; + const people = pulls.filter( + p => + !isSuffixBot(p.user.login) && + !(bots ?? []).includes(p.user.login) && + (project == null || projectOf(p.labels, prefix) === project) + ); + const spans: PullSpan[] = people.map(p => ({ + author: p.user.login, + project: projectOf(p.labels, prefix), + opened: epoch(p.created_at), + closed: p.closed_at ? epoch(p.closed_at) : null, + merged: !!p.merged_at, + })); + const reviews: ReviewSpan[] = people.flatMap(p => + [...p.status.allCR, ...p.status.allQA].map(sig => ({ + reviewer: sig.data.user.login, + author: p.user.login, + at: epoch(sig.data.created_at), + })) + ); + return { + label_prefix: prefix, + projects_repo: 'iFixit/projects', + projects: DUMMY_PROJECTS, + teams: DUMMY_TEAMS, + window: windowStats(spans, start, end, { teamOf: teamLookup(DUMMY_TEAMS), reviews }), + }; +} + +function load(range: Range, project: string | null): Promise { + if (isDummy()) return dummyData(range, project); + const query = new URLSearchParams({ start: range.start, end: range.end }); + if (project != null) query.set('project', project); + return fetch(`/projects-data?${query}`) + .then(r => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); +} + +// one fetch per window, reused for 5 minutes: switching tabs and back, or the +// range view and Today asking for the same window, costs nothing +const TTL_MS = 5 * 60_000; +const cache = new Map }>(); + +/** + * One window's projects data, or one project's with `project`: undefined + * while it loads, null if the fetch failed (the caller says so and moves on). + * A failure isn't cached, so the next mount tries again. + */ +export function useProjectsData( + range: Range | null, + project: string | null = null +): ProjectsData | null | undefined { + const key = range ? `${range.start}..${range.end}:${project ?? ''}` : ''; + const [got, setGot] = useState<{ key: string; data: ProjectsData | null }>(); + useEffect(() => { + if (!range) return; + let hit = cache.get(key); + if (!hit || Date.now() - hit.at > TTL_MS) { + hit = { at: Date.now(), data: load(range, project) }; + cache.set(key, hit); + } + let live = true; + hit.data.then(data => { + if (data == null) cache.delete(key); + if (live) setGot({ key, data }); + }); + return () => { + live = false; + }; + // keyed on the days, not the range object, which callers rebuild + // every render + }, [key]); + return got && got.key === key ? got.data : undefined; +} diff --git a/frontend-v2/src/model/roadmapData.ts b/frontend-v2/src/model/roadmapData.ts new file mode 100644 index 00000000..44342054 --- /dev/null +++ b/frontend-v2/src/model/roadmapData.ts @@ -0,0 +1,210 @@ +import { useEffect } from 'react'; +import { DUMMY_ROADMAP, isDummy } from '../backend/dummy'; +import { createMemoryStore } from '../storage'; +import { + checkRoadmapFields, + mondayOf, + type RoadmapFields, + type RoadmapItem, +} from '../../../shared/model/roadmap'; +import { utcDay } from '../../../shared/model/projects'; + +/** + * The roadmap's state on the board: the items (null until the first load), + * and the last problem saving, in words. Changes show at once and are rolled + * back if the server says no, so dragging a bar never waits on a round trip. + * Other people's changes arrive on the next load: every minute while the + * roadmap is open, and whenever the window regains focus. + */ +interface RoadmapState { + items: RoadmapItem[] | null; + loadFailed: boolean; + problem: string | null; +} + +const store = createMemoryStore({ items: null, loadFailed: false, problem: null }); + +const byPriority = (items: RoadmapItem[]) => + [...items].sort((a, b) => a.priority - b.priority || a.id - b.id); + +type Reply = { status: number; json: Record }; + +interface Api { + list: () => Promise; + create: (fields: Partial) => Promise; + update: (id: number, fields: Partial) => Promise; + remove: (id: number) => Promise; + reorder: (ids: number[]) => 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), + }); + // a dead session redirects toward GitHub's sign-in page, which isn't JSON + const json = (await res.json().catch(() => ({}))) as Record; + return { status: res.redirected ? 401 : res.status, json }; + }; + return { + list: () => send('GET', '/roadmap'), + create: fields => send('POST', '/roadmap', fields), + update: (id, fields) => send('PATCH', `/roadmap/${id}`, fields), + remove: id => send('DELETE', `/roadmap/${id}`), + reorder: ids => send('PUT', '/roadmap/order', { ids }), + }; +} + +/** The dummy board's stand-in for the server: the same checks, a table in + * memory, gone on reload. */ +function dummyApi(): Api { + let rows = DUMMY_ROADMAP.map(item => ({ ...item })); + let nextId = Math.max(0, ...rows.map(r => r.id)) + 1; + const ok = (json: Record, status = 200) => Promise.resolve({ status, json }); + const bad = (error: string, status = 400) => ok({ error }, status); + const touch = { updated_by: 'danielbeardsley', updated_at: Math.floor(Date.now() / 1000) }; + return { + list: () => ok({ items: byPriority(rows) }), + create: fields => { + const checked = checkRoadmapFields(fields, { partial: false }); + if ('error' in checked) return bad(checked.error); + const f = checked.fields; + const item: RoadmapItem = { + id: nextId++, + name: f.name ?? '', + project: f.project ?? null, + team: f.team ?? null, + lead: f.lead ?? null, + status: f.status ?? 'planned', + start: f.start ?? mondayOf(utcDay(Date.now() / 1000)), + weeks: f.weeks ?? 4, + notes: f.notes ?? '', + priority: Math.max(-1, ...rows.map(r => r.priority)) + 1, + ...touch, + }; + rows.push(item); + return ok({ item }, 201); + }, + update: (id, fields) => { + const checked = checkRoadmapFields(fields, { partial: true }); + if ('error' in checked) return bad(checked.error); + const row = rows.find(r => r.id === id); + if (!row) return bad('no such roadmap item', 404); + Object.assign(row, checked.fields, touch); + return ok({ item: { ...row } }); + }, + remove: id => { + const before = rows.length; + rows = rows.filter(r => r.id !== id); + return rows.length < before ? ok({ ok: true }) : bad('no such roadmap item', 404); + }, + reorder: ids => { + ids.forEach((id, i) => { + const row = rows.find(r => r.id === id); + if (row) row.priority = i; + }); + return ok({ items: byPriority(rows) }); + }, + }; +} + +const api = isDummy() ? 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.`; +} + +export async function loadRoadmap(): Promise { + try { + const reply = await api.list(); + if (reply.status !== 200) throw new Error(String(reply.status)); + store.set({ ...store.get(), items: reply.json.items as RoadmapItem[], loadFailed: false }); + } catch { + store.set({ ...store.get(), loadFailed: true }); + } +} + +/** Change the items on screen now; hand back a way to put them back. */ +function optimistic(change: (items: RoadmapItem[]) => RoadmapItem[]): () => void { + const before = store.get().items; + store.set({ ...store.get(), items: change(before ?? []), problem: null }); + return () => store.set({ ...store.get(), items: before }); +} + +function settle(reply: Reply, doing: string, undo: () => void): boolean { + if (reply.status >= 200 && reply.status < 300) return true; + undo(); + store.set({ ...store.get(), problem: problemOf(reply, doing) }); + return false; +} + +/** Add an item at the bottom. Resolves to it, or null when the server said no. */ +export async function createRoadmapItem(fields: Partial): Promise { + const reply = await api.create(fields).catch((): Reply => ({ status: 0, json: {} })); + // nothing was shown early, so there's nothing to take back + if (!settle(reply, 'add it', () => undefined)) return null; + const item = reply.json.item as RoadmapItem; + store.set({ ...store.get(), items: byPriority([...(store.get().items ?? []), item]) }); + return item; +} + +export async function updateRoadmapItem(id: number, fields: Partial): Promise { + const undo = optimistic(items => items.map(i => (i.id === id ? { ...i, ...fields } : i))); + const reply = await api.update(id, fields).catch((): Reply => ({ status: 0, json: {} })); + if (!settle(reply, 'save that', undo)) return false; + const saved = reply.json.item as RoadmapItem; + store.set({ + ...store.get(), + items: (store.get().items ?? []).map(i => (i.id === id ? saved : i)), + }); + return true; +} + +export async function removeRoadmapItem(id: number): Promise { + const undo = optimistic(items => items.filter(i => i.id !== id)); + const reply = await api.remove(id).catch((): Reply => ({ status: 0, json: {} })); + return settle(reply, 'remove it', undo); +} + +/** Put the items in this order, top first. If someone else changed the + * roadmap meanwhile, the server's list wins and the person hears why. */ +export async function reorderRoadmap(ids: number[]): Promise { + const undo = optimistic(items => + byPriority(items.map(i => ({ ...i, priority: ids.indexOf(i.id) }))) + ); + const reply = await api.reorder(ids).catch((): Reply => ({ status: 0, json: {} })); + if (reply.status === 409) { + store.set({ + ...store.get(), + items: reply.json.items as RoadmapItem[], + problem: 'Someone changed the roadmap while you were dragging; this is it now.', + }); + return; + } + if (settle(reply, 'save the new order', undo)) { + store.set({ ...store.get(), items: reply.json.items as RoadmapItem[] }); + } +} + +export function dismissRoadmapProblem(): void { + store.set({ ...store.get(), problem: null }); +} + +/** The roadmap, loaded now and kept fresh while a view shows it. */ +export function useRoadmap(): RoadmapState { + useEffect(() => { + void loadRoadmap(); + const every = setInterval(() => void loadRoadmap(), 60_000); + const onFocus = () => void loadRoadmap(); + window.addEventListener('focus', onFocus); + return () => { + clearInterval(every); + window.removeEventListener('focus', onFocus); + }; + }, []); + return store.useValue(); +} diff --git a/frontend-v2/src/store.ts b/frontend-v2/src/store.ts index 57831b53..5ead264b 100644 --- a/frontend-v2/src/store.ts +++ b/frontend-v2/src/store.ts @@ -22,6 +22,10 @@ export interface Snapshot { pulls: DerivedPull[]; /** bot logins beyond the `[bot]` suffix, from server config (initialize) */ extraBots: ReadonlySet; + /** the label prefix that files a PR into a project, from server config + * (initialize); null when the server isn't set up for projects, which + * hides the Projects tab */ + projectLabelPrefix: string | null; /** merged/closed in the last 14 days (the server's retention window) */ closed: PullData[]; me: string; @@ -52,6 +56,7 @@ let repoSpecs: RepoSpec[] = []; // reference invalidates the derive cache so weights re-resolve when it arrives. let weightLabels: ReadonlyMap = new Map(); let extraBots: ReadonlySet = new Set(); +let projectLabelPrefix: string | null = null; let me = ''; let connection: ConnectionState = 'connecting'; let initialized = false; @@ -213,6 +218,7 @@ export function isSnoozed( let snapshot: Snapshot = { pulls: [], extraBots, + projectLabelPrefix, closed: [], me, connection, @@ -271,6 +277,7 @@ function publish() { (a, b) => (Date.parse(b.closed_at ?? '') || 0) - (Date.parse(a.closed_at ?? '') || 0) ), extraBots, + projectLabelPrefix, me, connection, initialized, @@ -322,6 +329,7 @@ function start() { // server-owned config rides with the board (see shared/types) weightLabels = parseWeightLabels(payload.weightLabels); extraBots = new Set(payload.bots ?? []); + projectLabelPrefix = payload.projectLabelPrefix || null; for (const p of payload.pulls) raw.set(pullKey(p), p); initialized = true; // a reconnect resends everything; counting it as refresh progress diff --git a/frontend-v2/src/styles.css b/frontend-v2/src/styles.css index 01bcafc4..8c2b6784 100644 --- a/frontend-v2/src/styles.css +++ b/frontend-v2/src/styles.css @@ -968,3 +968,16 @@ input[type="checkbox"] { border: 1px solid var(--secondary); padding: 3px 8px; } + +/* The Projects date range calendar (react-day-picker). Its stylesheet takes + colors and sizes from --rdp-* variables, which RangeCalendar.tsx points at + the tokens above; these rules undo the two sizes it hard-codes, so picked + days and month names stay at the board's text size. */ +.pd-calendar .rdp-selected { + font-size: inherit; + font-weight: 600; +} +.pd-calendar .rdp-month_caption { + font-size: 13px; + font-weight: 600; +} diff --git a/frontend-v2/src/views/Projects.tsx b/frontend-v2/src/views/Projects.tsx new file mode 100644 index 00000000..7bb3433c --- /dev/null +++ b/frontend-v2/src/views/Projects.tsx @@ -0,0 +1,177 @@ +import { useMemo } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import type { DerivedPull } from '../../../shared/model/status'; +import type { PullData } from '../../../shared/types'; +import { buildToday, MISC_SLUG, type Today } from '../../../shared/model/projects'; +import { EmptyState, Segmented } from '../components/bits'; +import { Icon } from '../components/Icon'; +import type { RowOptions } from '../components/Row'; +import { + DEFAULT_RANGE, + previousRange, + resolveRange, + teamLookup, + useProjectsData, + type Range, +} from '../model/projectData'; +import { portfolioItems } from '../model/portfolio'; +import { DateRangePicker } from './projects/DateRangePicker'; +import { Overview } from './projects/Overview'; +import { People } from './projects/People'; +import { ProjectPage } from './projects/ProjectPage'; +import { Roadmap } from './projects/Roadmap'; +import type { Navigate, ProjectsNav } from './projects/parts'; + +export type { ProjectsNav } from './projects/parts'; + +const VIEWS: [ProjectsNav['view'], string][] = [ + ['overview', 'Overview'], + ['roadmap', 'Roadmap'], + ['people', 'People'], +]; + +const NO_TODAY: Today = { live: [], quiet: [], misc: [], unsorted: [], doubleLabeled: [] }; + +/** + * The Projects tab, for the person who plans the work: an overview of every + * project (headline numbers, charts, and one sortable list), the roadmap + * (the plan, dragged into shape, with the PRs' real activity drawn over it), + * and the people (who is on what, and whether review keeps up). A job outside + * Pulldasher files each PR with a project label and keeps one GitHub issue per + * project; the roadmap is Pulldasher's own. Today is built here from the live + * socket pulls, so it moves with the board; the project issues and the + * history come from /projects-data. + */ +export function Projects({ + pulls, + closed, + prefix, + nav, + navigate, + opts, + me, + onPerson, +}: { + /** people's open PRs, narrowed by the repo and people filters only */ + pulls: DerivedPull[]; + /** people's PRs closed in the last 14 days, same narrowing */ + closed: PullData[]; + /** the project label prefix; null when the server isn't set up for projects */ + prefix: string | null; + nav: ProjectsNav; + navigate: Navigate; + opts: RowOptions; + me: string; + onPerson: (login: string) => void; +}) { + const rangeKey = resolveRange(nav.range) ? nav.range : DEFAULT_RANGE; + const range = resolveRange(rangeKey) as Range; + const data = useProjectsData(prefix ? range : null); + // the same days just before the range, for every "compared with" line + const prev = useProjectsData(prefix ? previousRange(range) : null); + const today = useMemo( + () => (prefix ? buildToday(data?.projects ?? [], pulls, closed, prefix) : NO_TODAY), + [data, pulls, closed, prefix] + ); + const teamOf = useMemo(() => teamLookup(data?.teams ?? {}), [data]); + const items = useMemo( + () => portfolioItems(data?.projects ?? [], today, data?.window.projects ?? {}, teamOf), + [data, today, teamOf] + ); + const nameOf = useMemo(() => { + const names = new Map(items.map(i => [i.slug, i.name])); + names.set(MISC_SLUG, 'One-offs'); + return (slug: string) => names.get(slug) ?? slug; + }, [items]); + + if (!prefix) { + return ( + + ); + } + // one toolbar for the tab: the way back or the view switch, and the date + // range picker wherever the page shows numbers for a range + const toolbar = ( +
+ {nav.project ? ( + + ) : ( + navigate({ view })} + /> + )} + {/* the roadmap has its own months and quarters; the range is for numbers */} + {(nav.project || nav.view !== 'roadmap') && ( + navigate({ range: key })} + /> + )} +
+ ); + return ( + <> + {toolbar} + {nav.project ? ( + + ) : nav.view === 'people' ? ( + + ) : nav.view === 'roadmap' ? ( + + ) : ( + + )} + + ); +} diff --git a/frontend-v2/src/views/projects/DateRangePicker.tsx b/frontend-v2/src/views/projects/DateRangePicker.tsx new file mode 100644 index 00000000..53df3a04 --- /dev/null +++ b/frontend-v2/src/views/projects/DateRangePicker.tsx @@ -0,0 +1,77 @@ +import { lazy, Suspense, useState } from 'react'; +import { CalendarDays, Check, ChevronDown } from 'lucide-react'; +import { Icon } from '../../components/Icon'; +import { Popover } from '../../components/Popover'; +import { RANGE_PRESETS, rangeName, rangeWords, type Range } from '../../model/projectData'; + +// the calendar and its stylesheet load the first time the panel opens +const RangeCalendar = lazy(() => import('./RangeCalendar')); + +/** + * The date range control, shaped like the one in any analytics tool: one + * button that names the range and its days, opening the presets beside a + * two-month calendar. A preset applies on click; a range picked on the + * calendar applies with Apply. + */ +export function DateRangePicker({ + rangeKey, + range, + onChange, +}: { + rangeKey: string; + range: Range; + onChange: (key: string) => void; +}) { + // remounting the Popover is how a pick closes it (LensMenu does the same): + // its children get no close callback + const [picks, setPicks] = useState(0); + const pick = (key: string) => { + onChange(key); + setPicks(p => p + 1); + }; + return ( + ( + + )} + > +
+
    + {RANGE_PRESETS.map(([key, label]) => ( +
  • + +
  • + ))} +
+
+ }> + pick(`${r.start}..${r.end}`)} /> + +
+
+
+ ); +} diff --git a/frontend-v2/src/views/projects/Overview.tsx b/frontend-v2/src/views/projects/Overview.tsx new file mode 100644 index 00000000..5492e90a --- /dev/null +++ b/frontend-v2/src/views/projects/Overview.tsx @@ -0,0 +1,234 @@ +import type { DerivedPull, Status } from '../../../../shared/model/status'; +import { n } from '../../../../shared/format'; +import type { Today } from '../../../../shared/model/projects'; +import { Fold, FoldRows, RestGroup } from '../../components/Lane'; +import type { RowOptions } from '../../components/Row'; +import { + chartWindow, + rangeDays, + rangeWords, + useProjectsData, + type ProjectsData, + type Range, +} from '../../model/projectData'; +import type { PortfolioItem } from '../../model/portfolio'; +import { StatsCard } from '../stats/parts'; +import { AllocationChart, BacklogFlowChart, ChartSlot } from './lazyCharts'; +import { Tile, versus, versusDays, type Navigate, type ProjectsNav } from './parts'; +import { Portfolio } from './Portfolio'; + +const WAITING: Status[] = ['needs_cr', 'needs_recr', 'needs_qa']; + +/** The headline numbers: what's live, what's waiting, and how the range + * compares with the same number of days before. */ +function Headline({ + today, + data, + prev, + range, + items, + teamOf, +}: { + today: Today; + data: ProjectsData; + prev: ProjectsData | null | undefined; + range: Range; + items: PortfolioItem[]; + teamOf: (login: string) => string | null; +}) { + const t = data.window.totals; + const before = prev?.window.totals; + const period = `${rangeDays(range)} days before`; + const openNow: DerivedPull[] = [ + ...today.live.flatMap(g => g.open), + ...today.misc, + ...today.unsorted, + ]; + const waiting = openNow.filter(p => WAITING.includes(p.status)); + const waitingOnOthers = waiting.filter(p => teamOf(p.data.user.login) == null).length; + const started = items.filter(i => i.window?.first_opened && i.window.first_opened >= range.start); + const finished = items.filter( + i => + i.status === 'done' && + i.project?.closed_at && + i.project.closed_at.slice(0, 10) >= range.start && + i.project.closed_at.slice(0, 10) <= range.end + ); + return ( +
+ + + + + + +
+ ); +} + +/** The backlog chart: at least 90 days ending on the range's last day, + * the days before the range paled. */ +function BacklogCard({ range }: { range: Range }) { + const shown = chartWindow(range); + const data = useProjectsData(shown); + return ( + + {data === null ? ( +

Couldn’t load the chart.

+ ) : ( +
+ + {data && } + +
+ )} +

+ Green is what merged or closed since the first day; blue on top is what was still + open, so the blue band’s height is the backlog that day. +

+
+ ); +} + +/** + * The planner's overview: the headline numbers, where the backlog and the + * merged work are going, and every project on one list. PRs outside any + * project close it out, since sorting them is someone's job too. + */ +export function Overview({ + today, + data, + prev, + range, + prefix, + items, + teamOf, + nameOf, + nav, + navigate, + opts, +}: { + today: Today; + data: ProjectsData | null | undefined; + prev: ProjectsData | null | undefined; + range: Range; + prefix: string; + items: PortfolioItem[]; + teamOf: (login: string) => string | null; + nameOf: (slug: string) => string; + nav: ProjectsNav; + navigate: Navigate; + opts: RowOptions; +}) { + // two-label PRs already sit in a project, so they aren't "outside" ones + const outside = today.misc.length + today.unsorted.length; + return ( +
+ {data === null && ( +

+ Couldn’t load the project issues or the range’s numbers. Projects show by label + until they load. +

+ )} + {data && ( + + + + )} +
+ + +
+ + {data && ( + navigate({ project: slug })} + /> + )} + +
+
+
+ + {outside + today.doubleLabeled.length > 0 && ( + + + + + + + + + + + + )} +
+ ); +} diff --git a/frontend-v2/src/views/projects/People.tsx b/frontend-v2/src/views/projects/People.tsx new file mode 100644 index 00000000..30d0b66f --- /dev/null +++ b/frontend-v2/src/views/projects/People.tsx @@ -0,0 +1,280 @@ +import type { ReactNode } from 'react'; +import { projectName, type PersonWindow, type Today } from '../../../../shared/model/projects'; +import { Rows, eyebrowText } from '../../components/Lane'; +import { rangeDays, rangeWords, type ProjectsData, type Range } from '../../model/projectData'; +import { PersonCell, StatsCard } from '../stats/parts'; +import { ChartSlot, SplitWeeksChart } from './lazyCharts'; +import { Tile, versus } from './parts'; + +/** Live projects one person can have work in before the list says so. */ +export const SPREAD_THIN = 4; + +interface PersonRow { + login: string; + team: string | null; + /** live projects they have an open PR or a recent merge in, by name */ + live: string[]; + openNow: number; + w: PersonWindow | null; +} + +/** + * Everyone the tab knows about: every developer team member from config + * (even with nothing in flight, since an idle developer is news too), and + * anyone else with a PR in the range or on a live project. + */ +function peopleRows( + data: ProjectsData, + today: Today, + teamOf: (login: string) => string | null +): PersonRow[] { + const live = new Map(); + for (const g of today.live) + for (const login of g.people) live.set(login, [...(live.get(login) ?? []), projectName(g)]); + const openNow = new Map(); + for (const p of [...today.live.flatMap(g => g.open), ...today.misc, ...today.unsorted]) + openNow.set(p.data.user.login, (openNow.get(p.data.user.login) ?? 0) + 1); + // one entry per person whatever case a login arrives in + const byKey = new Map(); + for (const login of [ + ...Object.values(data.teams).flat(), + ...Object.keys(data.window.people), + ...live.keys(), + ...openNow.keys(), + ]) + if (!byKey.has(login.toLowerCase())) byKey.set(login.toLowerCase(), login); + return [...byKey.values()].map(login => ({ + login, + team: teamOf(login), + live: (live.get(login) ?? []).sort(), + openNow: openNow.get(login) ?? 0, + w: data.window.people[login] ?? null, + })); +} + +const cols = 'grid grid-cols-[minmax(0,1fr)_6rem_4rem_4rem_4rem_5rem_6rem] items-center gap-3'; +const hideSmall = 'hidden sm:block'; + +function HeaderRow() { + const head = (label: string, title: string, extra = '') => ( + + {label} + + ); + return ( +
+ Person + {head('Live projects', 'Live projects they have an open PR or a recent merge in')} + {head('Open', 'Their open PRs now')} + {head('Opened', 'PRs they opened in the range', hideSmall)} + {head('Merged', 'PRs of theirs merged in the range', hideSmall)} + {head('Reviews', 'CR and QA stamps they gave on other people’s PRs in the range')} + {head('On others’', 'Of those reviews, the ones on non-developers’ PRs', hideSmall)} +
+ ); +} + +function PersonLine({ + row, + me, + onPerson, +}: { + row: PersonRow; + me: string; + onPerson: (login: string) => void; +}) { + const spread = row.live.length >= SPREAD_THIN; + const num = (n: number | undefined, extra = '') => ( + {n || ''} + ); + return ( +
+ + + + + {row.live.length || ''} + + {num(row.openNow)} + {num(row.w?.opened, hideSmall)} + {num(row.w?.merged, hideSmall)} + {num(row.w?.reviews)} + {num(row.w?.reviews_on_non_dev, hideSmall)} +
+ ); +} + +function Section({ + title, + rows, + me, + onPerson, +}: { + title: string; + rows: PersonRow[]; + me: string; + onPerson: (login: string) => void; +}) { + if (!rows.length) return null; + const sorted = [...rows].sort( + (a, b) => + b.live.length - a.live.length || + b.openNow - a.openNow || + (b.w?.merged ?? 0) - (a.w?.merged ?? 0) || + a.login.localeCompare(b.login) + ); + return ( + <> +
+ {title} · {rows.length} +
+ {sorted.map(row => ( + + ))} + + ); +} + +/** + * The people view: who is on what, and whether review keeps up. Developers + * sit in their teams from config; everyone else with PRs is a non-developer, + * whose work needs a developer's review. The two weekly charts show who + * opened the PRs and whose PRs the reviews went to, and the list puts anyone + * on four or more live projects in amber. + */ +export function People({ + data, + prev, + today, + range, + teamOf, + me, + onPerson, +}: { + data: ProjectsData | null | undefined; + prev: ProjectsData | null | undefined; + today: Today; + range: Range; + teamOf: (login: string) => string | null; + me: string; + onPerson: (login: string) => void; +}) { + if (data === undefined) return

Loading the numbers…

; + if (data === null) { + return ( +

+ Couldn’t load the numbers for this range. Try again in a minute. +

+ ); + } + const rows = peopleRows(data, today, teamOf); + const teams = Object.keys(data.teams); + const period = `${rangeDays(range)} days before`; + const sum = (pick: (w: PersonWindow) => number, dev: boolean, d: ProjectsData | null | undefined) => + Object.values(d?.window.people ?? {}) + .filter(w => (w.team != null) === dev) + .reduce((s, w) => s + pick(w), 0); + const opened = data.window.totals.opened; + const openedByOthers = sum(w => w.opened, false, data); + const reviews = sum(w => w.reviews, true, data); + const reviewsOnOthers = sum(w => w.reviews_on_non_dev, true, data); + const spread = rows.filter(r => r.live.length >= SPREAD_THIN).length; + const pct = (part: number, whole: number) => (whole ? Math.round((part / whole) * 100) : 0); + const noTeams = !teams.length; + const card = (title: string, children: ReactNode) => ( + +
{children}
+
+ ); + return ( +
+ {noTeams && ( +

+ No developer teams are set up (config.js, projects.developerTeams), so everyone shows + as a non-developer. +

+ )} + +
+ + w.reviews, true, prev) : null, period)} + /> + + +
+
+
+ {card( + 'Who opened PRs', + + [w.opened.developers, w.opened.non_developers]} + labels={['Developers', 'Non-developers']} + ariaLabel="PRs opened each week, by developers and by non-developers" + /> + + )} + {card( + 'Whose PRs got the reviews', + + [w.reviews.on_developers, w.reviews.on_non_developers]} + labels={['On developers’ PRs', 'On non-developers’ PRs']} + ariaLabel="Reviews given each week, split by whether a developer or a non-developer wrote the PR" + /> + + )} +
+
+

Everyone

+ + + {teams.map(team => ( +
r.team === team)} + me={me} + onPerson={onPerson} + /> + ))} +
r.team == null)} + me={me} + onPerson={onPerson} + /> + +
+
+ ); +} diff --git a/frontend-v2/src/views/projects/Portfolio.tsx b/frontend-v2/src/views/projects/Portfolio.tsx new file mode 100644 index 00000000..7c5b2e1a --- /dev/null +++ b/frontend-v2/src/views/projects/Portfolio.tsx @@ -0,0 +1,402 @@ +import type { ReactNode } from 'react'; +import { ArrowDown, ArrowUp, ChevronRight, Download } from 'lucide-react'; +import type { Today } from '../../../../shared/model/projects'; +import { Segmented, textInputClass } from '../../components/bits'; +import { Icon } from '../../components/Icon'; +import { eyebrowText, FoldRows, laneShown, Rows } from '../../components/Lane'; +import type { RowOptions } from '../../components/Row'; +import { + GROUPINGS, + matchesFind, + matchesStatus, + parseSort, + portfolioCsv, + sortItems, + STATUS_FILTERS, + groupItems, + statusWord, + type PortfolioItem, + type SortKey, +} from '../../model/portfolio'; +import { FlagWords, PageLink, ProjectFacts, type Navigate, type ProjectsNav } from './parts'; + +interface Column { + key: SortKey; + label: string; + /** what the column counts, for its header's hover */ + title: string; + width: string; + /** narrow screens drop the columns a planner reads least */ + hide?: string; + cell: (item: PortfolioItem) => ReactNode; +} + +const num = (n: number | null | undefined) => (n == null ? '' : n); + +function targetCell(item: PortfolioItem): ReactNode { + if (!item.target) return ''; + const due = item.target.due_on + ? new Date(item.target.due_on).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + : item.target.title; + const open = item.status === 'live' || item.status === 'quiet'; + const late = open && item.dueInDays != null && item.dueInDays < 0; + const when = + item.dueInDays == null + ? item.target.title + : late + ? `${-item.dueInDays} days past ${item.target.title}` + : `${item.target.title}, in ${item.dueInDays} days`; + // past due on a project still open is the one thing here someone owes + return ( + + {due} + + ); +} + +const COLUMNS: Column[] = [ + { + key: 'status', + label: 'Status', + title: 'Live: an open PR or a merge in the last 14 days. Quiet: open, nothing in flight.', + width: 'w-16', + cell: i => statusWord(i.status), + }, + { + key: 'lead', + label: 'Lead', + title: 'The assignee on the project’s issue', + width: 'w-24', + hide: 'hidden md:block', + cell: i => (i.lead ? {i.lead} : ''), + }, + { + key: 'target', + label: 'Target', + title: 'The due date of the milestone on the project’s issue', + width: 'w-20', + hide: 'hidden md:block', + cell: targetCell, + }, + { + key: 'people', + label: 'Devs · others', + title: + 'People with an open PR or a merge in the last 14 days: on a developer team, and everyone else', + width: 'w-20', + cell: i => + i.developers.length + i.nonDevelopers.length ? ( + + {i.developers.length} · {i.nonDevelopers.length} + + ) : ( + '' + ), + }, + { + key: 'open', + label: 'Open', + title: 'Open PRs now', + width: 'w-12', + cell: i => num(i.open || null), + }, + { + key: 'waiting', + label: 'Waiting', + title: 'Open PRs waiting on a CR or QA', + width: 'w-14', + cell: i => num(i.waiting || null), + }, + { + key: 'merged', + label: 'Merged', + title: 'PRs merged in the date range', + width: 'w-14', + cell: i => num(i.window?.merged || null), + }, + { + key: 'toMerge', + label: 'Days to merge', + title: 'Median days from opened to merged, over the PRs merged in the date range', + width: 'w-20', + hide: 'hidden lg:block', + cell: i => num(i.window?.median_days_to_merge), + }, + { + key: 'idle', + label: 'Stalest', + title: 'Days the open PR that has gone longest without a change has sat', + width: 'w-14', + hide: 'hidden lg:block', + // under a day is nothing to say + cell: i => (i.idleDays ? `${i.idleDays}d` : ''), + }, +]; + +function Cells({ item }: { item: PortfolioItem }) { + return ( + <> + {COLUMNS.map(c => ( + + {c.cell(item)} + + ))} + + ); +} + +const rowClass = + 'flex items-center gap-3 px-3.5 py-2 text-xs text-ink-2 transition-[background-color] duration-150 ease-out hover:bg-muted motion-reduce:transition-none'; + +/** + * One project on one line. Its name opens the project page; anywhere else + * on the row opens its PRs in place, board rows and all. A project with + * nothing open has nothing to open, so it's a plain line. + */ +function PortfolioRow({ + item, + today, + hasRepo, + opts, + navigate, +}: { + item: PortfolioItem; + today: Today; + hasRepo: boolean; + opts: RowOptions; + navigate: Navigate; +}) { + // the name wraps rather than truncating (text never hides on this board), + // and flags sit under it so they can't crowd it out + const name = ( + + + {!!item.group?.flags.length && ( + + + + )} + + ); + if (!item.group?.open.length) { + return ( +
+ + {name} + +
+ ); + } + return ( +
+ + + {name} + + +
+ + + + +
+
+ ); +} + +/** A column header that sorts by its column; a second click reverses it. */ +function SortHeader({ + label, + title, + sortKey, + sort, + className, + navigate, +}: { + label: string; + title: string; + sortKey: SortKey; + sort: string; + className: string; + navigate: Navigate; +}) { + const current = parseSort(sort); + const active = current.key === sortKey; + return ( + + ); +} + +function download(items: readonly PortfolioItem[]) { + const url = URL.createObjectURL(new Blob([portfolioCsv(items)], { type: 'text/csv' })); + const a = document.createElement('a'); + a.href = url; + a.download = `projects-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +/** + * Every project on one list: the portfolio view a planner starts from. Tabs + * pick which projects (live by default), a column header sorts, grouping + * splits by parent, lead or team, and the find box narrows by name, parent + * or lead. What's listed is what the CSV holds. All of it rides in the URL, + * so a view can be shared. + */ +export function Portfolio({ + items, + today, + teamOf, + nameOf, + hasRepo, + nav, + navigate, + opts, +}: { + items: PortfolioItem[]; + today: Today; + teamOf: (login: string) => string | null; + nameOf: (slug: string) => string; + hasRepo: boolean; + nav: ProjectsNav; + navigate: Navigate; + opts: RowOptions; +}) { + const found = items.filter(i => matchesFind(i, nav.find)); + const shown = sortItems( + found.filter(i => matchesStatus(i, nav.status)), + nav.sort + ); + const groups = groupItems(shown, nav.group, teamOf, nameOf); + const statusOptions: [string, string][] = STATUS_FILTERS.map(([key, label]) => [ + key, + `${label} ${found.filter(i => matchesStatus(i, key)).length}`, + ]); + return ( +
+
+

All projects

+ navigate({ status })} + /> + + navigate({ find: e.target.value })} + className={`w-[180px] px-2.5 ${textInputClass}`} + /> + + +
+ +
+ + + {COLUMNS.map(c => ( + + ))} +
+ {!shown.length && ( +
+ {nav.find ? 'No project matches that.' : 'No projects in this list.'} +
+ )} + {groups.map(g => ( +
+ {g.title && ( +
+ {g.title} · {g.items.length} +
+ )} + {g.items.map(item => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/frontend-v2/src/views/projects/ProjectPage.tsx b/frontend-v2/src/views/projects/ProjectPage.tsx new file mode 100644 index 00000000..461f93fa --- /dev/null +++ b/frontend-v2/src/views/projects/ProjectPage.tsx @@ -0,0 +1,272 @@ +import { pullKey } from '../../../../shared/format'; +import { + projectOf, + type ProjectGroup, + type ProjectWindow, + type Today, + type WindowCounts, +} from '../../../../shared/model/projects'; +import type { PullData } from '../../../../shared/types'; +import { EmptyState } from '../../components/bits'; +import { ClosedRow } from '../../components/ClosedRow'; +import { Fold, FoldRows, GroupHeader, RestGroup, Rows } from '../../components/Lane'; +import type { RowOptions } from '../../components/Row'; +import { + chartWindow, + dayOf, + rangeDays, + rangeWords, + useProjectsData, + type ProjectsData, + type Range, +} from '../../model/projectData'; +import { StatsCard } from '../stats/parts'; +import { BacklogFlowChart, ChartSlot } from './lazyCharts'; +import { FlagWords, PeopleStack, ProjectFacts, WindowTiles } from './parts'; + +const DAY_MS = 86_400_000; +/** how many trailing days set the pace a forecast runs on */ +const PACE_DAYS = 28; + +// a project with no PRs in the period before still compares, against zero +const ZERO: WindowCounts = { + backlog_start: 0, + backlog_end: 0, + opened: 0, + merged: 0, + closed: 0, + median_age_start_days: null, + median_age_end_days: null, + median_days_to_merge: null, + developers: 0, + non_developers: 0, +}; + +const dateWords = (ms: number) => + new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + +/** + * A rough finish date for a project with an end: its open PRs divided by how + * many it finished (merged or closed) a week over the last four. New PRs + * keep arriving, so it's a guide, and it says so; set against the target, + * it answers "will this land in time?" in one sentence. + */ +function Forecast({ + group, + days, + dueOn, +}: { + group: ProjectGroup; + days: { departed: number }[]; + dueOn: string | null; +}) { + const open = group.open.length; + if (!open || days.length < 2) return null; + const last = days[days.length - 1].departed; + const before = days[Math.max(0, days.length - 1 - PACE_DAYS)].departed; + const finished = last - before; + if (finished <= 0) { + return ( +

+ Nothing here merged or closed in the last four weeks, so there’s no pace to guess a + finish from. +

+ ); + } + const perWeek = finished / (PACE_DAYS / 7); + const weeks = Math.max(1, Math.round(open / perWeek)); + const eta = Date.now() + weeks * 7 * DAY_MS; + const due = dueOn ? Date.parse(dueOn) : NaN; + const late = !Number.isNaN(due) && eta > due; + return ( +

+ At the last four weeks’ pace ({finished} finished, about{' '} + {Math.round(perWeek * 10) / 10} a week), the {open} open PR{open === 1 ? '' : 's'} take + about {weeks} week{weeks === 1 ? '' : 's'}: around {dateWords(eta)}. + {!Number.isNaN(due) && ( + + {' '} + That’s {late ? 'after' : 'before'} the {dateWords(due)} target. + + )} +

+ ); +} + +/** The project's own backlog chart and forecast, on at least 90 days + * ending on the range's last day. */ +function ProjectFlow({ + slug, + range, + group, + ongoing, + dueOn, +}: { + slug: string; + range: Range; + group: ProjectGroup | undefined; + ongoing: boolean; + dueOn: string | null; +}) { + const shown = chartWindow(range); + const data = useProjectsData(shown, slug); + // a pace only means something when the range runs up to today + const current = range.end >= dayOf(new Date()); + return ( +
+ + + {data === null ? ( +

Couldn’t load the chart.

+ ) : ( + <> + {data && group && !ongoing && current && ( +
+ +
+ )} + + {data && } + + + )} +
+
+ ); +} + +/** + * One project's page: what its issue says, who is on it (developers and + * everyone else), its open PRs as board rows, what merged in the last 14 + * days, its numbers for the range against the days before, its backlog + * chart, and, for a project with an end, a rough finish date. Everything + * comes from GitHub, so there's nothing to edit here: the issue holds the + * name, lead, target and parents, and the labels hold the PRs. + */ +export function ProjectPage({ + slug, + today, + data, + prev, + range, + closed, + prefix, + teamOf, + opts, +}: { + slug: string; + today: Today; + /** undefined while the project issues load, null if that failed */ + data: ProjectsData | null | undefined; + prev: ProjectsData | null | undefined; + range: Range; + closed: PullData[]; + prefix: string; + teamOf: (login: string) => string | null; + opts: RowOptions; +}) { + const live = today.live.find(g => g.slug === slug); + const group = live ?? today.quiet.find(g => g.slug === slug); + const project = group?.project ?? data?.projects.find(p => p.slug === slug) ?? null; + // a closed project isn't on Today, so read its recent merges straight off + // the closed pulls (the server keeps 14 days of them) + const merged = + group?.merged ?? closed.filter(p => p.merged_at && projectOf(p.labels, prefix) === slug); + const w: ProjectWindow | undefined = data?.window.projects[slug]; + const before = prev === undefined ? undefined : (prev?.window.projects[slug] ?? ZERO); + if (!group && !project && !w && !merged.length) { + return data === undefined ? ( +

Loading the project…

+ ) : ( + + ); + } + const standing = live + ? 'Live' + : group + ? 'Quiet' + : project?.state === 'closed' + ? project.state_reason === 'not_planned' + ? 'Dropped' + : 'Done' + : 'Nothing in flight'; + const people = group?.people ?? []; + const devs = people.filter(login => teamOf(login) != null); + const others = people.filter(login => teamOf(login) == null); + return ( + <> +
+

{project?.name ?? slug}

+ {standing} + {prefix + slug} + {group && ( + + + + )} + {people.length > 0 && ( + + {devs.length > 0 && ( + + Developers + + )} + {others.length > 0 && ( + + Others + + )} + + )} +
+
+ + + {group && group.open.length > 0 ? ( + + ) : ( +
+ No open PRs. +
+ )} +
+
+ {merged.length > 0 && ( + + + {merged.map(p => ( + + ))} + + + )} + {w && ( +
+ + + + +
+ )} + + + ); +} diff --git a/frontend-v2/src/views/projects/RangeCalendar.tsx b/frontend-v2/src/views/projects/RangeCalendar.tsx new file mode 100644 index 00000000..a2e2b006 --- /dev/null +++ b/frontend-v2/src/views/projects/RangeCalendar.tsx @@ -0,0 +1,89 @@ +import { useState, type CSSProperties } from 'react'; +import { DayPicker, type DateRange } from 'react-day-picker'; +// eslint-import-resolver-node can't follow exports-only paths (the same +// reason vite.config.ts disables this for @tailwindcss/vite); Vite resolves it. +// eslint-disable-next-line import/no-unresolved +import 'react-day-picker/style.css'; +import { + dateOf, + dayOf, + MAX_RANGE_DAYS, + rangeDays, + rangeWords, + type Range, +} from '../../model/projectData'; + +// DayPicker styles itself through these variables; point them at the +// board's tokens so the calendar matches both themes without its own CSS +const CALENDAR_VARS = { + '--rdp-accent-color': 'var(--brand)', + '--rdp-accent-background-color': 'var(--brand-50)', + '--rdp-range_start-color': 'var(--surface)', + '--rdp-range_end-color': 'var(--surface)', + '--rdp-today-color': 'var(--brand)', + '--rdp-day-height': '32px', + '--rdp-day-width': '32px', + '--rdp-day_button-height': '30px', + '--rdp-day_button-width': '30px', + '--rdp-day_button-border-radius': '8px', + '--rdp-nav-height': '2rem', + '--rdp-nav_button-height': '1.75rem', + '--rdp-nav_button-width': '1.75rem', + '--rdp-months-gap': '1.25rem', +} as CSSProperties; + +/** + * Two months of calendar for picking a range by hand: 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. + * Loaded lazily with the picker's panel, stylesheet included. + */ +export default function RangeCalendar({ + range, + onApply, +}: { + range: Range; + onApply: (picked: Range) => void; +}) { + const [draft, setDraft] = useState({ + from: dateOf(range.start), + to: dateOf(range.end), + }); + const today = new Date(); + const end = dateOf(range.end); + const picked = + draft?.from && draft.to ? { start: dayOf(draft.from), end: dayOf(draft.to) } : null; + return ( +
+ +
+ + {picked + ? `${rangeWords(picked)} · ${rangeDays(picked)} days` + : 'Click the first day, then the last'} + + + +
+
+ ); +} diff --git a/frontend-v2/src/views/projects/Roadmap.tsx b/frontend-v2/src/views/projects/Roadmap.tsx new file mode 100644 index 00000000..d29e0d38 --- /dev/null +++ b/frontend-v2/src/views/projects/Roadmap.tsx @@ -0,0 +1,913 @@ +import { + useRef, + useState, + type DragEvent, + type KeyboardEvent, + type PointerEvent, + type ReactNode, +} from 'react'; +import { GripVertical, Plus } from 'lucide-react'; +import { dayStart, utcDay } from '../../../../shared/model/projects'; +import { + addWeeks, + checkRoadmapFields, + MAX_WEEKS, + mondayOf, + moveBefore, + planEnd, + ROADMAP_STATUSES, + type RoadmapFields, + type RoadmapItem, + type RoadmapStatus, +} from '../../../../shared/model/roadmap'; +import { Segmented, textInputClass } from '../../components/bits'; +import { Icon } from '../../components/Icon'; +import { eyebrowText, Rows } from '../../components/Lane'; +import { useArmedConfirm } from '../../components/useArmedConfirm'; +import { dayOf, useProjectsData, type Range } from '../../model/projectData'; +import { mainTeam, type PortfolioItem } from '../../model/portfolio'; +import { + createRoadmapItem, + dismissRoadmapProblem, + removeRoadmapItem, + reorderRoadmap, + updateRoadmapItem, + useRoadmap, +} from '../../model/roadmapData'; +import type { Navigate, ProjectsNav } from './parts'; + +const DAY = 86400; + +interface Column { + start: string; + /** the first day after it */ + end: string; + label: string; +} + +/** The roadmap's columns: seven months from last month, or six quarters + * from last quarter, so a little of the past sits beside the plan. */ +function columnsFor(scale: 'month' | 'quarter', now: Date): Column[] { + const y = now.getFullYear(); + const m = now.getMonth(); + if (scale === 'month') { + return Array.from({ length: 7 }, (_, i) => { + const a = new Date(y, m - 1 + i, 1); + return { + start: dayOf(a), + end: dayOf(new Date(y, m + i, 1)), + label: a.toLocaleDateString(undefined, { + month: 'short', + year: i === 0 || a.getMonth() === 0 ? 'numeric' : undefined, + }), + }; + }); + } + const q = Math.floor(m / 3) * 3; + return Array.from({ length: 6 }, (_, i) => { + const a = new Date(y, q - 3 + i * 3, 1); + return { + start: dayOf(a), + end: dayOf(new Date(y, q + i * 3, 1)), + label: `Q${Math.floor(a.getMonth() / 3) + 1} ${a.getFullYear()}`, + }; + }); +} + +interface Horizon { + from: number; + to: number; + start: string; + end: string; +} + +/** A day's place across the track, 0 to 100 (clamped). */ +function at(day: string, h: Horizon): number { + const t = dayStart(day) ?? h.from; + return Math.min(100, Math.max(0, ((t - h.from) / (h.to - h.from)) * 100)); +} + +const STATUS_WORD: Record = { + planned: 'Planned', + active: 'In progress', + done: 'Done', + dropped: 'Dropped', +}; + +/** How each plan's bar reads: an outline before work starts, a fill once it + * has, green when it's done, faint when it was dropped. */ +const BAR_STYLE: Record = { + planned: { background: 'var(--brand-50)', borderColor: 'var(--brand)' }, + active: { + background: 'color-mix(in oklab, var(--brand) 55%, transparent)', + borderColor: 'var(--brand)', + }, + done: { + background: 'color-mix(in oklab, var(--ok) 45%, transparent)', + borderColor: 'var(--ok)', + }, + dropped: { + background: 'color-mix(in oklab, var(--ink-3) 15%, transparent)', + borderColor: 'color-mix(in oklab, var(--ink-3) 40%, transparent)', + }, +}; + +const weekWords = (day: string) => + new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); + +/** What a linked project's PRs actually did: first PR to last merge, or to + * today while it's open. From the horizon's own history, so a project that + * began months ago still starts in the right place. */ +function actualSpan( + slug: string | null, + linked: PortfolioItem | undefined, + history: Record, + today: string +): { start: string; end: string } | null { + if (!slug) return null; + const past = history[slug]; + const start = past?.first_opened ?? linked?.window?.first_opened ?? null; + if (!start) return null; + const open = linked && (linked.status === 'live' || linked.status === 'quiet'); + const end = open ? today : (past?.last_closed ?? linked?.window?.last_closed ?? start); + return { start, end }; +} + +const inputClass = `px-2.5 ${textInputClass}`; +const selectClass = `px-2 ${textInputClass}`; + +/** + * The editor for one item, open in the roadmap's own flow (work in progress + * lives inline, never in a popover a stray click can close). The same form + * adds a new item. Saves through the shared checks, so a mistake reads the + * same here as the server would say it. + */ +function Editor({ + item, + projects, + teams, + people, + onDone, +}: { + /** null to add a new item */ + item: RoadmapItem | null; + projects: { slug: string; name: string }[]; + teams: string[]; + people: string[]; + onDone: () => void; +}) { + const [draft, setDraft] = useState(() => + item + ? { + name: item.name, + project: item.project, + team: item.team, + lead: item.lead, + status: item.status, + start: item.start, + weeks: item.weeks, + notes: item.notes, + } + : { + name: '', + project: null, + team: null, + lead: null, + status: 'planned', + start: addWeeks(mondayOf(utcDay(Date.now() / 1000)), 1), + weeks: 4, + notes: '', + } + ); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const { armed, run } = useArmedConfirm(); + const set = (patch: Partial) => setDraft(d => ({ ...d, ...patch })); + const save = async () => { + const checked = checkRoadmapFields(draft, { partial: !!item }); + if ('error' in checked) return setError(checked.error); + setSaving(true); + const ok = item + ? await updateRoadmapItem(item.id, checked.fields) + : !!(await createRoadmapItem(checked.fields)); + setSaving(false); + if (ok) onDone(); + }; + const field = (label: string, control: ReactNode, wide = false) => ( + + ); + const projectOptions = draft.project && !projects.some(p => p.slug === draft.project) + ? [...projects, { slug: draft.project, name: draft.project }] + : projects; + return ( +
{ + e.preventDefault(); + void save(); + }} + onKeyDown={e => { + if (e.key === 'Escape') onDone(); + }} + > + {field( + 'Name', + set({ name: e.target.value })} + placeholder="What the work is called" + />, + true + )} + {field( + 'Status', + [s, STATUS_WORD[s]])} + onChange={status => set({ status })} + />, + true + )} + {field( + 'Starts the week of', + e.target.value && set({ start: mondayOf(e.target.value) })} + /> + )} + {field( + 'Length in weeks', + set({ weeks: Number(e.target.value) })} + /> + )} + {field( + 'Tracks the PRs of', + + )} + {field( + 'Team', + + )} + {field( + 'Lead', + <> + set({ lead: e.target.value.trim() || null })} + placeholder="GitHub login" + /> + + {people.map(login => ( + + + )} + {field( + 'Notes', +