Skip to content

Projects: a tab for what each team is building - #513

Merged
jarstelfox merged 119 commits into
masterfrom
projectdasher
Oct 10, 2026
Merged

jarstelfox merged 119 commits into
masterfrom
projectdasher

Conversation

@jarstelfox

@jarstelfox jarstelfox commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

🤖

We skip separate project-tracking tools as too slow for how fast we work. Leads still need to see what each team is building, what's late, and what needs a decision. This adds a Projects tab to Pulldasher that answers those from the PRs and issues people already make, so keeping it current is mostly putting a label on a PR.

It has run on dev and prod with real data since early October.

Changes

  • Projects tab: six views. Overview shows what each team is on and what's at risk; Decide, the calls the weekly meeting needs to make; Roadmap, plans with hard, soft or ongoing ends. People shows who's on what and who's overloaded, Look back what shipped in a date range, and each project gets its own page.
  • Where projects come from: a project: label on a PR or issue, issues in the projects repo, and PRs that link those issues ("Parts of #N"). Every save can be undone.
  • Server: tables and endpoints for plans, updates, project issues and their links, with the same data on /api/v1. bin/migrate-missing applies whatever migrations a database lacks.
  • Board: reconnects by itself after a server restart instead of failing for good, and says "Pulldasher was updated" after a deploy.

Important

Run bin/migrate-missing before starting this code. Dev and prod already have 0022 to 0032; they still need 0034 to 0038 (date indexes, built online, and a wider project column). Each is one statement, so a failed run can simply be run again, and it gives up on a table lock after 10 seconds instead of stalling the board.

Anyone in the org can edit or remove plans, project issues and the developer roster, on purpose: it's a team tool and every save can be undone. Only taking back an update is limited to its author.

PR jail and the self-review policy were built on this branch too; they're now their own pulls, stacked on this one.

Review

Six rounds of blind review, each fixed before the next. The last found nothing to fix. Known follow-up: renaming or deleting a project label on GitHub can split a project's history, since nothing listens for label events yet.

QA

  • Run npm run dev:dummy in frontend-v2 and open each Projects view.
  • On a project's page, set its plan end with "Pick a date…", then press Undo.
  • Run npm test at the root and npx vitest run in frontend-v2.

jarstelfox and others added 30 commits September 29, 2026 16:33
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:<slug>`, 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:<slug>` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A plan's dates say when the work should happen, not how it's going.
The planner had to ask each lead, then retype the answers into a
status report. Linear and GitHub Projects solve this with a short
update: on track, at risk or off track, and a note, kept as a history.

1) Each item takes updates in a new roadmap_updates table. An update
   keeps the plan as it stood, so the next can say how far the end
   moved in between.
2) The row says the latest health. At risk, off track, and an overdue
   update (none for 14 days while in progress) are amber, since each
   means someone has something to do.
3) Opening an item shows a form to post one, what changed since the
   last (PRs merged and opened, the plan's move), and the history.
4) The Overview opens with where the plans stand, worst first, with a
   button that copies it as text for an email or chat.

The open item is now in the URL, so the Overview can link to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two things the planner kept doing by hand. Checking a plan against its
milestone meant opening the project issue, and showing the roadmap to
people outside engineering meant explaining the week columns first.

1) A row linked to a project with a milestone draws a short line at
   its due date and names it ("target Dec 14").
   - Both turn amber when the plan ends after it, or when work still
     running past its plan has missed it: the planner owes a new plan
     or a new date.
2) A third layout, Now, next, later, lists the same plan without
   dates, each column in priority order.
   - Now is work in progress and plans whose start has come, next
     starts within 13 weeks, later is further out.
   - The columns come from the dates, so the layouts can't disagree.
   - A plan still marked planned after its start week reads "Was to
     start" in amber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prettier 2.7.1 with 3-space tabs, single quotes, 100 columns and no
parens around a lone arrow argument leaves the board's older files, like
Lane.tsx and Stats.tsx, unchanged. The Projects files were written by
hand and didn't match it. This changes whitespace and line breaks only,
so the feature commits after it show just what they change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub sends a milestone's due date as a time, like
2026-12-14T07:00:00Z, though people set it as a day. The board has
always shown the day part (StatePopover, the CSV), but the project list
and project page turned the time into a local date. On the dummy board
that put the same milestone on Dec 13 in the list and Dec 14 on the
roadmap.

