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..2efd2fa1 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -2,6 +2,7 @@ import { Eyebrow, PillTag, StatusDot } from "@facility/ui"; import Link from "next/link"; import { notFound } from "next/navigation"; import { CiStatusLink } from "@/components/ci-status"; +import { AssigneeChip } from "@/components/issues/assignee-chip"; import { Markdown } from "@/components/markdown"; import { ErrorNotice, Offline } from "@/components/offline"; import { LiveRefresh } from "@/components/shell/live-refresh"; @@ -165,6 +166,9 @@ export default async function StoryPage({ {label} ))} + {story.assignees.map((login) => ( + + ))} ; - 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 ; @@ -50,13 +50,20 @@ export default async function ProjectStoriesPage({ const activeStage = stage && stageKeys.has(stage as PipelineStageKey) ? (stage as PipelineStageKey) : null; const items = pipelineResult.ok ? pipelineStories(pipelineResult.data) : []; + const githubLogin = me.ok ? me.data.principal.githubLogin : undefined; + const mineLogin = mine === "1" ? githubLogin : undefined; 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 counts = [...stages].reverse().map((candidate) => { + if (mineLogin === undefined) return candidate; + const stories = candidate.stories.filter((story) => story.assignees.includes(mineLogin)); + return { ...candidate, count: stories.length, stories }; + }); + const mineFilteredItems = counts.flatMap((candidate) => candidate.stories); + const activeOpenStoryCount = mineFilteredItems.filter((story) => story.state === "open").length; const stageFiltered = activeStage ? counts.filter((candidate) => candidate.key === activeStage) @@ -143,6 +150,26 @@ export default async function ProjectStoriesPage({ ) : null} + {githubLogin ? ( +
+ + + mine + +
+ ) : null} {!pipelineResult.ok ? ( diff --git a/apps/web/components/issues/assignee-chip.tsx b/apps/web/components/issues/assignee-chip.tsx new file mode 100644 index 00000000..56be78a1 --- /dev/null +++ b/apps/web/components/issues/assignee-chip.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import { assigneeInitial, githubAvatarUrl } from "@/lib/pipeline"; + +export function AssigneeChip({ login }: { login: string }) { + const [broken, setBroken] = useState(false); + return ( + + {broken ? ( + + {assigneeInitial(login)} + + ) : ( + setBroken(true)} + className="size-4 rounded-full border border-(--line)" + /> + )} + @{login} + + ); +} diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b2375..597b5e98 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -5,8 +5,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { CiStatusLink } from "@/components/ci-status"; +import { AssigneeChip } from "@/components/issues/assignee-chip"; import type { PipelineStory } from "@/lib/pipeline"; -import { storyHref } from "@/lib/pipeline"; +import { assigneeSummary, storyHref } from "@/lib/pipeline"; function fmtAgo(iso: string | null) { if (!iso) return "—"; @@ -194,6 +195,20 @@ export function IssueRow({ {label} ))} + {assigneeSummary(story.assignees).shown.map((login) => ( + + ))} + {assigneeSummary(story.assignees).extra > 0 ? ( + `@${login}`) + .join(", ")} + > + +{assigneeSummary(story.assignees).extra} + + ) : null} {fmtAgo(story.ghUpdatedAt)} {action()} diff --git a/apps/web/lib/pipeline.ts b/apps/web/lib/pipeline.ts index 679199c5..6859f605 100644 --- a/apps/web/lib/pipeline.ts +++ b/apps/web/lib/pipeline.ts @@ -108,6 +108,20 @@ export function pipelineStories(pipeline: Pipeline): PipelineStory[] { return pipeline.stages.flatMap((stage) => stage.stories); } +/** GitHub avatar for an assignee login; needs no sync column and no new table. */ +export function githubAvatarUrl(login: string) { + return `https://github.com/${login}.png?size=40`; +} + +/** Initial-letter fallback for deployments where the browser cannot reach github.com. */ +export function assigneeInitial(login: string) { + return login.charAt(0).toUpperCase(); +} + +export function assigneeSummary(assignees: string[], maxShown = 1) { + return { shown: assignees.slice(0, maxShown), extra: Math.max(0, assignees.length - maxShown) }; +} + /** Open, non-draft pull requests that are genuinely waiting on a human review. */ export function reviewablePullRequests>( stories: Story[], diff --git a/apps/web/test/pipeline-story.test.ts b/apps/web/test/pipeline-story.test.ts index 836e08c5..9ec27b71 100644 --- a/apps/web/test/pipeline-story.test.ts +++ b/apps/web/test/pipeline-story.test.ts @@ -1,7 +1,13 @@ 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 { + assigneeInitial, + assigneeSummary, + githubAvatarUrl, + reviewablePullRequests, + storyHref, +} from "@/lib/pipeline"; import { deriveStoryTimeline, proposalsForStory } from "@/lib/story"; describe("story presentation contract", () => { @@ -287,6 +293,21 @@ describe("story presentation contract", () => { expect(reviewablePullRequests([story]).map(({ pull }) => pull.number)).toEqual([22]); }); + + it("renders nothing for an unassigned story", () => { + expect(assigneeSummary([])).toEqual({ shown: [], extra: 0 }); + }); + + it("shows the first assignee and collapses the rest as +N", () => { + expect(assigneeSummary(["ada"])).toEqual({ shown: ["ada"], extra: 0 }); + expect(assigneeSummary(["ada", "grace", "linus"])).toEqual({ shown: ["ada"], extra: 2 }); + }); + + it("builds the avatar URL from the login and falls back to the initial", () => { + expect(githubAvatarUrl("ada")).toBe("https://github.com/ada.png?size=40"); + expect(assigneeInitial("ada")).toBe("A"); + expect(assigneeInitial("grace")).toBe("G"); + }); }); function pipelinePull(