Skip to content

feat(web): show story assignees on the board - #171

Open
cloudbridgeuy wants to merge 9 commits into
theam:mainfrom
cloudbridgeuy:trunk-story-assignees
Open

feat(web): show story assignees on the board#171
cloudbridgeuy wants to merge 9 commits into
theam:mainfrom
cloudbridgeuy:trunk-story-assignees

Conversation

@cloudbridgeuy

@cloudbridgeuy cloudbridgeuy commented Aug 17, 2026

Copy link
Copy Markdown

Addresses #101 — the row, the story header, and the mine filter. Avatars are
deliberately left out and filed separately; see Not in this PR below.

apps/web only. No new dependency, no change to any package.json, no API or
schema change — assignees was already on both responses the web fetches.

What you get

On the row (components/issues/issue-row.tsx) — between the label chips and
the relative time, in the same dim mono as the time. One assignee, several, and
none:

Story rows showing @guzmonne, @guzmonne +1, @cloudbridgeuy, and unassigned rows

Unassigned rows render nothing at all — no placeholder, no reserved column.

In the story header (stories/[number]/page.tsx) — same grammar at header
scale, after the last label chip and before the issue #N ↗ link:

Story header showing @guzmonne +1 between the risk label and the issue link

The same header on an unassigned story, labels running straight into the issue link

The owner takes the labels' --dim rather than the link's brighter --mut: it is
a read-only fact, not somewhere to click. The difference is visible in the first
of those two shots.

A mine chip on the Stories board, beside the stage chips, matching
me.principal.githubLogin case-insensitively against assignees:

The board filtered to mine, showing two stories and every stage count matching the rows beneath it

The story dropped there is assigned — to someone else. The filter selects your
stories rather than dropping unowned ones.

Where the rules live

Four helpers in lib/pipeline.ts, covered in test/pipeline-story.test.ts — the
file the issue points at:

Helper Rule
storyOwner(assignees) Lead assignee in GitHub's order, plus a count of the rest. Blanks dropped, whitespace trimmed. Returns null when there are none, so nothing renders.
ownedBy(assignees, login) Case-insensitive membership. false for any list when there is no login.
boardHref(projectId, filter) Every chip URL on the board.
mineFilterOn(mine, login) Whether the filter actually applies.

The row and the header both render @{login} plus ` +${extra}` when
extra > 0; only the size class differs.

Two behaviour changes on the board, called out so they are not mistaken for
refactors:

  • The stage chip count now reads the rendered list. It was a separately
    maintained count field; with a filter in play it would have disagreed with the
    rows beneath it. Both the number and its colour tone now come from
    stories.length.
  • Every chip URL goes through boardHref. The chips previously built their
    own template literals, and all in particular would have discarded the whole
    query string. It now clears the stage while preserving a filter the user did not
    ask to clear.

The login-less viewer

mineFilterOn exists because mine === "1" alone is not safe. A viewer with no
githubLogin — an API-key principal, or a user with no linked GitHub identity —
who arrives at ?mine=1 from a shared link, a bookmark or browser history would
match no story, so every stage empties; the mine chip is not rendered for them,
so there is no control to switch it off; and every chip preserves the parameter,
so none of them recovers the board. That is a blank board escapable only by
hand-editing the URL — an invisible dead control, worse than a disabled chip.

mineFilterOn makes the filter inert for such a viewer, and the recovery path has
its own test. The chip itself is absent rather than disabled:

The filter row for a viewer with no GitHub login, ending at the Backlog chip with no mine chip

The status filter is deliberately still validated against the unfiltered story
set, so a status never silently drops out of the URL when mine empties the
board. There is a comment in the page saying so.

Not in this PR

Avatars. The issue proposes https://github.com/{login}.png?size=40 with the
topbar's initial-letter fallback for deployments that must not let the browser
reach github.com. That is a self-contained piece of work carrying its own egress
question, so bundling it here would have made both harder to review.

It is being written up now as its own issue, and I will link it from #101 and from
this thread as soon as it is filed. Nothing in this PR blocks it: the avatar has a
natural home right next to @{login}, and storyOwner already hands the call site
the lead assignee and the count of the rest.

Testing

  • apps/web suite: 11 files, 80 tests. No new test file and no new test
    dependency — the presentation rules are pinned in the existing suite, the way
    storyHref and the CI grammar already are.

  • pnpm verify run on this branch, green.

  • Verified by hand on a live instance against GitHub-synced issues, which is where
    the screenshots above come from: the row, the header, mine narrowing the board,
    all clearing the stage while keeping mine, the status × dropping only the
    status, the chip being absent for a login-less viewer, and that viewer recovering
    from a stray ?mine=1.

    One limit worth stating: +N was exercised live at N=1 rather than N=2, because
    GitHub only assigns users who can reach the repository and the test instance had
    two accounts. The count itself is covered by unit test.