Every date in the Projects tab now goes through one dayWords in
model/projectData.ts, which reads the day part as UTC. Four copies of
the same formatter go away with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A planner wants to know how much of the team's work went to what they
planned, and how much went elsewhere. Swarmia calls this investment
balance. The Overview's merged work chart already had the numbers per
project, so it now has a second view, Roadmap or not:

1) Projects on the roadmap (linked to an item that isn't dropped).
2) Other projects.
3) One-offs, the project:misc PRs.
4) PRs in no project.

Under it, the share, which reads "7 of 14 merged PRs (50%) were in
projects on the roadmap" on the dummy board, followed by the same share
for the days before when they had merges. Both periods are measured
against today's roadmap. The choice is in the URL (split=roadmap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some plans can't start until another finishes: the checkout redesign
needs the Shopify sync done first. The planner kept that in the notes,
where nothing checked it, so a drag could put the redesign ahead of the
work it needs without anyone noticing.

1) An item has a "Waits on" list of other items.
   - The editor only offers choices that can be saved: never the item
     itself, dropped work, or a loop (A waits on B, which waits on A).
   - The server checks the same rules and answers 400 with the reason.
2) The row names what it waits on ("after Search reindex"). It turns
   amber, "starts before Shopify sync ends", when the item's first week
   comes before the other's planned end, or when the other was dropped.
3) Now, next, later shows the same words on each card.

The list is a waits_on column of ids ("3,7"). roadmap_items is new on
this branch and not deployed anywhere, so the column goes into its
CREATE TABLE rather than an ALTER.

The today line is now drawn per row, so an open editor no longer has a
dashed line through its fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Projects tab is a lazy chunk so developers reviewing PRs don't
download it, but two imports pulled its code into the main bundle
anyway:

1) app.tsx read DEFAULT_RANGE from model/projectData.ts for the URL,
   which brought that file, the shared projects model and the dummy
   projects with it. The constant now lives in lens.ts.
2) backend/dummy.ts, which the board always loads, held the dummy
   projects, teams and roadmap, and so the roadmap model. They move to
   backend/dummyProjects.ts, which only the tab imports.

Main bundle, gzipped: 150.9 KB before, 146.2 KB now, against 144.8 KB
on master. The Projects chunk grows by what left the main bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jira Plans and Asana both check a plan against the people who'd do it.
Without effort estimates the honest version here is plans against
developers, so each team lane's header now says it: "at most 2 at once
for 4 developers".

It turns amber once a week has as many plans as the team has
developers, "2 at once in the week of Oct 5 for 2 developers", because
then at least one plan has one developer or none. That's the same worry
as the "one person" flag on live projects, caught while it's still a
plan. The count runs from this week to the end of the timeline, and
only planned and in-progress items count.

The dummy board's FixBot team drops to two developers and gets a plan
that overlaps its webdriver work, so the amber state shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every component that called useRoadmap started its own 60-second timer
and focus listener, and the Overview mounts two of them, so it fetched
/roadmap twice a minute and twice on every focus. The first view to
mount now starts one timer and the last to unmount stops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project page's forecast read the milestone's due time in local
time, so on the dummy board it said "after the Sep 24 target" right
under a facts row saying "due Sep 25". It now takes the day part the
way every other date in the tab does since 88bcdcf, and compares the
forecast's local day against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The roadmap knew how each plan was going, but the project list and the
project pages didn't, so a planner scanning projects had to switch to
the roadmap to see which were in trouble.

1) The Overview's project list has a Plan column.
   - It shows the linked roadmap item's latest health, "No update" or
     "Update due" when one is owed, or else its status.
   - Sorting by it puts the worst first; the CSV gets a Roadmap health
     column.
2) A project's page has a plan row under its facts: the planned weeks,
   status, health and latest note, with a link that opens it on the
   roadmap. A project off the roadmap says so.

The health words and the worst-first order move into the shared model
so the list, the overview card and the CSV use one copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The roadmap could only be changed from the board: every write needed a
browser session, and /api/v1 had reads only. We want a CLI, and a Claude
session driving it, to manage the roadmap with the same GitHub token the
review skills already use.

1) Every roadmap write is now under /api/v1 as well: add, change, remove,
   reorder, and post an update. canWrite accepts the Bearer caller that
   lib/api-auth.js checked, and records the write as that login.
