Skip to content

Pulldasher frontend v2: a React + TypeScript rewrite#477

Merged
jarstelfox merged 382 commits into
masterfrom
frontend-v2
Jul 24, 2026
Merged

Pulldasher frontend v2: a React + TypeScript rewrite#477
jarstelfox merged 382 commits into
masterfrom
frontend-v2

Conversation

@jarstelfox

Copy link
Copy Markdown
Member

Note

Draft, for the review trail. This branch is already running on dev
(pulldasher-dev.cominor.com) and prod (pulldasher.cominor.com), deployed
straight from frontend-v2. The PR exists so a change this size has a home
for review and history, not because it's blocked on merge. There are no CI or
branch gates on Pulldasher; prod is whatever's checked out.

Summary

A ground-up rewrite of the Pulldasher board as a React + TypeScript + Vite + Tailwind app under frontend-v2/, replacing the legacy frontend and served at /. The old board answered "here is every open PR"; v2 is reviewer-centric — it answers "what should I review next, and what needs me," and adds the coordination layer (claims, a turn rotation, "Deal me one") that the team was doing by hand in Slack.

Everything below is a summary; the commits tell the full story (202 of them, atomic).

What's in it

  • The board & lenses — Review · My work · Team · People · Classic · Stats. Per-person repo relevance (your primary repos lead; the rest fold away), an aging cross-repo backstop, stacked-PR nesting in the lanes, a universal state popover on every card, and a "what changed since your last look" delta.
  • Coordination — in-memory review claims (never touches the DB) with per-claim expiry and the claimer auto-added as a GitHub reviewer; a "your turn" rotation that points a starved, unclaimed PR at the best-fit reviewer (reciprocity-ranked, deterministic across clients) with a one-click Claim it nudge; and "Deal me one", which hands you the single best pull to review and lets you run a triage streak.
  • Nudges & rewards — a gamified toast system (what-to-review, author-side alerts, and reward moments), a recoverable notification panel, per-kind on/off toggles, toast dwell (ms or sticky) and bell-badge settings, and reward animations (canvas emoji-confetti, sparkle burst, shimmer, odometer) scaled to each moment and gated on prefers-reduced-motion.
  • Code regions — free-text areas you own; matching PRs float up the queue and gather in their own section.
  • Filters & stats — a query box with saved filters, weight + viewer-state filter dropdowns, and a redesigned Stats view (multi-series line, donut, two-series charts) plus trend history.
  • Settings — theme/density, age thresholds, drafts/cryo defaults, open-in-new-tab, claim length/warn, and the full notifications panel.

Server-side (small, and safe)

  • Per-claim expirylib/claims.js stores a per-record expiresAt from a clamped [5m, 24h] client TTL; app.js forwards it from the claimReview socket event. The broadcast wire is unchanged ({ login, at }). Covered by test/claims.test.js.
  • Retire the legacy frontend and serve v2 at / (the old /v2/ path is gone).
  • No DB schema or migration changes — a standing constraint for this project. Claims live in server memory; a restart clears them.

Testing

  • ~296 unit tests (Vitest) across frontend-v2/src/model/ and components
  • tsc strict — clean
  • Biome + production build — clean
  • Server test/claims.test.js
  • Deployed + smoke-tested on dev and prod: 701 open pulls load from the prod metrics DB, / auth-gates (302), config.json serves weightLabels, no errors

QA

  • Board loads and switches across all six lenses; a bare link opens your default lens
  • Claim a review → hand icon appears for everyone, and you're added as a GitHub reviewer; it expires on its own
  • "Deal me one" hands a sensible pull; claiming deals the next; aging and bot PRs are reachable (bots demoted)
  • "Your turn" points at a reasonable person and the Claim it button claims in place
  • Reward toasts fire (inbox zero, streak, top reviewer, PR green, board clear) with their animations; "review landed" stays quiet
  • prefers-reduced-motion degrades every animation to a still
  • Reloading the page does not replay the load-time nudges
  • Weight labels drive CR weight (the size: * chain), i.e. config.json is present in dist/
Deploy / rollback notes

Frontend-only for the latest slice; the only server change in the whole branch is the per-claim expiry above. Prod rollback is a rm-and-run with pulldasher:pre-40ff358-20260720; dev with any prior pulldasher-dev:<sha>. Build bakes frontend-v2/public/config.json (weight labels) into dist/ — load-bearing, gitignored.

