diff --git a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx index 43c432c9..ee87c7cf 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -1,4 +1,4 @@ -import { Eyebrow, PillTag, StatusDot } from "@facility/ui"; +import { Avatar, Eyebrow, PillTag, StatusDot } from "@facility/ui"; import Link from "next/link"; import { notFound } from "next/navigation"; import { CiStatusLink } from "@/components/ci-status"; @@ -9,7 +9,7 @@ import { PullRequestLinks } from "@/components/story/pull-request-links"; import { StoryTimeline } from "@/components/story/timeline"; import { StoryTriggerButtons } from "@/components/story/trigger-buttons"; import { api } from "@/lib/api"; -import { pipelineStories } from "@/lib/pipeline"; +import { avatarInitial, avatarUrlFor, pipelineStories, storyOwner } from "@/lib/pipeline"; import { detachablePullRequests, linkableIssues, @@ -102,6 +102,7 @@ export default async function StoryPage({ stageLabels, }); const stage = story.stage; + const owner = storyOwner(story.assignees); const prLinks = new Map(); for (const pr of story.prs) prLinks.set(pr.number, pr.url); @@ -165,6 +166,19 @@ export default async function StoryPage({ {label} ))} + {owner ? ( + + + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + + + ) : null} ; - searchParams: Promise<{ stage?: string; status?: string }>; + searchParams: Promise<{ stage?: string; status?: string; mine?: string }>; }) { - const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]); + const [{ projectId }, { stage, status, mine }] = await Promise.all([params, searchParams]); const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]); if (!pipelineResult.ok && pipelineResult.offline) return ; @@ -45,18 +51,30 @@ export default async function ProjectStoriesPage({ const permissions = me.ok ? me.data.permissions : []; const canTrigger = hasPermission(permissions, "runs:trigger"); const canSync = hasPermission(permissions, "repos:write"); + const viewerLogin = me.ok ? me.data.principal.githubLogin : undefined; + const mineOn = mineFilterOn(mine, viewerLogin); const stages = pipelineResult.ok ? pipelineResult.data.stages : []; const stageKeys = new Set(stages.map((candidate) => candidate.key)); const activeStage = stage && stageKeys.has(stage as PipelineStageKey) ? (stage as PipelineStageKey) : null; + // Validated against every story, not just the mine-scoped set, so a status filter + // never silently drops out of the URL when "mine" empties the board. const items = pipelineResult.ok ? pipelineStories(pipelineResult.data) : []; const stageStates = new Set(items.map((story) => story.stageState)); const activeStatus = activeStage && status && stageStates.has(status as PipelineStageState) ? (status as PipelineStageState) : null; - const counts = [...stages].reverse(); - const activeOpenStoryCount = items.filter((story) => story.state === "open").length; + const scoped = mineOn + ? stages.map((s) => ({ + ...s, + stories: s.stories.filter((story) => ownedBy(story.assignees, viewerLogin)), + })) + : stages; + const counts = [...scoped].reverse(); + const activeOpenStoryCount = scoped + .flatMap((s) => s.stories) + .filter((story) => story.state === "open").length; const stageFiltered = activeStage ? counts.filter((candidate) => candidate.key === activeStage) @@ -95,7 +113,7 @@ export default async function ProjectStoriesPage({
( 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)", + s.stories.length > 0 ? FILTER_COUNT_TONE[s.kind] : "text-(--dim)", )} > - {s.count} + {s.stories.length} ))} @@ -134,7 +152,7 @@ export default async function ProjectStoriesPage({ {activeStatusLabel} @@ -143,6 +161,19 @@ export default async function ProjectStoriesPage({ ) : null} + {viewerLogin ? ( + + mine + + ) : null}
{!pipelineResult.ok ? ( diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b2375..5bc3ecb6 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -1,12 +1,12 @@ "use client"; -import { Button, ButtonLink, StatusDot, toneFor } from "@facility/ui"; +import { Avatar, Button, ButtonLink, StatusDot, toneFor } from "@facility/ui"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; import type { PipelineStory } from "@/lib/pipeline"; -import { storyHref } from "@/lib/pipeline"; +import { avatarInitial, avatarUrlFor, storyHref, storyOwner } from "@/lib/pipeline"; function fmtAgo(iso: string | null) { if (!iso) return "—"; @@ -61,6 +61,7 @@ export function IssueRow({ } const current = story.currentRun; + const owner = storyOwner(story.assignees); const openPull = story.prs.find((pull) => pull.state === "open") ?? null; const failedAgent = current?.mode.includes("architect") ? "architect" @@ -194,6 +195,19 @@ export function IssueRow({ {label} ))} + {owner ? ( + + + + @{owner.login} + {owner.extra > 0 ? ` +${owner.extra}` : ""} + + + ) : null} {fmtAgo(story.ghUpdatedAt)} {action()} diff --git a/apps/web/components/shell/topbar.tsx b/apps/web/components/shell/topbar.tsx index 1d9e2fb8..a539bbae 100644 --- a/apps/web/components/shell/topbar.tsx +++ b/apps/web/components/shell/topbar.tsx @@ -1,8 +1,8 @@ -import { PillTag } from "@facility/ui"; -import Image from "next/image"; +import { Avatar, PillTag } from "@facility/ui"; import { SignOutButton } from "@/components/shell/sign-out"; import { ProjectSwitcher } from "@/components/shell/switcher"; import type { Me, Project } from "@/lib/api"; +import { avatarInitial } from "@/lib/pipeline"; export function Topbar({ me, @@ -26,17 +26,11 @@ export function Topbar({ className="flex items-center gap-2 font-mono text-[11px] text-(--dim)" title={me.principal.email} > - {me.principal.avatarUrl ? ( - - ) : null} + {me.principal.githubLogin ? `@${me.principal.githubLogin}` : me.principal.email} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 679199c5..941ed302 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -104,6 +104,70 @@ export function storyHref( return `/projects/${projectId}/stories/${story.number}?${storyQuery(story)}`; } +export type BoardFilter = { + stage?: PipelineStageKey | null; + status?: PipelineStageState | null; + mine?: boolean; +}; + +/** The stories board URL for a given combination of filter chips. */ +export function boardHref(projectId: string, filter: BoardFilter = {}) { + const params = new URLSearchParams(); + if (filter.stage) params.set("stage", filter.stage); + if (filter.status) params.set("status", filter.status); + if (filter.mine) params.set("mine", "1"); + const query = params.toString(); + return `/projects/${projectId}/stories${query ? `?${query}` : ""}`; +} + +/** Whether a story's assignees include the signed-in viewer, by GitHub login. */ +export function ownedBy(assignees: string[], login: string | undefined): boolean { + if (!login) return false; + const target = login.toLowerCase(); + return assignees.some((assignee) => assignee.toLowerCase() === target); +} + +/** + * Whether the mine filter should actually apply. It is inert — never on — + * for a viewer with no GitHub login to match against, even if `?mine=1` + * is already sitting in the URL (a shared link, a bookmark, browser + * history), so such a viewer is never trapped on a board with every + * story filtered out and no chip left to undo it. + */ +export function mineFilterOn(mine: string | undefined, login: string | undefined): boolean { + return mine === "1" && Boolean(login); +} + +export type StoryOwner = { login: string; extra: number }; + +/** The story's lead assignee, GitHub-ordered, with a count of the rest. */ +export function storyOwner(assignees: string[]): StoryOwner | null { + const logins = assignees.map((login) => login.trim()).filter(Boolean); + const [login] = logins; + if (!login) return null; + return { login, extra: logins.length - 1 }; +} + +/** + * GitHub serves an avatar for any login at this path, so no avatar URL has to + * travel on the wire. It 302s to `avatars.githubusercontent.com`. + * + * `?size=40` rather than the 14–20 CSS px we draw at, so 2× displays stay sharp. + */ +export function avatarUrlFor(login: string): string | null { + const trimmed = login.trim(); + if (!trimmed) return null; + return `https://github.com/${encodeURIComponent(trimmed)}.png?size=40`; +} + +/** The letter an avatar falls back to when there is no image to draw. */ +export function avatarInitial(value: string | null | undefined): string { + const trimmed = (value ?? "").trim(); + // Spread, not `[0]`, so an astral first character survives intact. + const [first] = [...trimmed]; + return first ? first.toUpperCase() : "?"; +} + export function pipelineStories(pipeline: Pipeline): PipelineStory[] { return pipeline.stages.flatMap((stage) => stage.stories); } diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 836e08c5..57b204f3 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; import { ciStatusLabel } from "@/components/ci-status"; import type { PipelineStageKey, PipelineStory, Proposal, StoryDetail } from "@/lib/api"; -import { reviewablePullRequests, storyHref } from "@/lib/pipeline"; +import { + avatarInitial, + avatarUrlFor, + boardHref, + mineFilterOn, + ownedBy, + reviewablePullRequests, + storyHref, + storyOwner, +} from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -277,6 +286,67 @@ describe("story presentation contract", () => { expect(proposalsForStory([linked, unrelated], detail, false)).toEqual([linked]); }); + it("names no owner for an unassigned story", () => { + expect(storyOwner([])).toBeNull(); + }); + + it("names the sole assignee with nothing left over", () => { + expect(storyOwner(["a"])).toEqual({ login: "a", extra: 0 }); + }); + + it("counts the remaining assignees past the first", () => { + expect(storyOwner(["a", "b", "c"])).toEqual({ login: "a", extra: 2 }); + }); + + it("keeps GitHub's assignee order rather than sorting it", () => { + expect(storyOwner(["zoe", "adam"])).toEqual({ login: "zoe", extra: 1 }); + }); + + it("drops empty and blank assignees before naming an owner", () => { + expect(storyOwner(["", " ", "a"])).toEqual({ login: "a", extra: 0 }); + }); + + it("trims whitespace around an assignee's login", () => { + expect(storyOwner([" a "])).toEqual({ login: "a", extra: 0 }); + }); + + it("builds a GitHub avatar URL from a login, at twice the drawn size", () => { + expect(avatarUrlFor("octocat")).toBe("https://github.com/octocat.png?size=40"); + }); + + it("trims a login before building its avatar URL", () => { + expect(avatarUrlFor(" octocat ")).toBe("https://github.com/octocat.png?size=40"); + }); + + it("escapes a login rather than letting it shape the avatar URL", () => { + expect(avatarUrlFor("a/b?c")).toBe("https://github.com/a%2Fb%3Fc.png?size=40"); + }); + + it("has no avatar URL to offer for a blank login", () => { + expect(avatarUrlFor("")).toBeNull(); + expect(avatarUrlFor(" ")).toBeNull(); + }); + + it("falls back to the first letter of a login, uppercased", () => { + expect(avatarInitial("octocat")).toBe("O"); + expect(avatarInitial("Octocat")).toBe("O"); + }); + + it("falls back to the first letter of an email when there is no login", () => { + expect(avatarInitial("ada@example.test")).toBe("A"); + }); + + it("keeps an astral first character whole in the fallback", () => { + expect(avatarInitial("😀nn")).toBe("😀"); + }); + + it("shows a question mark rather than an empty box when there is nothing to draw", () => { + expect(avatarInitial("")).toBe("?"); + expect(avatarInitial(" ")).toBe("?"); + expect(avatarInitial(null)).toBe("?"); + expect(avatarInitial(undefined)).toBe("?"); + }); + it("does not count draft pull requests as waiting for human review", () => { const story = storyDetail(); story.prs = [ @@ -287,6 +357,60 @@ describe("story presentation contract", () => { expect(reviewablePullRequests([story]).map(({ pull }) => pull.number)).toEqual([22]); }); + + it("never counts a story as owned when the viewer has no GitHub login", () => { + expect(ownedBy(["alice"], undefined)).toBe(false); + expect(ownedBy([], undefined)).toBe(false); + }); + + it("matches an assignee to the viewer's login regardless of case", () => { + expect(ownedBy(["Alice"], "alice")).toBe(true); + }); + + it("finds no owner in an empty assignee list", () => { + expect(ownedBy([], "alice")).toBe(false); + }); + + it("does not match an assignee who isn't the viewer", () => { + expect(ownedBy(["bob"], "alice")).toBe(false); + }); + + it("builds a mine-only board link with no other filters", () => { + expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); + }); + + it("combines the stage and mine filters in one board link", () => { + expect(boardHref("project-1", { stage: "backlog", mine: true })).toBe( + "/projects/project-1/stories?stage=backlog&mine=1", + ); + }); + + it("omits the mine key entirely when mine is off", () => { + expect(boardHref("project-1", { mine: false })).toBe("/projects/project-1/stories"); + }); + + it("keeps mine on when the all chip clears the stage", () => { + expect(boardHref("project-1", { stage: "backlog", status: "ready_to_plan", mine: true })).toBe( + "/projects/project-1/stories?stage=backlog&status=ready_to_plan&mine=1", + ); + expect(boardHref("project-1", { mine: true })).toBe("/projects/project-1/stories?mine=1"); + }); + + it("turns the mine filter on only when the viewer has a GitHub login to match against", () => { + expect(mineFilterOn("1", "alice")).toBe(true); + expect(mineFilterOn("1", undefined)).toBe(false); + expect(mineFilterOn(undefined, "alice")).toBe(false); + expect(mineFilterOn(undefined, undefined)).toBe(false); + }); + + it("recovers a login-less viewer who arrives with ?mine=1 already in the URL", () => { + // A shared link, bookmark, or browser history can carry `mine=1` for a + // viewer with no GitHub login. The derived flag must stay off so the + // board renders normally and the all chip offers a clean way out. + const mineOn = mineFilterOn("1", undefined); + expect(mineOn).toBe(false); + expect(boardHref("project-1", { mine: mineOn })).toBe("/projects/project-1/stories"); + }); }); function pipelinePull( diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx new file mode 100644 index 00000000..a7d1bd10 --- /dev/null +++ b/packages/ui/src/avatar.tsx @@ -0,0 +1,55 @@ +import { cx } from "./cx"; + +/** `"` and `\` would end the CSS string early; whitespace would end the url() token. */ +function cssUrl(src: string): string { + return `url("${src.replace(/["\\\s]/g, encodeURIComponent)}")`; +} + +/** + * Square avatar with an initial-letter fallback underneath the image. + * + * The image is painted as a CSS background rather than as an `` on + * purpose. An `` that fails to load makes every browser draw its own + * broken-image glyph over the letter — `alt=""` does not suppress it — while a + * background that fails to load paints nothing at all. So a deployment whose + * browsers cannot reach the image host degrades to the letter on its own, with + * nothing to configure. + * + * Decorative: the login it stands for is always written out beside it. + */ +export function Avatar({ + src, + initial, + size, + className, +}: { + src?: string; + initial: string; + size: number; + className?: string; +}) { + return ( + + {initial} + {src ? ( + + ) : null} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ad740f9d..3bce0af7 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ +export { Avatar } from "./avatar"; export { Button, ButtonLink } from "./button"; export { cx } from "./cx"; export { Field, Select, TextArea, TextInput } from "./field";