2) Two routes a script needs that the board didn't: GET
   /api/v1/roadmap/:id for one item, and POST /api/v1/roadmap/:id/move,
   which puts one item above another without sending the whole order.
3) GET /api/v1 lists every route with what it does, and the rules a
   write is checked against. app.js registers the routes from the same
   table in controllers/api-routes.js, so the list can't miss one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of this branch's roadmap commits turned up five bugs, each
confirmed against the code:

1) Deleting an item left its id in other items' waits_on.
   - The server refuses unknown ids, so those items could no longer be
     saved.
   - Deleting now takes the id off every list: on the server, in the
     dummy API, and on screen.
2) Links to a plan did nothing while the roadmap showed now, next and
   later.
   - The Overview's plan list and the project page now go through
     openPlan, which switches to a timeline.
   - The roadmap scrolls to the open item whenever it changes.
3) "Roadmap or not" read 0% until the roadmap loaded. It now waits.
4) A load that went out before a save and answered after it put the
   old plan back for up to a minute, and a new item could show twice.
   - Loads older than the latest write are dropped.
   - A new item replaces a copy a load already brought.
5) Save sent every field the editor opened with, so it undid a bar
   dragged while the editor was open. It sends only what changed.

Two copy fixes from the same review: form errors are amber, not red,
since red is CI's; and cards in Next and Later say the month their work
starts instead of week dates. New tests cover the store's races and the
amber rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The timeline was drawn for a handful of plans, but we have over a
hundred projects in flight, most with no plan, and it couldn't show how
overloaded that makes us. Its marks also needed a legend: a gray line
for a project's PRs and an upright tick for its milestone meant nothing
to someone reading it cold.

1) A load chart across the top, on the rows' own weeks.
   - Weeks up to today are labeled "In flight, from PRs", blue on the
     roadmap and gray with no plan; weeks after are labeled "Planned".
   - A dashed line says how many developers there are, and the band
     above it is amber: more in flight than people.
   - The left side says this week's count, split the same way, and how
     many that is per developer.
2) Every live project shows, planned or not. One with no plan is a
   dashed bar labeled "since Aug 17, no plan", with a plus that puts it
   on the roadmap over the weeks its PRs have run.
3) Marks label themselves.
   - A linked plan still in flight past its end grows an amber piece
     labeled "+3 wk over".
   - A milestone is a flag with its date ("Dec 14 target").
   - Bars say their dates and weeks inside when they have room.
4) Reading spans: month lines run through every row (quarters
   stronger), the header with the column names stays put while the rows
   scroll, and rows take one line of meta.
5) Zoom: a column name fills the width with that quarter or month. The
   toolbar zooms back out one step and pages to the period before or
   after, and the zoom is in the URL, so Back works too.
6) Lanes fold and remember it. A team lane's words count its plans and
   its projects with no plan against its developers.
7) The editor plans this or next month or quarter in one click, for
   organizing by month or quarter.

Show: "Only the plan" hides the projects with no plan. The load model,
the zoom and the columns are pure modules with tests, and the main
bundle stays at 146.4 KB gzipped (the zoom key's pattern lives in
lens.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 375px the timeline's track sat beside the names in about 100px, so
six quarters' names ran into each other and the bars were slivers. On a
phone each row's name and details now sit above a track the full width
of the card, and the header drops its small month marks and zoom glasses,
which don't fit. From 640px up nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The developer teams now drive the roadmap's developer line and each
lane's load, but they lived in config.js, so a person joining or leaving
a team meant a deploy. And a CLI driving the roadmap should be able to
change them the same way it changes a plan.

1) A project_settings table holds settings saved from the board or the
   API, one JSON value per name. Saved developer teams replace
   config.js's for every count; null goes back to config.js.
2) GET and PATCH /api/v1/settings (and /settings for the board's
   session), behind the same write gate as the roadmap. checkDeveloperTeams
   refuses a login on two teams, since a developer's work counts toward
   one team's load.
3) The People view lists the teams and says where they come from.
   - Editing gives each team a name and a member list, with add, remove,
     save, and a way back to config.js.
   - Every loaded window loads again after a save, since each developer
     count changes.

The settings are cached in memory, loaded at startup and after each save;
one process serves the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dummy board has 12 projects in flight; a real board can have over
a hundred.
The roadmap looked fine at 12 and was too tall and too repetitive at
100, and the bench couldn't show it.