🤖 Generated with Claude Code

jarstelfox and others added 30 commits July 19, 2026 19:06
The single three-tab popover buried every dimension behind a tab click
and rendered durable mutes, saved scope, and session toggles as
indistinguishable rows. An audit of the surface found the split that
fits: two focused triggers (Repos, People) that open straight into
their lists, with the rarely-diverging session drafts toggle demoted to
an inline row beside cryo under an explicit "this session" separator.

Active filters now surface as header chips, and the chip grammar IS the
durable/session distinction: a dismissible pill (x) clears session
scope; an informational pill (muted repos, muted people) opens the
control that owns the durable state. "Clear filters" says outright that
mutes and stars survive it.

Cohesion fixes from the same audit: one shared FilterSearch input
replaces three copy-pasted search boxes (RepoManager and TeamPicker
adopt it too), mute/unmute is a QuietButton everywhere instead of a
hand-rolled 11px variant, starring is finally reachable from the
one-click filter surface instead of only the Settings drawer, and the
"Your team" preset chip is brand-bordered so a personal roster doesn't
read as an org fixture. People rows gain star and mute, with a muted-
people fold mirroring the muted-repos one. Muted people filter out of
the board through the same reveal levers as muted repos (show-all, or
naming them in scope or an author: query).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Starring a repo or person used to mean a trip to Settings or the filter
popover; now the row's kebab menu carries Star/Mute for both the PR's
repo and its author (author items hide on your own PRs). The kebab was
mobile-only, so it now renders at every width — the desktop hover
cluster keeps its three icon actions and gains the kebab for the rest.

Starred authors get a small brand star on their row avatar via a new
avatarBadge slot on CardShell — one slot instead of touching every
Avatar call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewers triage by budget — "I have twenty minutes, show me the small
stuff" — and by obligation — "what's actually my move here." The query
grammar now answers both: weight:xs,s (comma list, OR within the
token), has:action (this card carries an imperative for you, powered by
rowNote), is:restamp (you owe a re-look), and is:blocked. Tokens
compose with the existing grammar's AND semantics, and unknown has:/is:
values match nothing, same as an unrecognized status:.

The row's weight meter becomes a filter toggle: click to scope the
board to that size class, click again to clear — the same in-context
filtering avatars already do for people. The Legend gains a Query group
documenting the grammar, which was previously undocumented there.

matchesQuery now takes the viewer login, since has:action is inherently
viewer-relative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "4 muted repos" pill sat directly beside the Repos trigger whose
active label already reads "4 muted", and clicking it opened the same
popover the trigger does — a duplicate button for the fact next door.
Same for muted people. With them gone the chip grammar is simpler and
stricter: every chip is session state and every chip is dismissible;
durable state lives on the triggers that own it. The ref plumbing that
existed only to let those pills open the popovers goes too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The State dropdown needs mutually-exclusive buckets — one answer per
(pull, viewer) to "where does this sit for me": restamp, review, qa,
mine, blocked, waiting. Re-stamp is checked before rowNote so an owed
re-look never disappears into Finish-QA/Re-QA strings. Blocked here is
deliberately viewer-relative, unlike the is:blocked query token: a
dev-block you authored carries the Address-feedback action, so it's
your move, not a thing you're blocked on.

weightFilterKey/matchesWeightFilter pin down the unknown-size rule in
one place: a pull without a real size matches only "unknown", never the
XS its zeroed line counts would imply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two new header triggers alongside Repos and People: multi-select
checkbox panels with live counts, XS through XL plus size-unknown, and
the six viewer-relative action states. Selections are session state
serialized in the hash (weight=xs,s / state=review,restamp), so a
filtered view is shareable, and they AND with typed query tokens the
same way scope already ANDs with repo: — no merge logic, no conflicts.

Counts come from a pool that excludes the dropdown's own pass, so
checking XS doesn't make the other options' numbers vanish. Both render
as dismissible chips and reset with Clear filters. All four filter
triggers collapse to icon-only below the sm breakpoint to keep the
375px header on two rows.

The row weight-meter click now toggles the Weight selection instead of
overwriting the query string — clicking it used to silently wipe any
hand-typed query text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-day max ticks rendered as sixty disconnected specks at 60-column
density — a dot lattice, not information — and worse, the outliers set
the y-scale, compressing every average bar into an identical 8%-floor
stub. Two failures hiding the one thing the chart exists to show.

