Skip to content

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

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

feat(web): show story assignees on the board#171
cloudbridgeuy wants to merge 7 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.

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