?projects=100 in the dummy board's URL now adds that many projects in
flight, each with one to four open PRs cloned from the fixture, spread
over the last six months, named like real work ("Checkout caching").
About one in eight is on the roadmap, and each dummy team gains
developers, so the load reads like a real board: 112 in flight for 18
developers. Everything comes from the index, so a reload looks the same,
and backend/dummyScale.ts loads only when the parameter is there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At a real board's size the roadmap listed 94 projects with no plan, two
or three lines each, and the only way to plan one was a plus that
guessed its weeks. Organizing them by month or quarter meant planning
each with the guess, then opening its editor to fix the dates.

1) A project with no plan is one line: its name, lead and open PRs. The
   dashed bar already says since when, so the row doesn't repeat it.
   The section is about 1,200px shorter at 100 projects.
2) Its plus opens a chooser under the row: the weeks its PRs have run
   and two more, this or next month, or this or next quarter. One
   click plans it; it's in progress once its first week has come.
3) Dragging across its weeks on the timeline plans it for them, with
   the dates shown while you drag.
4) A find box narrows the rows by name, label, lead or team, and each
   section says what it shows ("3 of 92 live projects"). The load chart
   still counts everything.

The header's first column says Project, not Priority, since the rows
below the plans aren't in priority order, and the name column widens
from 1024px so fewer names wrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most of the roadmap did nothing when clicked: the chart's weeks and counts, the words
on a row, the milestone flags, the month names. A planner reached for
them and nothing happened. And one click did the wrong thing: a plain
click on the track of a project with no plan created a one-week plan.

1) The load chart.
   - A week's bar picks that week, and the rows narrow to what was in
     flight or planned then, banded through every row.
   - Arrow keys move the pick; Escape clears it. The chart takes one
     tab stop, not one per week.
   - Its counts filter to what they count, adding a third Show, "No plan
     yet", and the developer count opens the teams.
2) The header: a month name under the quarters zooms in, like the
   quarter names, and while zoomed Today comes back to this period.
3) A plan's row.
   - A plain click on its bar opens its editor; only a drag moves it.
   - The lead narrows to their work, the health opens the updates,
     "after X" opens X, and the flag or a missed target opens the
     project. The amber overrun opens the plan.
4) A project with no plan: a plain click on its track opens the
   chooser, and only a drag plans it. Its lead and PR count click
   through too.
5) A Now, next, later card opens its plan, and the Plan cell in the
   project list opens it on the roadmap.

DESIGN.md gets the rule: what would someone expect clicking a mark to
do? Build that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same rule as the roadmap: a number or a word a planner would reach
for goes where it comes from.

1) Live projects scrolls to the project list, Waiting on review now
   opens the review board, and Developers and others opens People.
   Tiles with nowhere to go stay plain.
2) In Where the plans stand, the health word opens the plan and its
   updates, like the name, and the lead opens the roadmap narrowed to
   their work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some work stops without being done or dropped: its people got pulled
onto something else, or it waits on a call nobody has made yet. The
roadmap could only say planned, in progress, done or dropped, so stopped
work either kept counting as in progress or got marked dropped, which
isn't true either.

Parked means stopped for now. It leaves the weeks ahead of the load and
Now, next and later, and owes no updates. Anything waiting on it reads as
a clash, the way waiting on dropped work does. Its bar is a gray outline.

The status is a new value in migration 0022's enum. That migration hasn't
shipped yet, so it changes in place instead of getting a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A team with no product manager has nobody whose job is to notice that a
plan slipped, a project stalled, or new work started with no decision
behind it. The roadmap had the facts but waited for someone to go
looking. On the dummy board at a hundred projects, 94 of 112 in flight
have no decision at all.

Decide lists those calls, worst first, and says why each one is there:
1) new: in flight with no roadmap item
2) its plan ended, with the project still in flight or with nothing in
   flight at all
3) stalled: open PRs with no activity for 21 days
4) its latest update says at risk or off track, and the plan hasn't
   changed since
5) marked done or dropped a week or more ago, but PRs are still open
6) parked, but its PRs moved

Each call is one click that writes the roadmap: commit through the end
of a coming month or quarter, park, finish, or drop. A decided row stays
in place at the same height and says what was decided, so nothing
vanishes and the next row doesn't slide under the pointer. The tab's
label counts what's left.

Any change to an item counts as a decision (its updated_at). So an edit
now stamps updated_at on screen right away, and the dummy board stamps
each write instead of once at page load.