Bars now scale to the average series so daily variation is visible, and
the outlier story gets exactly one mark: the window's slowest merge is
pinned to the top of its day and named in the summary line. Every day's
slowest still lives in its tooltip. PeriodColumns' scale ignores markers
generally now — a callout should never drag the data down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stacked PRs rendered as unrelated rows with a prose flag naming a
branch. Now a dependent PR whose parent sits in the same rendered list
indents beneath it with a hairline elbow, chains recurse with the
visual depth capped at two, and the now-redundant stacked flag drops
off nested rows. groupIntoTree runs after each lane's own sort — the
stack occupies the parent's position, children follow in order — and
lane membership never changes: a blocked child stays in the blocked
fold. Nesting is presentation, not reorganization.

Parent resolution keys on repo + head ref over the local list only, and
any ambiguity (the fixture ships a real duplicate-head case) renders
flat rather than guessing. Cycles chase iteratively, no recursion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The feedback popover only existed where feedback existed; a fresh PR
with zero activity answered hover with nothing. The status badge is now
the universal trigger on every card: state restated in a sentence plus
your move when you have one, the facts (opened when and by whom, CR and
QA progress with names, stale stamps in amber, CI contexts when red),
the feedback excerpt rows where sources exist, and a GitHub link
always. Closed rows get the receipt version. Same popover, two doors —
the context text still opens it where feedback lives.

Dependent PRs whose parent is elsewhere on the board now name it:
"stacked on #N" with the parent's title and link, via a whole-board
parent lookup memoized once — the base-branch prose remains only when
the parent truly isn't open anywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A labeled/unlabeled webhook wrote the label row but never marked the
pull dirty itself — clients only heard about it because the
pull_request handler happens to call updatePull unconditionally
afterward, which marks dirty as a side effect. Progressive CR-weight
updates (the size labels from ifixit#63258) depend on that broadcast,
so anchor it where the label change happens: handleLabelEvents marks
the pull dirty after its own write. An audit traced the whole
label-to-weight chain and this was its one fragile link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An author who dev-blocked their own PR read "Address feedback · from
you" — technically true, useless as a nudge. When you're the only
blocker the action is lifting your own block, so say that; when others
blocked too, address their feedback and name only them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three header-area bugs from mobile QA:

The lens nav had no scroll escape: as a flex item with default
min-width:auto its six tabs set a min-content width the row couldn't
shrink below, so at 375px the whole page grew wider than the viewport
and clipped ("My work 5" also wrapped mid-label). The nav now scrolls
horizontally inside its own box — min-w-0, nowrap tabs, hidden
scrollbar chrome — and the body never scrolls sideways.

The "N merged or closed since your last look" banner was dead text.
It's now a button (only when the current lens has a shipped fold with
content): opens the fold by writing the same pd2.folds store a click
would, smooth-scrolls to it, and pulses it with the existing
fresh-flash keyframe — new information announced in the motion
vocabulary the board already uses.

Clicking Review snapped back to the configured default view: buildHash
omitted the lens param against a hardcoded 'review' while readHash fell
back to settings.defaultLens, so a lens-less hash meant different
things to writer and reader. Both now share one defaultLensFallback().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A recurring triage view — "QA sweep" (state: qa, weight: xs,s), "quick
wins" (weight: xs, has an action) — cost four to six clicks to rebuild
every time, even though the whole session state already serializes into
the hash. A saved filter is a named bookmark of that string.

Two doors, one list (pd2.savedFilters, cap 8, oldest evicted): focusing
the empty search box drops a panel — saved rows with derived summaries,
arrow-keys and Enter, a "Save current filter…" footer that appears
exactly when something is active, and two suggested combos before any
exist — and a "Saved" trigger joins the filter row only once at least
one filter is saved, so new users pay zero header cost. Applying one is
just navigating to its hash; chips populate themselves. Durable state
(mutes, stars, your team) is deliberately not captured — those aren't a
viewpoint, they're your board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The └ connector rendered as a literal "L" in the UI font, and as a
flex child it consumed row width on top of the 22px indent — enough to
overflow rows inside Classic's narrow columns. It's now a CSS hairline
elbow absolutely positioned inside the indent gutter (zero flex width),
and the per-depth indent tightens to 16px. Rows no longer overflow at
any column width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CR dining-philosophers problem: everyone sees the same queue,
nobody knows who's already reading what, so people either double-review
or — more often — assume someone else is on it. QA solved this years
ago with the QAing label; CR gets the equivalent, minus the label
ceremony: claimReview/releaseReview socket messages and a reviewClaims
whole-map broadcast.