storyOwner() picks the first assignee (GitHub's order, not sorted)
and reports how many are left over, returning null when a story has
no assignees so no call site can render an "unassigned" placeholder
by accident. issue-row.tsx renders "@login" plus "+N" between the
label chips and the relative-time stamp, and nothing when the story
is unassigned.
Render the story's lead assignee beside the label chips in the story
header, using the same @login (+N) grammar already used on the
Backlog row. The header now reads story.assignees from StoryDetail,
which previously arrived from the API and was dropped on the floor.
Adds ownedBy() and boardHref() as pure helpers in lib/pipeline.ts, and
uses boardHref for all four board filter chips (all, stage, status
clear, mine) instead of hand-built URL strings. The mine chip narrows
each stage's stories to the signed-in viewer's GitHub login, composes
with the existing stage/status filters, and only renders when the
viewer has a GitHub login to match against. Stage chip counts and the
active-open-stories subtitle now read from the mine-scoped stories so
they never go stale relative to what's shown.
mineOn previously read straight from the mine=1 query param, so a
viewer with no GitHub login (a key principal, or any user whose
principal.githubLogin is unset) who arrived at ?mine=1 via a shared
link, bookmark, or browser history landed on a board with every
story filtered out by ownedBy(), no mine chip to undo it (it only
renders when a login exists), and no other chip to recover with,
since all four preserve mine.

Lift the derivation into mineFilterOn(mine, login) in lib/pipeline.ts,
which is false whenever the viewer has no login to match against
regardless of the raw query param. The board now renders normally
for such a viewer even with ?mine=1 in the URL, and every chip link
emits a clean, mine-free href.
@cloudbridgeuy

Copy link
Copy Markdown
Author

The avatar follow-up is now filed as #174, as this PR's body said it would be.

It also records one thing that came out of shaping it: #174 will change the topbar, which today carries referrerPolicy="no-referrer" on the viewer's avatar. The replacement cannot keep that attribute, so github.com will start seeing the deployment's origin. That trade is stated up front in #174 rather than left for review to find.

This PR is unchanged and does not depend on #174. #174 will be stacked on this branch.

@cloudbridgeuy

Copy link
Copy Markdown
Author

The avatar follow-up is now open as #175, implementing #174.

One thing worth knowing while you review this PR: #175 is cut from this branch, and
GitHub cannot base a cross-fork PR on a branch that only lives on my fork, so #175
targets main and its diff currently contains this PR's commits as well. The avatar
work itself is the last three commits there — seven files in apps/web and
packages/ui.

That means the two are ordered, not parallel: merge this one first and I will rebase
#175, after which its diff will show only those seven files. Nothing here needs to
change for that to work.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution!

The mine filter currently treats a failed /v1/me request like a user who genuinely has no GitHub login. If the pipeline loads but the identity request fails, ?mine=1 silently shows every story and removes the filter chip. Please distinguish those cases, show an error instead of displaying an unfiltered board, and add a regression test for this partial failure.

When the filter is working but the user has no assigned stories, please also show a clear empty state instead of rendering every stage as empty.

With those small improvements, we can approve and merge it!

…ne filter

A failed identity request used to be collapsed into 'viewer has no
GitHub login', so ?mine=1 showed every story while removing the chip
that could undo it. mineFilterState now returns off/on/blocked so the
board can say the identity check failed rather than pretend the filter
found nothing.

Also adds the empty state the reviewer asked for: when the mine filter
is active and nothing is assigned to the viewer, the board says so and
links back to the unfiltered board instead of rendering every stage
empty.
@cloudbridgeuy

Copy link
Copy Markdown
Author

Both points addressed in 6c21a71.

Failed /v1/me ≠ no login. mineFilterOn (boolean) is replaced by mineFilterState, which returns off / on / blocked. A requested-but-unconfirmable identity now yields blocked: the board renders an error notice — "Couldn't apply the "mine" filter — couldn't confirm who you are (…)". No unfiltered board, no silent downgrade. The login-less recovery path is unchanged and still renders the board normally. Regression test: "reports a failed /v1/me as blocked rather than as a filter that found nothing" — verified to fail against the previous code before this fix.

Empty state. With the filter on and zero assigned stories across all stages, the board now shows one clear message ("Nothing is assigned to @login right now…") plus a link back to the unfiltered board, instead of five empty stages.

Verification: web suite 82/82 green (2 new tests), lint + typecheck clean. One note: scripts/deploy-aws.test.mjs fails on this branch with or without these changes (its AWS process fakes get SIGTERMed) — pre-existing, unrelated.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution!

The requested behavior is fixed, but this branch now conflicts with main in the Stories page. When updating it, please preserve the api.project(projectId) lookup and pass builderPlanRequired into every IssueRow rendered by the new boardBody; otherwise the Builder plan-policy wiring from #205 is lost.

With that small integration fix, we can approve and merge it!

cloudbridgeuy pushed a commit to cloudbridgeuy/facility that referenced this pull request Sep 1, 2026
main gained the WSJF chip and the Builder plan policy while this branch was
open, and both landed in the two files this stack rewrites.

Three conflicts, all resolved by keeping both sides:

- issue-row.tsx imports and row body: the WSJF chip and the assignee avatar
  are independent additions after the labels. The chip keeps main's adjacency
  to the labels and the owner sits next to the timestamp.
- stories/page.tsx data loading: main added api.project alongside api.me,
  this stack renamed me to meResult and added the mine search param. The
  combined call binds all three.
- stories/page.tsx board body: this stack extracted the stage list into
  boardBody(), main added builderPlanRequired to IssueRow inside it. The
  extraction is kept and the prop threaded into it.

theam#171 is still open and rewrites the same two files, so it will meet the same
conflicts and must resolve them the same way.
`main` moved ahead while this branch was open and two of its commits land
in the same two files:

- d71315c feat(pipeline): order active stages by the WSJF judgement
- 1770945 feat(governance): require approved plans for Builder (theam#205)

Both conflicts keep both sides; neither is a semantic clash.

`issue-row.tsx` — the WSJF chip and the assignee both wanted the slot
after the labels. The chip keeps main's adjacency to the labels and the
owner sits next to the timestamp, so the row reads
title · labels · wsjf · owner · updated.

`stories/page.tsx` — main added the `api.project(projectId)` lookup that
feeds `builderPlanRequired`; this branch renamed `me` to `meResult` and
added the `mine` search param. One `Promise.all` binds all three.

The second hunk of that file is the one worth naming: this branch
extracted the stage list into `boardBody()`, and main added
`builderPlanRequired` to the `IssueRow` inside the copy it still had
inlined. Taking either side alone drops the other's work, so the
extraction stays and the prop is threaded into it. That is the
integration the review asked for.

Verified on the merged tree: tsc --noEmit clean, 87 web tests pass,
biome check clean, guards pass.
@cloudbridgeuy

cloudbridgeuy commented Sep 1, 2026

Copy link
Copy Markdown
Author

Conflicts resolved in e3963bc, and the integration you named is preserved.

main moved 18 commits ahead while this branch sat open. Two of them land in the same two files this PR rewrites:

  • d71315cfeat(pipeline): order active stages by the WSJF judgement
  • 1770945feat(governance): require approved plans for Builder (#205)

Both conflicts keep both sides. Neither was a semantic clash — they were two changes reaching for the same lines, not for the same behaviour.

components/issues/issue-row.tsx

The WSJF chip and the assignee both wanted the slot after the label chips. The chip keeps main's adjacency to the labels and the owner sits next to the relative time, so the row now reads:

title · labels · wsjf · owner · updated

stories/page.tsx — the one you flagged

Two hunks. The first is mechanical: main added the api.project(projectId) lookup that feeds builderPlanPolicy, this branch renamed me to meResult and added the mine search param. One Promise.all binds all three, so the project lookup survives intact:

const [pipelineResult, meResult, project] = await Promise.all([
  api.pipeline(projectId),
  api.me(),
  api.project(projectId),
]);

The second hunk is the one worth naming, and it is exactly the trap you pointed at. This branch extracted the stage list into boardBody(). main added builderPlanRequired to the IssueRow inside the copy it still had inlined. The two sides therefore disagreed about where the IssueRow lives, not about what it should receive — so taking either side wholesale silently drops the other's work. The extraction stays and the prop is threaded into it:

<IssueRow
  key={story.key}
  projectId={projectId}
  story={story}
  canTrigger={canTrigger}
  builderPlanRequired={
    !project.ok || project.data.builderPlanPolicy === "required"
  }
/>

"Every IssueRow rendered by the new boardBody" is covered by construction: there is exactly one IssueRow in the file. boardBody exists to keep the stage list out of the branch arm, so the ladder above it — pipeline error, mine blocked, mine empty, no stories, board — stays readable; it is called once, at the end of that ladder.

I also checked the wiring as a whole rather than just this file: every builderPlanPolicy / builderPlanRequired reference present on origin/main under apps/web is still present at this head, including the story detail page at stories/[number]/page.tsx:144. Nothing from #205 was lost in the merge.

Merge rather than rebase

#175 is stacked on this branch and is already under review at a pushed SHA. Rebasing here would force-push history out from under that review, so I merged main in instead. CI is fine with it: pull-request-title.yml validates subjects with --no-merges, so the merge commit is exempt from the Conventional Commits gate.

The ordering from my earlier comment is unchanged — merge this one first. I verified that decision rather than assuming it: git merge-tree of #175's head against this new head produces a clean tree, no conflicts. Both branches resolved these same two files identically, so they converge.

Verification, on the merged tree

Check Result
tsc --noEmit clean
vitest run (apps/web) 87 / 87
biome check apps/web packages/ui 144 files, no fixes
node guards/run.mjs 2 guards, 0 failed
origin/main fully contained yes
Net diff vs main 5 files, +305 −56 — the assignee work only

GitHub now reports this branch MERGEABLE.

No behaviour changed in this push. The two items from your first review stay as they landed in 6c21a71mineFilterState returning off / on / blocked with the error notice on blocked, and the filter's empty state — both still covered by the tests in test/pipeline-story.test.ts.

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.

3 participants