GET /api/v1/decide lists the same rows for a script or a Claude session,
and GET /api/v1 says what each reason means and which write clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The load chart counted only the plans after today. With most work in
flight undecided, it dropped off a cliff at today and said the team
frees up next week, when nothing says so.

Now each week ahead counts:
1) every plan until its planned end
2) a plan past its end whose project is still in flight, since nothing
   says it's done
3) every project still open with no decision at all

Parked, finished and dropped work stops counting. The future half is
labeled "Ahead, if nothing changes" and drawn in paler tints of the same
colors. The only way to bring it down is a decision.

The model moves to shared/model/load.ts, so the chart, the team lanes
and the new GET /api/v1/load count the same way. The roadmap builds its
spans with the shared spansFrom instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project's row said "lead in 3 other projects" in amber when its lead
had PRs in three or more other live projects. With several projects in
flight per developer, that's most leads, so the amber showed on most
rows and stopped meaning anything.

Amber on a project row means someone owes the project something, and a
busy lead isn't that. The People view already counts who is on four or
more live projects, which is where that question gets answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarstelfox and others added 27 commits October 9, 2026 11:25
Sessions live in server memory, so every deploy signs everyone out.
An open tab reconnected its socket within seconds, then asked /token
who it was; the server redirected that to GitHub's sign-in, the
browser blocked the cross-site hop, and the board read it as a network
blip. It showed "Live updates lost, retrying in the background" and
retried every 30 seconds with no way to succeed. A hidden tab never
reloaded, so it never recovered, and the "Pulldasher was updated."
banner, which rides on the same sign-in, never showed.

Now /token doesn't follow redirects, so a redirect reads as an expired
session (the visible tab reloads and signs back in), a tab that comes
back into view retries at once, and a working sign-in clears the
"Live updates lost" banner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A playful nudge to close out PRs: when your own open PRs pass a limit
(more than 7 open, or any open longer than 14 days; drafts, bots and
deploy-held PRs don't count), a jail cell drops over the board and
lists them. Dragging the key into the padlock lets you out.

It shows again only when things get worse: at least 4 hours after it
last showed, with more open PRs or another one past the age line. A
refresh never brings it back, and open tabs stay in sync. A lock badge
beside the bell shows while you're over a limit and reopens the list.
Settings hold both limits, whether drafts count, and a preview button
(or #jail=1 in the URL).

The art follows the light and dark themes; the robot stands behind
the door with his tally wall beside him; on a phone the list comes
first and the key is one drag straight down into the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open jail showed a copy of your PRs from the moment it dropped, so
a merge or a new PR arriving over the socket never reached it: it
stayed on "8 open PRs" after a ninth came in. The cell now reads the
live list; merging under the limits says "You're under the limits now.
Use the key to walk out." Leaving saves what you last saw, so PRs
opened while it was up don't drop it again right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The jail listed your PRs as one flat, oldest-first list, so the ones you
could merge in seconds sat between ones waiting on someone else. Now it
splits them the way My work does (same rowWord): Ready to merge on top,
then Your move, then Waiting on others, each oldest first. Each row
shows the age, the move, a CI mark, and who it waits on.

The why line counts against the groups ("Merge your 3 ready ones and
close 10 more"), never counts a parked PR as ready, and doesn't promise
"you're out" while an old PR would still hold you. The preview now
shows your own PRs as jail counts them. CiGlyph comes out of CiStatus
so the board and the jail draw the same CI mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
People complained about the jail. The full-screen animated cell with
the key and the lock was a lot to sit through, and some people don't
want the feature at all.

Now it's a modal with a small sad robot behind bars, drawn as a few
lines in the board's own color tokens so it matches the UI and themes
with dark mode, above the same grouped list. It still has no close
button: you drag the card onto a trash can at the bottom of the screen
(on a phone that's a swipe down), and a keyboard or screen reader
presses the trash. A new PR jail setting turns it off, badge and all;
it defaults to on. The cell, key, lock and inmate art and their CSS go
away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some people want the header lock without the pop-up: they'd rather
check their PRs when they choose to. The single on/off switch couldn't
say that, since Off hid the lock too.

Now the PR jail setting has three choices: Automatic pops it up when
you're over the limits, Manual only shows the lock to open when you
like, and Off hides both. Anyone who already picked Off keeps it.

The trash can at the bottom becomes a snooze clock labeled "Snooze".
Dragging your PR list into a trash can read like throwing the PRs
away; snooze is what it really does, since jail comes back when things
get worse. The hint under the list now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging the card onto a clock was the only way past the jail without
fixing anything, and it didn't work the same on a phone or a keyboard.
The point is a little friction, not a puzzle.

1) Snooze is now a hold: press and hold the row at the bottom for 2
   seconds, with a pointer, a finger, Enter or Space. A small line-drawn
   padlock shows it: the key in the keyhole wiggles while it waits,
   turns a quarter turn as you hold while a ring around it fills, and
   the shackle pops open at the end. Letting go early springs it back.
   A tap, a click or Esc does nothing.
2) The jail lets you go by itself once you're under the limits: it
   says "You're out. Nice work." and closes after a couple of seconds.
3) A countdown to freedom: "6 to go" next to the title and on the
   header badge, enough PRs to get under the count limit plus every one
   past the age limit, ticking down live as you merge or close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The jail's list was tight: 13px rows two pixels apart, the move and the
detail run together with dots, and the snooze lock just closed the card
when you finished holding it.

1) Finishing the hold opens the lock: the shackle lifts out of the
   body and swings over its right leg to the other side, and the ring
   and the key turn green, before the jail closes.