Claims are deliberately ephemeral, so they live in server memory only —
no DB, no GitHub writes. A 4-hour TTL with a 10-minute sweep keeps
abandoned claims from squatting; last-writer-wins keeps it social
coordination rather than a lock; a restart clearing claims is correct,
not a loss. The claimer's login comes exclusively from the socket's
authenticated user (stashed at authenticate time) — never from a
client argument. v1 clients never registered the event and are
untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two pure answers to reviewer deadlock. turnFor names exactly one
suggested reviewer for a starving pull: the pool is everyone with CR
history in that repo (minus the author, minus anyone already stamped),
and the pick is a djb2 hash of the pull key over the sorted pool — so
every teammate's board names the same person with zero server state.
Diffusion of responsibility ends when a specific name appears.

dealOne picks the single best card from an already-filtered queue:
starvation-led, +2 for repo familiarity, +2 for reciprocity (the
author has stamped your work), +1 for a quick win, ties broken by the
queue's own crSort order. Choice paralysis is half the problem; one
dealt card is a task where thirty are a wall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claims flow over the socket into the snapshot and speak QAing's
grammar: "X is reading it" while fresh, softening after two hours to
"claimed 3h ago — pick it up?" so a claim absolves other reviewers
without becoming squatting rights. Your own claim nudges back:
"Finish your review". Claim/Release lives in the row cluster and
kebab, hidden on your own PRs.

Turn names ride the existing amber starvation register — "unreviewed
29d · cdcline's turn", or "your turn" upgrading your Review-it pill —
suppressed the moment anyone claims. Becoming the named reviewer fires
one desktop notification through the existing transition diff.

"Deal me one" sits in the Review queue's lane header and honors the
active filters (the Weight dropdown doubles as a time budget). Dealing
claims the pick, scrolls to it, and flashes it with the fresh-row
pulse; Pass releases and deals the next. The state popover's facts
gain the claim and rotation lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1's filter menus let you narrow to a single value in one click; v2's
pickers only had per-row toggles, so isolating one repo/person/weight/state
meant unchecking everything else by hand.

Add a shared hover-revealed OnlyButton to every picker row (Repos, People,
Weight, State) that sets the selection to exactly that item. It reveals on
row hover or keyboard focus so it stays out of the way but remains
reachable, and stops propagation so it doesn't also toggle the row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claiming a review was purely a dashboard-local signal, invisible to anyone
looking at the PR on GitHub. Mirror it onto the PR: when someone claims a
review, also request them as a reviewer, and drop the request when the claim
is released (so passing on a dealt pull doesn't leave orphan requests).

The login comes from the socket's authenticated user, never the client, so
it can't be spoofed. There is no per-user GitHub token, so the request is
made by the shared app token with the claimer as the requested reviewer; the
call is best-effort and fire-and-forget, since the local claim already
succeeded and GitHub legitimately refuses a self-request from the PR author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A needs_recr pull where YOUR own CR stamp went stale was being dropped into
the "stamped by you — waiting on another reviewer" fold and excluded from the
review queue. That's backwards: a stale stamp means the PR is back to 0-of-1
and still needs a CR, so it's reviewable (by you re-stamping, or by anyone
else) and it is emphatically not waiting on someone else.

Split the buckets by live vs stale instead: only an active crBy stamp counts
as "stamped, in flight"; a recrBy pull stays in the review queue, where its
row note already reads "your move: Re-stamp". Review.tsx was already correct
and is the reference; this brings team.ts and People.tsx in line. Updates the
team test that encoded the old behavior and adds a live-stamp case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A gamification layer for CR/QA work needs to fire transient messages on
meaningful transitions, not on standing conditions, and it must not dump a
backlog the moment you open the board. Put that logic in a pure, testable
model: readSignals reads the board (queue sizes, your fresh stamps, the
rotation's turns), and diffCheers diffs two snapshots into reward/nag toasts
plus the baseline to carry forward. The first tick only primes.

Rewards: a stamp of yours landed, a session milestone, the queue cleared.
Nags: your pile crossed a threshold, the rotation named you on a starved
pull, a stamp of yours went stale. Session-only, since there's no per-user
history server-side. Covered by cheers.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nothing surfaced the human side of review work: clearing a queue or letting
it pile up passed silently. Wire the cheers evaluator to a transient toast
stack that fires while you're on the board (unlike desktop notifications,
which fire when you're away). Rewards rise with a brand medallion that pops,
the hero moments spark; nags settle in heavier and a touch wistful to softly
motivate. Auto-dismiss, capped at three, clickable to the PR, reduced-motion
aware, and gated by a new "Cheers & nudges" setting (default on).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewers watch particular areas (Growthbook, Shopify, a subsystem) but had
no way to say so; those PRs sat wherever weight and age put them. Add a
codeRegions setting: free-text strings matched case-insensitively against a
PR's title, body, labels, branch, and repo (all already on the wire, so no
server change and no extra API calls). Matches float to the top of every
review lane, above even starred authors, and carry a small marker so a
floated PR explains itself. A settings editor manages the list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dealing a pull used to claim it immediately. Now that claiming also requests
you as a reviewer on GitHub, that's too eager: a deal is a suggestion, not a
commitment. Deal now scrolls to the pick and waits behind a "Claim it"
confirmation; "Pass" skips it (nothing to release) and deals the next, so you
can flip through the queue a card at a time. The flash moves to the moment
you claim, landing as the claim badge appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"N merged or closed since your last look" jumped to a fold of closed rows,
which is no more useful than scrolling there yourself, and it worked only on
Review and My work. Replace it with a relevance-ranked catch-up: it names
what actually shipped while you were away, yours first, then ones you
reviewed, each linking to the PR. A dismiss button marks everything seen; a
long list folds the tail into "see all on the board", where the old
jump-to-fold survives. Works on every lens now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The eye ("I'm reading this") read as surveillance more than volunteering.
A raised hand — putting your hand up to take the review — matches what
claiming now means (you're on the PR as a reviewer). Uses Heroicons' solid
hand-raised; ActionIcon gained an optional viewBox size so the 24-unit path
renders alongside the 16-unit action icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The toast engine only knew about "cheers" (the gamified review
rewards/nags). The shipped catch-up wants the same transient corner
surface, so lift the shape into a shared model/toast.ts: a Toast with an
'info' tone alongside reward/nag, plus per-toast dedupeKey, ttlMs, and
onAct/onGone hooks so a caller can drive a custom click and mark-seen.

cheers.ts now aliases CheerToast to Toast (it only ever emits a subset).
useToasts takes an extras: Toast[] feed, deduped by key and pushed once
each, independent of the cheers setting since these aren't gamification.
remove() fires onGone exactly once via a ref mirror so a toast leaving
for any reason (dismiss, timeout, eviction) can settle its side effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn a scope-filtered list of closed pulls into a single info toast
instead of a standing panel. Only pulls that are yours or that you
reviewed count (shipRelevance != null) — an org merge you never touched
isn't worth a nudge, which is the whole point of moving this off an
always-on banner. Singular vs plural title, a yours/reviewed breakdown
body, and a dedupeKey of newest-close-time plus count so a fresh merge
re-fires but the same standing backlog fires once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the full-width RecentlyShipped panel with the single info toast
from shippedToast. app.tsx builds it from scopedClosed (so it honors the
active repo/author/hidden filters), narrowed to closes since lastSeen and
to pulls relevant to you; suppressed entirely while a text query is
active. Clicking jumps to the shipped fold and marks the catch-up seen;
onGone marks seen too, so timing out or dismissing settles it. This is
the "don't distract me with merges I don't care about" fix — an org-wide
banner of every close becomes a quiet, relevance-gated nudge or nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The competitive toasts (top-of-board, "you passed X") need a per-person
review tally, but the server has no per-reviewer history — stats-history
is org-wide aggregate. So rank client-side over the window we already
have: count the distinct pulls each login holds a CR/QA stamp on across
open + recently-closed, dense-ranked. Window-limited by design, hence
copy that says "on the board", not "this week".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jarstelfox and others added 8 commits July 24, 2026 11:53
draftsMode 'mine' hides other people's drafts from the board, but
boardHidden's review-requested exception lets a draft someone asked you
to review through, and it then surfaced at the top of "Recently updated",
where a not-yet-ready draft you don't own is just noise. The "what
changed" glance now drops others' drafts (your own stay: they're your
work); the review-requested draft still shows in the queue, where acting
on it makes sense.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bots (dependency bumps and other machine authors) are low-priority review
work but still real work, so they default to visible in their own fold.
Some people would rather never see them. Add a durable "Ignore bot PRs"
preference (off by default) to the Hidden panel: on, it keeps bots off
every lens through the board's own boardHidden predicate, and "Show
everything for now" still reveals them. The Hidden door now also opens
when bots are present, so the toggle is reachable even when nothing else
is hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prod and dev config.js both default cr_req to 1 (a single code review),
but the tracked config.example.js and the test fixture still defaulted to
2. That drift meant localdev, new setups, and the test fixture required
two CRs where the real environments require one, so a PR with a single CR
stamp and qa_req 0 (no QA) read as needs_cr / not-ready there while prod
correctly showed it ready (the #63282 report). Align both tracked files
to prod's default of 1; qa_req was already 1 everywhere. A PR still sets
its own cr_req N in the body to require more, or 0 to waive review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes for PRs with non-default requirements.

The stamps popover said "No stamps yet." even when the requirement is
waived (cr_req/qa_req 0). It now reads "CR not required." / "QA not
required." in that case, and drops the pip legend there since there are
no marks to decode.

The pip cluster was pinned to a fixed min-width (sized for three marks),
so a one-CR PR reserved empty air and didn't visually shrink. It is now
content-sized: one required stamp shows one mark, four show four, and the
slot grows and shrinks with the required count. The rail stays anchored
on the age numeral (a wider cluster grows leftward), so the age column
holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The console greeting was a one-line "pulldasher v2 · wrench in hand". Now
it prints an actual wrench, run-length encoded (even runs are spaces, odd
runs '#') and logged as monospace pre-formatted art.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Content-sizing each row's pip cluster (previous commit) made the columns
ragged when different pulls need different counts. Instead, publish the
widest cr_req/qa_req on the board as CSS variables (--cr-slots /
--qa-slots, computed over all open pulls) and pad every rail slot to
them, so the CR and QA marks line up in a column across rows. The slot
still collapses when nothing needs many: a board where every pull needs
one CR shows one-wide slots, and a single PR bumped to three widens them
all until it merges. Density-aware (14px / 12px pip). Popover pips stay
content-sized.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applies the CLEAR findings from a PRODUCT/DESIGN.md audit.

- "lens" reads "tab" in every rendered string (savedFilters describeHash,
  the Snoozed-fold copy, the snooze chip title/aria-label). The Lens type
  and LENS_LABELS identifiers stay.
- "scope" reads "filters" in the Reset button title and a repo checkbox's
  aria-label, matching the existing "repos filter:" convention.
- Em dashes removed from rendered copy (~25 sites: lane/fold glosses,
  SubDoor bodies, titles, aria-labels, WORD_GLOSS, the why-lines), each
  swapped for the punctuation the sentence wants (comma, colon, semicolon,
  period, or the house "·"), per DESIGN.md's no-em-dash rule.
- The per-signer stale pip title now says "stale review: invalidated by a
  later push", matching the popover legend's word for the same mark.
- Ci's no-timing cell renders an empty string instead of a lone em dash.

Test assertions updated for the "tab" and colon-in-why-line changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`npm run lint` (eslint ./, the CI Build step) failed with one error:
react/prop-types couldn't resolve `className` through HeaderIconButton's
`ButtonHTMLAttributes<…> & {…}` intersection, so it read the destructured
`className` as an undeclared prop. Declare `className?: string` explicitly
in the props type (ButtonHTMLAttributes already carries it; this just
makes the rule see it). Type-only change, no runtime effect. Repo-wide
eslint errors now 0. The frontend build gate (tsc + vite build) never ran
eslint, which is how this slipped in with the never-CI'd cleanup commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jarstelfox

Copy link
Copy Markdown
Member Author

@danielbeardsley I polished and fixed some bugs, now I am running review passes from claude. After that's done, I will push and then stop here

jarstelfox and others added 16 commits July 24, 2026 14:14
Fold ran `if (!count) return null` before its useRef and
useSyncExternalStore (foldOpenStore.useValue) calls. Review renders
every RestGroup Fold ungated at a fixed tree position, so a lane
emptying or filling on the mounted tab (e.g. closed 0->1 the moment a
PR merges) changed that Fold instance's hook count between renders and
threw "Rendered more hooks than during the previous render", crashing
the Review tab. Run both hooks unconditionally, then bail on empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parse() grafts additions/deletions/changed_files from the fetched full
pull onto a list-sourced item but omitted mergeable, so every bulk/list
refresh (server startup refresh.openPulls, the refresh-*-pulls bins)
wrote mergeable back as NULL. derive()'s strict `mergeable === false`
then never matched, so a genuinely-conflicted PR read as mergeable and
reached the ready-to-merge bucket board-wide until an unrelated per-pull
webhook re-derived it. This defeated the getFromDB tinyint fix. Graft
mergeable alongside the diff stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two robustness fixes to this module's background work.

1. The pullsChanged handler ran dbManager.getPull(...).then(...) with no
   .catch, so a rejected DB read (connection drop, deadlock) became an
   unhandled rejection. There is no global unhandledRejection handler, so
   under Node's default that terminates the whole server. Add a .catch.

2. The closed-pull cull and the reviewRequestsByPull prune it drives were
   scheduled with a one-shot setTimeout, so they ran once ~1h after boot
   and never again. The in-memory pull list and the review-request cache
   then grew unbounded for the process lifetime, and sendInitialData
   shipped an ever-growing stale-closed list. Recur with setInterval
   (still unref()'d so it never keeps the process alive on its own).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
githubLoginForToken returned null for every non-200, and resolveUser
cached that as an invalid-token verdict for the 5-minute TTL. A transient
GitHub 403 (secondary rate limit) or 5xx on the /user lookup therefore
locked out a valid caller with a 401 for five minutes. Treat only a 401
as a definitively bad token (safe to remember); throw on any other non-ok
status so the blip surfaces as a 500 and is not cached. Network errors
already threw and were never cached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The participants source was filtered by everStamped but the
unstamped_reviewers source was not, so a reviewer holding an active or
stale CR/QA signature who also left a COMMENTED or CHANGES_REQUESTED
review was wrongly counted as "engaged, no stamp" (and a stale-CR holder
was double-listed in recrBy). That drove a wrong "in discussion with X" /
"X looking" row note and violated the field's own contract, which the
"excludes anyone who already holds a CR/QA signature, active or stale"
test only enforced along the participants path. Combine both sources and
apply the author/everStamped/bot filter to them uniformly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. top-of-board fired on a passive rise to #1 (a rival's stamped PRs
   merging off the board dropping their count below yours) with no new
   review by you -- the exact false reward the climbing edge was fixed to
   avoid. Add the same own-count-growth guard.

2. The stale-claim nag had no status gate, so it nagged "still unreviewed"
   about a claim on a pull other reviewers had already CR-completed. Gate
   on CR_INCOMPLETE, matching the row-level treatment.

3. A stamp-landed reward evicted by MAX_PER_TICK was recorded as seen and
   never re-fired, silently losing the praise. Record the landed key in a
   mutable set the push's eviction callback un-records, matching the other
   rewards' evicted-then-deferred contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. An unrecognized is: value hit `return false`, and since matchesQuery
   requires every term to match, one unknown value silently emptied the
   whole board. Fall through to the plain-substring match unknown top-level
   keys already use, so it behaves like any other unrecognized token.

2. toggleScopeMember collapsed an explicit repo/author selection back to
   "everyone" on a length-only comparison, which misfired when the scope
   held a stale entry no longer among the candidates (equal length, wrong
   set) and wiped the filter. Collapse only when the selection actually
   covers every current candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-2xx response from /user-names was treated as a successful empty
result, permanently caching every requested login as "no name" for the
24h TTL and degrading those names to raw logins until expiry. Leave the
logins unresolved on a non-ok response, matching the network-failure
.catch path, so a later requestNames retries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rotation's "your turn" OS notification checked only
turns.get(key) === me && !isClaimed(p), with no bot-author guard, so a
starved Dependabot-style PR could page a human -- the noise cheers.ts
already guards against with its own isBot check on the same turns input.
Add the same bot-author guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
signoffLeaders credited a pull's own author for CR/QA signatures placed on
their own PR, inflating their "reviewed" total with non-review work --
unlike reciprocity() in the same file, which excludes self-stamps. Skip a
signature whose author is the pull's author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-person "owes a re-stamp" map walked only recrBy (owed re-CR) and
never reqaBy (owed re-QA), so a reviewer whose QA stamp a push invalidated
showed no owed dot or badge, dropped out of the "Re-stamps they owe" fold,
and -- if they had only re-QA debt and no authored board PR -- vanished
from the directory entirely. DebtCard's board-wide reviewDebt() already
sums both. Walk reqaBy too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. GitHub logins are case-insensitive (one account), but the duplicate-add
   guard and toggleTeammate deduped case-sensitively, so "JDoe" and "jdoe"
   could both land as two roster members. Dedupe case-insensitively,
   keeping whichever spelling arrived first.

2. renameTeam no-ops on a blank or colliding name, but nameDraft only
   resynced through a prop-keyed effect, so the input kept showing the
   invalid draft indefinitely. Revert nameDraft to the actual team name
   when the rename is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. NumberField read an emptied input as the number 0 and immediately
   clamped it to min, so the field could never be cleared to type a fresh
   multi-digit value. Hold the raw string, commit only a finite parse
   (clamped), and revert to the committed value on blur.

2. The Settings "return focus to the cog on close" effect ran on initial
   mount (open starts false), stealing focus to the gear on page load.
   Guard it so it only fires after a real open-then-close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Navigating to the default lens with no filters assigned
   location.hash = '' on the lens-changed branch, leaving a stray trailing
   '#'. Use the same pathname+search fallback the sibling same-lens branch
   uses (via pushState, preserving the history entry a lens change earns).

2. The display-name effect depended only on [pulls, closed] but read
   settings.teams / hiddenPeople, so a newly added teammate whose login was
   not on the board stayed nameless until an unrelated board update. Add a
   stable joined-login dependency so it refreshes when the roster or hidden
   set changes, without looping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
authFailed was set once by start()'s one-shot whoami(), so a session that
died while the tab slept never flipped it. authenticate()'s reconnect
collapsed every token failure -- session-expired and transient alike --
into one onState('error'), so the board showed "Live updates lost,
retrying in the background" instead of "Sign-in failed, reload to sign in
again", wrongly implying a dead session would self-heal.

Mark a /token redirect or 401 as a distinct AuthExpiredError, add an
onAuthExpired signal the store maps to authFailed (true on session death,
back to false when a reconnect re-auths), and suppress the connection
banner while authFailed so only one banner shows. A transient network or
5xx failure keeps its existing retry/backoff untouched -- no false "sign
in again" on a blip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes requested off the code review.

1. is:draft / is:mine now filter. An unrecognized is: value falls through
   to a substring match, but is:draft / is:mine (reasonable guesses) match
   no PR text and left the board empty. Implement them as real filters
   (draft status / authored-by-you) and document them in the filter
   aria-label, title, and the Legend.

2. Cheers respect a snooze. start-here / quick-wins / your-turn drew from a
   reviewable pool that ignored snoozes, so a pull you snoozed ("not
   today") in Review could still be nudged as the best thing to review.
   Thread the snoozed pull keys into evaluateCheers and drop them from the
   reviewable pool only -- board-cleared and the leaderboard still see the
   whole board, so a snooze doesn't fake an empty queue.

3. Remove the dead weight-letter click handler. onWeightToggle was defined,
   threaded through RowOptions, and compared in Row's memo, but never
   invoked -- the weight letter is an inert display span. The Legend claimed
   "a row's weight letter filters with a click"; drop the dead handler and
   correct the copy to describe what the letter actually does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jarstelfox

Copy link
Copy Markdown
Member Author

All review feedback addressed. I am not planning to push anymore commits

@jarstelfox
jarstelfox marked this pull request as ready for review July 24, 2026 21:38
@danielbeardsley

Copy link
Copy Markdown
Member

382 commits! That's a record for a single pull I think.

I vote for merging!

@jarstelfox
jarstelfox merged commit f924c74 into master Jul 24, 2026
1 check passed
@jarstelfox
jarstelfox deleted the frontend-v2 branch July 24, 2026 22:09
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.

2 participants