2) Focus starts on "Hold to snooze", so holding Enter or Space works
   the moment it opens.
3) More room: a wider card with more padding, rows split by hairlines
   with real space between, 14px titles, ages in days, and the next
   step as a small pill instead of a dot-separated word. Each group
   says what to do with it ("Each of these is waiting on you").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Hold to snooze" was our word, not the user's, and the jail had grown
its own styles (uppercase eyebrows, hint lines, a subtext under the
button) when the board already has a header system people know.

1) The button says what happens: "Hold to get back to work", full
   width with the lock at its side. A quick click says "Hold it down",
   so the gesture teaches itself; the subtext is gone (Settings
   already says when it comes back). Done, it reads "Unlocked".
2) Groups use the board's GroupHeader (title, a short note, the count
   on the right), pinned to the top of the list as you scroll.
3) Size: 40rem on a desktop so titles fit one line, at most 80% of
   the screen tall; a sheet up from the bottom on a phone, with the
   button at thumb height. The header and the button stay put and
   only the list scrolls.
4) "Copy a nudge" on Waiting on others copies a Slack-ready list of
   those PRs, their links, and who each one waits on.
5) A PR that merges or closes while the jail is up slides out of the
   list as the countdown ticks, instead of vanishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Developers now CR and QA their own PRs unless they ask for review, so
"needs CR" no longer means someone else owes a review. derive() needs
to know which PRs are the author's own job and which are waiting on
people who were asked.

A pull is ownReview when its author is a developer (config
projects.developerTeams, or everyone when that's unset) and nothing
requested a review: no person, no team, no claim. askedOf lists who was
asked, with a requested GitHub team counting as everyone on the roster
team of that name, and askedAt is when. Only pulls waiting on others
can starve. An author's own stale stamp now owes a re-stamp, like
anyone else's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The self-review policy needs a few things from the server:

1) A clean merge of master into a PR no longer voids its CR and QA
   stamps, the way GitHub keeps an approval across one. The commit
   list is only fetched when a stamp actually predates the head, to
   spare the API quota. A merge that resolved conflicts reads the
   same as a clean one; the code says so.
2) Review requests to GitHub teams (requested_teams) are stored and
   sent to the board, which counts one as a request to everyone on the
   roster team of that name.
3) "Input hints": which areas a PR's diff touches that usually deserve
   team input (CI, migrations, alerting, agent docs, deploy config,
   dependencies), from its changed paths, fetched once per head.
4) The board gets the developer roster, the server-side derive uses
   it, and /api/v1 adds review.own, asked_of and asked_at.
5) Stats history's time to first CR only counts someone other than the
   author.

Migration 0033 adds the two columns; run it before this code. The
dummy board seeds team requests, request times, self-stamped pulls and
input hints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With authors stamping their own PRs, "time to first CR" drops to about
zero and review debt counts work nobody owes.

The Stats tab now shows the time to answer a review request (median
and slowest 10%, in hours), who's waiting on someone (open requests by
hours, outside contributors' PRs by days), and how merged PRs were
reviewed: asked, by someone else, self-reviewed, or not stamped, with
a rough count of reverts and quick follow-up fixes after self-reviewed
merges. Open PRs by author and repo say "in self-review" and "waiting
on others"; stamps per day count only stamps on other people's PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bot's PR still needs someone else's review, but derive() only knew
the [bot] suffix, so with no roster configured a config-listed bot
like ifixit-systems counted as reviewing its own. The review policy now
carries config.bots, on the board and the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Developers review their own PRs unless they ask, so the board stops
treating every unstamped PR as someone else's job.

1) Nothing assigns review: the rotation, "your turn" alerts and cheers,
   and "Return the favor" are gone.
2) The review queue and Needs QA hold only PRs that asked you (by name
   or team), ones you said you'd review, and PRs from outside the dev
   team or bots. Requests lead Waiting on you, oldest first, with
   "asked 3h ago"; a new request sends a desktop alert.
3) Your own PRs say what you owe: Stamp CR, Stamp QA, Re-stamp, or
   "waiting on bob · asked 3h ago", turning into "Nudge bob" after 4
   hours. "Find a QA-er", "Nudge for a review" and "in the CR queue"
   are gone, and so is the "Getting QA is a to-do" setting.
4) Claim reads "I'll review" and "Drop it".
5) New: "Could use your input", a closed-by-default fold of other
   people's self-reviews in your code regions or repos you review,
   with no counts; and a quiet "worth asking for review?" hint on your
   PRs that touch CI, migrations, alerting, agent docs, deploy config
   or dependencies.
6) Words across Review, My work, Team, CI, Classic, Projects stages,
   cheers and PR jail now match the policy, and "is:asked" finds
   requests of you.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requests are answered in hours under the self-review policy, but the
"asked 3h ago" clock only lived in the state popover. Rows waiting on
someone who was asked now carry it as a flag ("asked 3h ago", or
"review asked" for a team request with no time), amber past 4 hours,
naming who was asked on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found three holes in the self-review model:

1) A CR comment doesn't clear GitHub's review request, so a reviewer
   who had stamped stayed "asked": the author was told to nudge them,
   and everyone on a requested team was told to QA it. askedOf now
   drops anyone whose CR already counts.
2) Someone who stamped a self-reviewed PR without being asked was told
   to re-stamp after the next push. On a self-reviewed PR only the
   author owes a re-stamp now.
3) A team request had no time, so it never got the hours clock. The
   wire gains team_requests (slug and time); members inherit their
   team's time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1) Being asked to review means code review: a requested reviewer sees
   "Review it" while it's in review, never "QA it". The author tests
   their own ("Stamp QA"); Needs QA holds only claims and PRs from
   outside the dev team or bots.
2) The "Starving" block in the review queue is gone. Requests of you
   come first, oldest request first, then the rest of the queue.
3) "Board's clear" only counts review that's yours.
4) Stats' "Asked" no longer counts a claim.
5) Ready to merge shows your own ready PRs, plus others' only when you
   were asked or said you'd review; the author merges. It replaces the
   Merge row in Waiting on you.
6) A team request that names nobody on the roster is in nobody's
   queue: the author sees "waiting on a review from {team}", and it
   can show under Could use your input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found:

1) "Update branch" on GitHub dropped every stamp until the refresh put
   them back, and sent reviewers a false "Re-stamp owed". The push
   webhook now checks the new head and skips that when it's a base
   merge.
2) The keep-the-stamp rule failed open: with no commit dated after the
   stamp, or a PR past GitHub's 250-commit list, the stamp survived any
   push. Both now drop it. A commit made before the stamp but pushed
   after still slips through; the code says so.
3) The commit list was fetched on every webhook, closed PRs included,
   whenever any old stamp predated the head, and a GitHub error failed
   the whole refresh. It's now open PRs only, newest stamp per person,
   cached per head, and errors fall back to the old behavior.
4) Team review requests now record when they were asked, so a team's
   members get the hours clock.
5) The "alerting" hint fired on any Alert* file, like AlertBanner.tsx;
   it now matches alerting directories and config files only.

The dummy board gets dated team requests and hints on your own PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch carried three features. PR jail and the self-review policy
now each get their own pull, stacked on this one, so each can be read
and merged on its own. This takes their 17 commits back out; the
pr-jail and self-review-policy branches put them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1) A project issue stopped refreshing whenever GitHub's issue-fields
   read failed: the error rejected the whole refresh, so a closed
   issue never showed as closed. A failed read now keeps the stored
   fields and refreshes the rest.
2) Issue search and attach read any repo in the tracked orgs with the
   bot's token, so someone could see titles from a private repo they
   can't open. Both now only reach the repos this board reads issues
   from.
3) The startup schema check didn't know migrations 0022 to 0024, so a
   fresh database got a confusing error instead of "these are missing".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Look back, People and time spent read comments, stamps, reviews and
pulls by date with no index, so every fetch scanned those tables, and
every project save makes open Projects tabs fetch again. Migration
0034 adds the date indexes online. It's numbered after 0033, which the
self-review branch already ran on dev and prod.

The startup schema check learns to look for an index, so it names
0034 until it's in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found:

1) 0034 put four ALTERs in one file and the schema check only looked
   for the last index, so a failure partway (a lock timeout, a dropped
   connection) left a file that failed on every rerun with "Duplicate
   key name". Each index is now its own one-statement migration
   (0034 to 0037), checked by name, so a rerun picks up where it
   stopped. bin/migrate-missing also gives up on a table lock after
   10 seconds instead of stalling the live board behind it.
2) A plan's project allowed 24 lowercase characters while project
   labels and attached issues allow 64 with dots and underscores, so a
   longer label could never get a plan. One shared rule now checks all
   three, and 0038 widens roadmap_items.project to 64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of the Projects views found:

1) Look back and People kept their own 5-minute cache, so after a teams
   save or an issue added on a project page they showed old numbers.
   A projects save now refreshes them too, live as well as on the
   dummy board.
2) Undo on a plan saved as Planned whose PRs had started wrote back
   "active", a change nobody made. Undo restores the stored status.
3) Portfolio's "missed target" did instant math rounded up, so a
   target read missed from 5pm Pacific on its day while the rest of
   the tab said it was still due. It now counts whole days like the
   rest.
4) The drafted update's finish used the UTC day while the project
   page used the local one, so they could disagree by a day.
5) The date range allowed 401 days and then dropped the pick
   silently; it stops at 400.
6) A reload answered while a removal was in flight could put a
   removed plan back for up to a minute. Loads that land during a
   removal are dropped and run again once it settles.
7) A plan starting later could be given a finish before its start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of the Projects branch found:

1) A board only heard about project changes while connected, so after
   a laptop slept or the server restarted, the review board's parked
   and finishing order and the Projects data stayed old until a
   reload. A reconnect now fetches them again.
2) Two teams typed with the same name merged before the server could
   refuse them, so the first team's members silently stopped being
   developers. The form now says "the team X is listed twice", the
   server's own words, and doesn't save.
3) The Overview's overload tile counted everyone, non-developers
   included, while the teams were still loading or had failed to
   load. It waits for them, and says "?" when they failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review with real-world messy data in mind found:

1) A deleted or transferred issue stayed "open" in its project
   forever: GitHub answers NOT_FOUND, which looked like a failed
   batch. Now NOT_FOUND removes it from its projects, and so do the
   deleted and transferred issue webhooks, so Decide can ask "Is it
   done?" again.
2) If the saved settings failed to load at startup, the first "mark
   ongoing" or teams save wrote over the saved ones. The load retries
   until it works, and saves answer "Settings are still loading" until
   it has.
3) One issue that always failed made the hourly issue sync re-read
   the whole window every hour. The marker moves on, and failed items
   retry on their own, three times at most.
4) One renamed or unreadable repo made GitHub reject the whole search,
   so the Add an issue box found nothing. Search retries that batch a
   repo at a time and skips the bad one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1) The reconnect refetch counted snapshots per subscriber, and both
   subscribers start after the first snapshot, so the first reconnect,
   the very case it was for, was taken for the connect. The socket now
   counts snapshots itself, before any subscriber.
2) The search fallback asked each repo alone after any error, so a
   rate limit multiplied the calls, and the empty answer was kept for
   a minute. It now falls back only when GitHub says a repo can't be
   searched, passes other errors up, and keeps no answer with a
   missing part.

Also notes that a retried issue whose save fails gets one retry, not
three: refresh.issue only rejects when GitHub can't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jarstelfox
jarstelfox marked this pull request as ready for review October 10, 2026 02:47
@jarstelfox
jarstelfox merged commit 8c38a31 into master Oct 10, 2026
1 check passed
@jarstelfox
jarstelfox deleted the projectdasher branch October 10, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant