diff --git a/src/features/board/components/BoardGrid.tsx b/src/features/board/components/BoardGrid.tsx index c0b780e2f..8c50816fd 100644 --- a/src/features/board/components/BoardGrid.tsx +++ b/src/features/board/components/BoardGrid.tsx @@ -30,6 +30,7 @@ import { OpenPullRequestsCard } from "./OpenPullRequestsCard"; import { PathStepCard } from "./PathStepCard"; import { PathToFirstContributionCard } from "./PathToFirstContributionCard"; import { SuggestedTasksCard } from "./SuggestedTasksCard"; +import { TaskPoolCard } from "./TaskPoolCard"; import { BoardCardContext } from "./boardCardControls"; import { BoardStageBand } from "./BoardStageBand"; import { cardAccent } from "../layout/cardAccents"; @@ -471,6 +472,8 @@ function BoardCardView({ return ; case "SUGGESTED_TASKS": return ; + case "TASK_POOL": + return ; case "COMPETENCY_PROGRESS": return ; case "MEMORY_RECAP": diff --git a/src/features/board/components/CurrentTaskCard.tsx b/src/features/board/components/CurrentTaskCard.tsx index 828174296..db71e99d4 100644 --- a/src/features/board/components/CurrentTaskCard.tsx +++ b/src/features/board/components/CurrentTaskCard.tsx @@ -31,6 +31,9 @@ type CurrentTaskCardProps = { * looking current: the task block is kept but muted, a warning stripe above it says what happened * and where to go next, and the buddy question is repointed at picking a new one. Silently * dropping the card would leave the hire still thinking this is their goal. + * + * Picking a task by hand happens on the "Task pool" card, which this one points at whenever there + * is something to pick; the buddy is offered beside it, never instead of it. */ export function CurrentTaskCard({ content, @@ -69,8 +72,8 @@ export function CurrentTaskCard({

)} @@ -98,8 +101,8 @@ export function CurrentTaskCard({ ) : ( - Nothing claimed yet. Ask your buddy what would be a good one to pick up — they can suggest - tasks that fit what you've already shown. + Nothing grabbed yet. Pick one from the "Task pool" card — or ask your buddy, who + can suggest tasks that fit what you've already shown. )} @@ -135,7 +138,7 @@ export function CurrentTaskCard({ ? `How do I get started on "${content.title ?? "my task"}"? A short checklist of first steps would help.` : "What would be a good task for me to pick up?" } - label={closed ? "Help me pick another one" : undefined} + label={closed || !hasTask ? "Help me choose" : undefined} /> ); diff --git a/src/features/board/components/SuggestedTasksCard.tsx b/src/features/board/components/SuggestedTasksCard.tsx index 2a518bd09..f1c4fedf5 100644 --- a/src/features/board/components/SuggestedTasksCard.tsx +++ b/src/features/board/components/SuggestedTasksCard.tsx @@ -5,6 +5,7 @@ import { Marked } from "./Marked"; import { useCardMarks } from "../marks/useCardMarks"; import { AskTheBuddy } from "../../buddy/components/AskTheBuddy"; import { AddTaskToBoard } from "./AddTaskToBoard"; +import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton"; import type { BoardCard, SuggestedTasksContent } from "../types"; type SuggestedTasksCardProps = { @@ -23,9 +24,10 @@ type SuggestedTasksCardProps = { * line per signal, and a number is not something a hire can act on. The order already carries * everything a score would say. * - * Claiming is deliberately not a button here. It changes what the hire's whole plan aims at, so it - * stays where every other onboarding-changing action is: proposed by the buddy, confirmed by the - * hire, in the conversation. + * Grabbing is a button here, with its own confirm step (see `GrabTaskButton`). It used to be + * buddy-only — "I want to work on this" opened the conversation and the mentor proposed the claim — + * but a hire who already knows what they want should not need a conversation to say so. The buddy + * is still one press away, for the hire who wants a second opinion first. */ export function SuggestedTasksCard({ content, @@ -72,15 +74,16 @@ export function SuggestedTasksCard({ )} - {/* Two different things, and only one of them is a commitment. Saying "I want to - work on this" aims the hire's whole plan at the task and goes through the - mentor's confirm button; keeping it is a working copy on their own board that - claims nothing. A hire who is not ready to claim anything should still be able to - put a task somewhere they will find it again. */} + {/* Grabbing is the commitment — it aims the hire's whole plan at the task. Keeping it + is a working copy on their own board that claims nothing, for a hire who is not + ready to commit but wants to find the task again. */}
+
+ +
diff --git a/src/features/board/components/TaskPoolCard.tsx b/src/features/board/components/TaskPoolCard.tsx new file mode 100644 index 000000000..d6307b93e --- /dev/null +++ b/src/features/board/components/TaskPoolCard.tsx @@ -0,0 +1,270 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { ChevronDown, ExternalLink, LayoutList, Search, UserCheck } from "lucide-react"; +import { Badge } from "../../../components/ui/Badge"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Input } from "../../../components/ui/Input"; +import { AskTheBuddy } from "../../buddy/components/AskTheBuddy"; +import { GrabTaskButton } from "../../task-pool/components/GrabTaskButton"; +import type { TaskType } from "../../starter-work/types"; +import { BoardCardFrame } from "./BoardCardFrame"; +import type { BoardCard, BoardPoolTask, TaskPoolContent } from "../types"; + +type TaskPoolCardProps = { + content: TaskPoolContent; + card: Pick; + onDismiss?: (cardId: string) => void; + dismissing?: boolean; +}; + +const TYPE_LABELS: Record = { + BUG: "Bug", + FEATURE: "Feature", + DOCS: "Docs", + TEST: "Tests", + CHORE: "Chore", + OTHER: "Other", +}; + +/** + * The whole starter-work pool, to browse and grab from by hand. + * + * **Browsing is the hire's, help choosing is the buddy's.** "Good next tasks" shows three and the + * buddy can reason about fit in conversation — but a task you can only reach by asking somebody is + * a task you cannot simply look for. This card is the direct way in: every live task, in the same + * fit order the buddy reads, with the same reasons, searchable, filterable, and grabbable here. + * + * The buddy stays one press away rather than being the only door: "Help me choose" sits above the + * list for the hire who does not know where to start, and "Is this a good fit?" sits on every task + * for the hire who found a candidate and wants a second opinion. + * + * The order is the backend's and is never re-sorted here. Filtering hides; it does not re-rank. + */ +export function TaskPoolCard({ content, card, onDismiss, dismissing }: TaskPoolCardProps) { + const { tasks, currentTaskId } = content; + // Handed to every grab button, so "Swap … for this?" names the same task the pool marks — even + // when the current-task card is not on the board to read it from. A current task that is no + // longer in the live pool leaves it to the button to read the card instead. + const currentTitle = + currentTaskId === null ? null : tasks.find((task) => task.taskId === currentTaskId)?.title; + const [query, setQuery] = useState(""); + const [type, setType] = useState("ALL"); + const [hideTaken, setHideTaken] = useState(false); + + const presentTypes = useMemo( + () => + (Object.keys(TYPE_LABELS) as TaskType[]).filter((t) => tasks.some((x) => x.taskType === t)), + [tasks], + ); + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return tasks.filter((task) => { + if (type !== "ALL" && task.taskType !== type) return false; + if (hideTaken && task.sourceHasAssignee === true) return false; + if (!needle) return true; + return `${task.title} ${task.summary ?? ""}`.toLowerCase().includes(needle); + }); + }, [tasks, query, type, hideTaken]); + + return ( + 0 + ? `${tasks.length} open ${tasks.length === 1 ? "task" : "tasks"}, best fit first` + : undefined + } + onDismiss={onDismiss} + dismissing={dismissing} + > + {tasks.length === 0 ? ( + + Your PM hasn't put any starter tasks up for this project yet. Your buddy can still + suggest something to get going with. + + ) : ( +
+

+ Pick one yourself — or, if you're not sure what fits, let your buddy help. +

+ setQuery(event.target.value)} + placeholder="Search tasks" + aria-label="Search tasks" + icon={
+ )} + + +
+ ); +} + +type PoolTaskRowProps = { + task: BoardPoolTask; + isCurrent: boolean; + /** See `GrabTaskButton.currentTitle` — undefined means "not known here". */ + currentTitle: string | null | undefined; +}; + +function PoolTaskRow({ task, isCurrent, currentTitle }: PoolTaskRowProps) { + const [expanded, setExpanded] = useState(false); + const hasMore = Boolean(task.summary || task.rationale) || task.reasons.length > 1; + + return ( +
  • +
    +

    {task.title}

    + {task.url && ( + + + )} +
    + +
    + {task.bestFit && ( + + Best fit + + )} + {task.taskType !== "OTHER" && ( + + {TYPE_LABELS[task.taskType]} + + )} + {task.sourceHasAssignee === true && ( + + + )} +
    + + {task.reasons.length > 0 && ( +
      + {(expanded ? task.reasons : task.reasons.slice(0, 1)).map((reason, index) => ( + // By position, not by text: the backend does not promise two reasons never read alike. +
    • + · {reason} +
    • + ))} +
    + )} + + {expanded && (task.summary || task.rationale) && ( +
    + {task.summary &&

    {task.summary}

    } + {task.rationale && ( +

    + Why it's a good first task: + {task.rationale} +

    + )} +
    + )} + +
    + + + {hasMore && ( + + )} +
    +
  • + ); +} + +function FilterChip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/src/features/board/layout/cardAccents.ts b/src/features/board/layout/cardAccents.ts index 6420bda7b..a4c4bf573 100644 --- a/src/features/board/layout/cardAccents.ts +++ b/src/features/board/layout/cardAccents.ts @@ -99,6 +99,7 @@ const ACCENTS: Record = { // What the board has worked out about the hire: what to try next, how far along they are. SUGGESTED_TASKS: PURPLE, + TASK_POOL: PURPLE, COMPETENCY_PROGRESS: PURPLE, // The hire's current step, live from the path — the one AI-owned card the hire partly writes to. diff --git a/src/features/board/layout/cardIcons.ts b/src/features/board/layout/cardIcons.ts index bd59bac33..0e9eb9219 100644 --- a/src/features/board/layout/cardIcons.ts +++ b/src/features/board/layout/cardIcons.ts @@ -4,6 +4,7 @@ import { CheckSquare, Footprints, GitPullRequest, + LayoutList, Link2, Network, PenLine, @@ -35,6 +36,7 @@ const ICONS: Record = { ARRIVAL_STEPS: PlaneLanding, OPEN_PULL_REQUESTS: GitPullRequest, SUGGESTED_TASKS: Sparkles, + TASK_POOL: LayoutList, COMPETENCY_PROGRESS: Award, MEMORY_RECAP: Brain, PATH_STEP: Footprints, diff --git a/src/features/board/layout/cardNames.ts b/src/features/board/layout/cardNames.ts index 2d62c4405..74e724a13 100644 --- a/src/features/board/layout/cardNames.ts +++ b/src/features/board/layout/cardNames.ts @@ -30,6 +30,8 @@ export function cardName(card: BoardCard): string { return content.title ?? "The task you are on"; case "SUGGESTED_TASKS": return "Work worth picking up"; + case "TASK_POOL": + return "Task pool"; case "COMPETENCY_PROGRESS": return "What you have shown"; case "MEMORY_RECAP": diff --git a/src/features/board/layout/taskPoolShown.ts b/src/features/board/layout/taskPoolShown.ts new file mode 100644 index 000000000..63963e7d9 --- /dev/null +++ b/src/features/board/layout/taskPoolShown.ts @@ -0,0 +1,50 @@ +/** + * Whether the hire has the task pool card on their board. + * + * The pool card is a switch, not a dismissal. Dismissing a card is sticky on the server — the board + * never brings back a card somebody said no to — and for most cards that is the point. The pool is + * the one way to grab a task without the buddy, though, so losing it for good to one stray press + * would quietly put the hire back to asking. So its X only hides it, and the switch in the board's + * rail brings it back, the same way the path strip works (see `pathWindowFold.ts`). + * + * Local storage for the same reason as the path strip: a preference that follows the machine is + * close enough, and it is not worth a round trip. The buddy does not see it. + */ +const STORAGE_VERSION = 1; + +function shownKey(boardId: string): string { + return `sprintstart:board-task-pool-shown:${boardId}`; +} + +type StoredShown = { version: number; shown: unknown }; + +/** Whether the pool is on this board, defaulting to yes — and to yes for anything unreadable. */ +export function readTaskPoolShown(boardId: string): boolean { + if (!boardId) return true; + + try { + const raw = window.localStorage.getItem(shownKey(boardId)); + if (!raw) return true; + + const parsed = JSON.parse(raw) as StoredShown; + if (parsed?.version !== STORAGE_VERSION || typeof parsed.shown !== "boolean") return true; + + return parsed.shown; + } catch { + return true; + } +} + +/** Stores whether the pool is on this board. A storage that refuses still holds for this visit. */ +export function writeTaskPoolShown(boardId: string, shown: boolean): void { + if (!boardId) return; + + try { + window.localStorage.setItem( + shownKey(boardId), + JSON.stringify({ version: STORAGE_VERSION, shown } satisfies StoredShown), + ); + } catch { + // Nothing to do and nothing to say. + } +} diff --git a/src/features/board/types.ts b/src/features/board/types.ts index 92d23cf37..ce0153a43 100644 --- a/src/features/board/types.ts +++ b/src/features/board/types.ts @@ -1,5 +1,6 @@ import type { ArrivalStep } from "../arrival/types"; import type { StepStatus } from "../onboarding/types"; +import type { TaskType } from "../starter-work/types"; /** * The board: a hire's persistent working surface on one project. @@ -15,6 +16,7 @@ export type BoardCardKind = | "OPEN_PULL_REQUESTS" | "CURRENT_TASK" | "SUGGESTED_TASKS" + | "TASK_POOL" | "COMPETENCY_PROGRESS" | "MEMORY_RECAP" | "DIAGRAM" @@ -140,6 +142,32 @@ export type SuggestedTasksContent = { tasks: BoardSuggestedTask[]; }; +/** One task in the pool, with what a hire needs to choose it. Never a score. */ +export type BoardPoolTask = { + taskId: string; + title: string; + summary: string | null; + /** Why this is a reasonable first task, in the words of whoever put it in the pool. */ + rationale: string | null; + url: string | null; + taskType: TaskType; + reasons: string[]; + /** Near the top of the ranking and matched on at least one signal. */ + bestFit: boolean; + /** Three-valued: only `true` means the tracker shows somebody on it. */ + sourceHasAssignee: boolean | null; +}; + +/** + * The whole live pool, best fit first, to browse and grab from by hand. The order is the ranking — + * filter it, never re-sort it. `currentTaskId` is the task the hire is on, if any. + */ +export type TaskPoolContent = { + kind: "TASK_POOL"; + tasks: BoardPoolTask[]; + currentTaskId: string | null; +}; + /** One competency, with the bar it is measured against — never a score out of a hundred. */ export type BoardCompetency = { competencyKey: string; @@ -326,6 +354,7 @@ export type BoardCardContent = | OpenPullRequestsContent | CurrentTaskContent | SuggestedTasksContent + | TaskPoolContent | CompetencyProgressContent | MemoryRecapContent | DiagramContent diff --git a/src/features/buddy/components/AskTheBuddy.tsx b/src/features/buddy/components/AskTheBuddy.tsx index e5aab3c77..3c5525b73 100644 --- a/src/features/buddy/components/AskTheBuddy.tsx +++ b/src/features/buddy/components/AskTheBuddy.tsx @@ -6,6 +6,11 @@ type AskTheBuddyProps = { question: string; /** What the control says. Defaults to a plain invitation. */ label?: string; + /** + * Spacing around the control. Defaults to the gap it needs under a card's body; a row that lays + * its actions out itself passes an empty string. + */ + className?: string; }; /** @@ -14,9 +19,10 @@ type AskTheBuddyProps = { * One mechanism serves every surface that has one. A card can seed a question and the mentor * answers it with its own tools — which means a card never needs its own action machinery, and * anything that *would* change the hire's onboarding still arrives as a proposal the hire confirms. - * Claiming a suggested task is the case that proves it: "I want to work on X" makes the mentor - * propose `claim_goal`, and the confirm button is still the thing that claims it. A claim button on - * the card would have gone around that gate. + * + * Grabbing a task is the one exception with its own button (`task-pool/GrabTaskButton`), because a + * hire who already knows what they want should not need a conversation to say so. It keeps a + * confirm step of its own, and this stays beside it for the hire who wants help choosing. * * The draft is pre-filled rather than sent, so the hire can change it before it goes — it is their * question, and a card that speaks for somebody is a card they stop trusting. @@ -32,12 +38,16 @@ type AskTheBuddyProps = { * The one place `openAiBuddy` really is a no-op is `/buddy`, where the widget takes itself off the * page: nothing renders this there, and the page's own composer is the thing to use. */ -export function AskTheBuddy({ question, label = "Ask your buddy about this" }: AskTheBuddyProps) { +export function AskTheBuddy({ + question, + label = "Ask your buddy about this", + className = "mt-3", +}: AskTheBuddyProps) { return ( + ); + } + + return ( + + + {replaces ? ( + <> + Swap "{replaces}" for this? + + ) : ( + "Make this your task?" + )} + + + + + ); +} diff --git a/src/features/task-pool/hooks/useTaskPool.ts b/src/features/task-pool/hooks/useTaskPool.ts new file mode 100644 index 000000000..eb7a180ee --- /dev/null +++ b/src/features/task-pool/hooks/useTaskPool.ts @@ -0,0 +1,37 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { myStarterWorkService } from "../../../services/myStarterWorkService"; +import { queryKeys } from "../../../services/queryKeys"; +import { useInvalidateBoard } from "../../board/hooks/useInvalidateBoard"; +import type { Board, CurrentTaskContent } from "../../board/types"; + +/** + * The task the hire is on right now, read off the board already in the cache. + * + * Never fetches. Every place that grabs a task lives on the board, so the board has been read by + * the time this is asked — and the current-task card is the one source the rest of the page + * already agrees with. Null when there is no task, or no board loaded yet. + */ +export function useCurrentTask(projectId: string): CurrentTaskContent | null { + const { data } = useQuery({ + queryKey: queryKeys.board.byProject(projectId), + enabled: false, + }); + const content = data?.cards.find((card) => card.content.kind === "CURRENT_TASK")?.content; + return content?.kind === "CURRENT_TASK" && content.taskId !== null ? content : null; +} + +/** + * Grabs a pool task as the hire's current task. + * + * Re-reads this project's board afterwards, whatever happened. On success the current-task card + * says something else (or has just been pinned) and the pool's "you're on this" marker has moved. + * On a 409 the backend has just checked the task against its source and found it no longer live — + * so the pool card is known to be stale, and the task should leave it under the toast that says so. + */ +export function useGrabTask(projectId: string) { + const invalidateBoard = useInvalidateBoard(projectId); + return useMutation({ + mutationFn: (taskId: string) => myStarterWorkService.claim(projectId, taskId), + onSettled: invalidateBoard, + }); +} diff --git a/src/pages/BoardPage.tsx b/src/pages/BoardPage.tsx index 3990ab8ef..77d2dbd24 100644 --- a/src/pages/BoardPage.tsx +++ b/src/pages/BoardPage.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router-dom"; import { AlertCircle, - Bot, Check, FolderPlus, LayoutDashboard, + LayoutList, ListTree, Maximize2, Milestone, @@ -42,6 +41,7 @@ import { useToast } from "../context/useToast"; import { useFocusMode } from "../context/useFocusMode"; import { readCollapsedCards, writeCollapsedCards } from "../features/board/layout/collapsedCards"; import { readPathWindowShown, writePathWindowShown } from "../features/board/layout/pathWindowFold"; +import { readTaskPoolShown, writeTaskPoolShown } from "../features/board/layout/taskPoolShown"; import { readPinnedCards, writePinnedCards } from "../features/board/layout/pinnedCards"; import { ALL_SECTIONS, @@ -313,6 +313,32 @@ export function BoardPage() { }); }, [showPathWindow, toast]); + // The task pool card, on or off — a switch like the path strip rather than a dismissal. See + // `layout/taskPoolShown.ts` for why this one card does not get the sticky server-side removal. + const [isTaskPoolShown, setIsTaskPoolShown] = useState(true); + const [taskPoolReadFor, setTaskPoolReadFor] = useState(null); + + if (storedFor !== taskPoolReadFor) { + setTaskPoolReadFor(storedFor); + setIsTaskPoolShown(readTaskPoolShown(boardId)); + } + + const showTaskPool = useCallback( + (shown: boolean) => { + setIsTaskPoolShown(shown); + writeTaskPoolShown(boardId, shown); + }, + [boardId], + ); + + const removeTaskPool = useCallback(() => { + showTaskPool(false); + toast.info("Task pool hidden", { + description: "You can put it back from the switches on the right.", + action: { label: "Undo", onClick: () => showTaskPool(true) }, + }); + }, [showTaskPool, toast]); + const [pinnedIds, setPinnedIds] = useState>(new Set()); const [pinsReadFor, setPinsReadFor] = useState(null); @@ -436,6 +462,11 @@ export function BoardPage() { } function handleDismiss(cardId: string) { + if (board?.cards.find((card) => card.id === cardId)?.content.kind === "TASK_POOL") { + removeTaskPool(); + return; + } + setPendingRemovals((current) => new Set(current).add(cardId)); const timer = window.setTimeout(() => { @@ -474,9 +505,19 @@ export function BoardPage() { * would be worse than no rail at all — the counts are about the board, and the view is about the * hire's attention. */ + // Whether the backend put a pool card on this board at all. The rail switch only exists for a + // board that has one: a switch that toggles nothing visible reads as broken. + const hasTaskPool = board?.cards.some((card) => card.content.kind === "TASK_POOL") ?? false; + const allCards = useMemo( - () => board?.cards.filter((card) => card !== pathCard && !pendingRemovals.has(card.id)) ?? [], - [board, pathCard, pendingRemovals], + () => + board?.cards.filter( + (card) => + card !== pathCard && + !pendingRemovals.has(card.id) && + (isTaskPoolShown || card.content.kind !== "TASK_POOL"), + ) ?? [], + [board, pathCard, pendingRemovals, isTaskPoolShown], ); const { @@ -1115,6 +1156,23 @@ export function BoardPage() {
    ) : null} - - {/* The board is curated by the mentor, so it should always be one click from them. */} -

    - -

    { + return await apiClient.fetch( + `${BASE_URL}/goal?projectId=${encodeURIComponent(projectId)}`, + { method: "POST", body: JSON.stringify({ taskId }) }, + ); + }, +}; diff --git a/tests/unit/features/board/BoardGrid.test.tsx b/tests/unit/features/board/BoardGrid.test.tsx index 758cea19e..4cebe4af1 100644 --- a/tests/unit/features/board/BoardGrid.test.tsx +++ b/tests/unit/features/board/BoardGrid.test.tsx @@ -227,7 +227,7 @@ describe("BoardGrid", () => { ); // Vanishing when the goal is cleared would read as the board losing things. - expect(screen.getByText(/nothing claimed yet/i)).toBeInTheDocument(); + expect(screen.getByText(/nothing grabbed yet/i)).toBeInTheDocument(); }); it("shows why each task was suggested, and never a score", () => { diff --git a/tests/unit/features/board/BoardSubmenus.test.tsx b/tests/unit/features/board/BoardSubmenus.test.tsx index 39f2abf60..f7c6ecffd 100644 --- a/tests/unit/features/board/BoardSubmenus.test.tsx +++ b/tests/unit/features/board/BoardSubmenus.test.tsx @@ -81,7 +81,7 @@ describe("taking a card into the conversation", () => { expect(lastDraft()).toContain("no response in 5 days"); }); - it("claiming a suggested task goes through the buddy, not around the confirm gate", () => { + it("a suggested task is grabbed behind its own confirm, with the buddy one press away", () => { render( { />, ); - // No claim button on the card: claiming changes what the hire's whole plan aims at. - expect(screen.queryByRole("button", { name: /^claim/i })).not.toBeInTheDocument(); + // Grabbing changes what the hire's whole plan aims at, so the first press only asks. + fireEvent.click(screen.getByRole("button", { name: /grab this/i })); + expect(screen.getByRole("button", { name: /yes, grab it/i })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /i want to work on this/i })); + fireEvent.click(screen.getByRole("button", { name: /is this a good fit/i })); expect(lastDraft()).toContain("Fix the flaky login test"); }); diff --git a/tests/unit/features/board/CurrentTaskCard.test.tsx b/tests/unit/features/board/CurrentTaskCard.test.tsx index f74d39561..fc9492a41 100644 --- a/tests/unit/features/board/CurrentTaskCard.test.tsx +++ b/tests/unit/features/board/CurrentTaskCard.test.tsx @@ -48,11 +48,11 @@ function lastDraft(): string { describe("the current-task card when the issue behind it was closed", () => { beforeEach(() => vi.mocked(openAiBuddy).mockReset()); - it("shows the warning and points at the good-next-tasks card", () => { + it("shows the warning and points at the task pool card", () => { render(); expect(screen.getByText(/this issue was closed where it lives/i)).toBeInTheDocument(); - expect(screen.getByText(/good next tasks/i)).toBeInTheDocument(); + expect(screen.getByText(/task pool/i)).toBeInTheDocument(); }); it("keeps the card and the task on screen rather than falling back to the empty state", () => { @@ -60,13 +60,13 @@ describe("the current-task card when the issue behind it was closed", () => { expect(screen.getByText("What you're working on")).toBeInTheDocument(); expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument(); - expect(screen.queryByText(/nothing claimed yet/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/nothing grabbed yet/i)).not.toBeInTheDocument(); }); it("seeds the buddy composer with a question about picking a different task", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /help me pick another one/i })); + fireEvent.click(screen.getByRole("button", { name: /help me choose/i })); expect(lastDraft()).toContain("Fix the flaky login test"); expect(lastDraft()).toContain("closed"); @@ -95,6 +95,10 @@ describe("the current-task card when the issue behind it was closed", () => { />, ); - expect(screen.getByText(/nothing claimed yet/i)).toBeInTheDocument(); + expect(screen.getByText(/nothing grabbed yet/i)).toBeInTheDocument(); + // Both ways in: the pool card, and asking the buddy to help pick. + expect(screen.getByText(/task pool/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /help me choose/i })); + expect(lastDraft()).toMatch(/good task for me/i); }); }); diff --git a/tests/unit/features/board/TaskPoolCard.test.tsx b/tests/unit/features/board/TaskPoolCard.test.tsx new file mode 100644 index 000000000..9170ea9f7 --- /dev/null +++ b/tests/unit/features/board/TaskPoolCard.test.tsx @@ -0,0 +1,186 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ApiError } from "../../../../src/services/apiClient"; + +import { TaskPoolCard } from "../../../../src/features/board/components/TaskPoolCard"; +import type { + BoardCard, + BoardPoolTask, + TaskPoolContent, +} from "../../../../src/features/board/types"; + +vi.mock("../../../../src/features/projects/useProjectContext", () => ({ + useProjectContext: () => ({ selectedProjectId: "p1" }), +})); + +vi.mock("../../../../src/features/buddy/aiBuddyBus", () => ({ + openAiBuddy: vi.fn(), +})); + +vi.mock("../../../../src/services/myStarterWorkService", () => ({ + myStarterWorkService: { claim: vi.fn() }, +})); + +import { openAiBuddy } from "../../../../src/features/buddy/aiBuddyBus"; +import { myStarterWorkService } from "../../../../src/services/myStarterWorkService"; + +const card: Pick = { + id: "c1", + owner: "AI", + placedAt: null, +}; + +function task(taskId: string, title: string, over: Partial = {}): BoardPoolTask { + return { + taskId, + title, + summary: null, + rationale: null, + url: null, + taskType: "BUG", + reasons: ["You know TypeScript"], + bestFit: false, + sourceHasAssignee: null, + ...over, + }; +} + +function content(over: Partial = {}): TaskPoolContent { + return { + kind: "TASK_POOL", + tasks: [ + task("t1", "Fix the flaky login test", { bestFit: true }), + task("t2", "Document the release flow", { taskType: "DOCS" }), + task("t3", "Add a retry to the uploader", { sourceHasAssignee: true }), + ], + currentTaskId: null, + ...over, + }; +} + +/** The draft the last "ask the buddy" control put in the composer. */ +function lastDraft(): string { + const calls = vi.mocked(openAiBuddy).mock.calls; + return calls[calls.length - 1][0]?.draft ?? ""; +} + +describe("the task pool card", () => { + beforeEach(() => { + vi.mocked(openAiBuddy).mockReset(); + vi.mocked(myStarterWorkService.claim).mockReset(); + }); + + it("lists the whole pool in the order it was ranked", () => { + render(); + + const titles = screen.getAllByText(/fix the flaky|document the release|add a retry/i); + expect(titles.map((el) => el.textContent)).toEqual([ + "Fix the flaky login test", + "Document the release flow", + "Add a retry to the uploader", + ]); + expect(screen.getByText("Best fit")).toBeInTheDocument(); + }); + + it("filters by search, type and whether somebody is already on it", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Docs" })); + expect(screen.queryByText("Fix the flaky login test")).not.toBeInTheDocument(); + expect(screen.getByText("Document the release flow")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + fireEvent.click(screen.getByRole("button", { name: "Hide taken" })); + expect(screen.queryByText("Add a retry to the uploader")).not.toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: /search tasks/i }), { + target: { value: "login" }, + }); + expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument(); + expect(screen.queryByText("Document the release flow")).not.toBeInTheDocument(); + }); + + it("grabs only after the second press", async () => { + vi.mocked(myStarterWorkService.claim).mockResolvedValue({ + proposalId: "t1", + title: "Fix the flaky login test", + summary: null, + sourceUrl: null, + }); + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]); + expect(myStarterWorkService.claim).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /yes, grab it/i })); + await waitFor(() => expect(myStarterWorkService.claim).toHaveBeenCalledWith("p1", "t1")); + }); + + it("marks the task the hire is on instead of offering to grab it", () => { + render(); + + expect(screen.getByText(/you're on this one/i)).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /grab this/i })).toHaveLength(2); + }); + + it("hands the choice to the buddy when the hire asks for help", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /help me choose/i })); + expect(lastDraft()).toMatch(/not sure which one fits/i); + + fireEvent.click(screen.getAllByRole("button", { name: /is this a good fit/i })[1]); + expect(lastDraft()).toContain("Document the release flow"); + }); + + it("moves focus to the confirm, and back when it is cancelled", () => { + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]); + const confirm = screen.getByRole("button", { name: /yes, grab it/i }); + expect(confirm).toHaveFocus(); + // The question is what a screen reader hears with the button, not a silent change nearby. + expect(confirm).toHaveAccessibleDescription(/make this your task/i); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(screen.getAllByRole("button", { name: /grab this/i })[0]).toHaveFocus(); + }); + + it("names the task it replaces from the pool itself", () => { + // No current-task card in any cache here — the pool alone knows what the hire is on. + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]); + + expect(screen.getByText(/swap/i)).toHaveTextContent( + 'Swap "Document the release flow" for this?', + ); + }); + + it("re-reads the board when the task turns out not to be open anymore", async () => { + vi.mocked(myStarterWorkService.claim).mockRejectedValue(new ApiError(409, "not live")); + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const invalidate = vi.spyOn(client, "invalidateQueries"); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + render(, { wrapper }); + + fireEvent.click(screen.getAllByRole("button", { name: /grab this/i })[0]); + fireEvent.click(screen.getByRole("button", { name: /yes, grab it/i })); + + // The backend just checked the task against its source, so the pool card is known to be stale. + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["board", "p1"] })); + // And the hire is handed back the button they started from. + expect(screen.getAllByRole("button", { name: /grab this/i })[0]).toHaveFocus(); + }); + + it("says so when the pool is empty, and still offers the buddy", () => { + render(); + + expect(screen.getByText(/hasn't put any starter tasks up/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /help me choose/i })).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/features/board/taskPoolShown.test.ts b/tests/unit/features/board/taskPoolShown.test.ts new file mode 100644 index 000000000..7e688fcc3 --- /dev/null +++ b/tests/unit/features/board/taskPoolShown.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { + readTaskPoolShown, + writeTaskPoolShown, +} from "../../../../src/features/board/layout/taskPoolShown"; + +describe("whether the task pool card is on the board", () => { + beforeEach(() => window.localStorage.clear()); + afterEach(() => vi.restoreAllMocks()); + + it("round-trips the switch", () => { + writeTaskPoolShown("b1", false); + expect(readTaskPoolShown("b1")).toBe(false); + + writeTaskPoolShown("b1", true); + expect(readTaskPoolShown("b1")).toBe(true); + }); + + it("keeps each board's answer to itself", () => { + writeTaskPoolShown("b1", false); + + expect(readTaskPoolShown("b2")).toBe(true); + }); + + it("shows it to anybody who has not decided otherwise", () => { + // A hire who has never seen the pool cannot have decided against it. + expect(readTaskPoolShown("b1")).toBe(true); + expect(readTaskPoolShown("")).toBe(true); + }); + + it("shows it rather than trusting something unreadable", () => { + // User-writable storage: a hand-edited or foreign entry must not take the pool off a board. + window.localStorage.setItem("sprintstart:board-task-pool-shown:b1", "{oh no"); + window.localStorage.setItem( + "sprintstart:board-task-pool-shown:b2", + JSON.stringify({ version: 99, shown: false }), + ); + + expect(readTaskPoolShown("b1")).toBe(true); + expect(readTaskPoolShown("b2")).toBe(true); + }); + + it("survives a storage that throws", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + + expect(() => writeTaskPoolShown("b1", false)).not.toThrow(); + expect(readTaskPoolShown("b1")).toBe(true); + }); +}); diff --git a/tests/unit/pages/BoardPageTaskPool.test.tsx b/tests/unit/pages/BoardPageTaskPool.test.tsx new file mode 100644 index 000000000..c9ae7eae6 --- /dev/null +++ b/tests/unit/pages/BoardPageTaskPool.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { BoardPage } from "../../../src/pages/BoardPage"; +import { ToastProvider } from "../../../src/context/ToastProvider"; +import type { Board, BoardCard } from "../../../src/features/board/types"; + +vi.mock("../../../src/services/boardService", () => ({ + boardService: { fetchBoard: vi.fn(), dismissCard: vi.fn() }, +})); + +vi.mock("../../../src/context/useAuth", () => ({ + useAuth: () => ({ profile: { permissionGroup: "USER" } }), +})); + +vi.mock("../../../src/features/projects/useProjectContext", async () => { + const { createProjectContextValue, createSelectableProject } = + await import("../setup/projectContext"); + return { + useProjectContext: () => + createProjectContextValue({ + selectedProjectId: "p1", + projects: [createSelectableProject({ id: "p1", name: "Project One" })], + selectedProject: createSelectableProject({ id: "p1", name: "Project One" }), + }), + }; +}); + +import { boardService } from "../../../src/services/boardService"; + +const note: BoardCard = { + id: "c1", + kind: "NOTE", + owner: "HIRE", + position: 0, + placedAt: null, + content: { kind: "NOTE", text: "deploys are on Thursdays" }, +}; + +const pool: BoardCard = { + id: "c2", + kind: "TASK_POOL", + owner: "AI", + position: 1, + placedAt: null, + content: { + kind: "TASK_POOL", + currentTaskId: null, + tasks: [ + { + taskId: "t1", + title: "Fix the flaky login test", + summary: null, + rationale: null, + url: null, + taskType: "BUG", + reasons: [], + bestFit: true, + sourceHasAssignee: null, + }, + ], + }, +}; + +function boardWith(...cards: BoardCard[]): Board { + return { boardId: "b1", projectId: "p1", cards }; +} + +async function renderBoard() { + render( + + + + + , + ); + await waitFor(() => + expect(screen.getByRole("heading", { name: "deploys are on Thursdays" })).toBeInTheDocument(), + ); +} + +/** + * The pool is the one way to grab a task without the buddy, so it is a switch rather than a + * dismissal: a server-side dismissal is sticky and neither the hire nor the buddy could undo it. + */ +describe("the task pool on the board", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.mocked(boardService.fetchBoard).mockReset().mockResolvedValue(boardWith(note, pool)); + vi.mocked(boardService.dismissCard).mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => vi.useRealTimers()); + + it("hides on its X and never tells the server", async () => { + await renderBoard(); + expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /remove the task pool card/i })); + expect(screen.queryByText("Fix the flaky login test")).not.toBeInTheDocument(); + + // Well past the undo window every other card waits out before it dismisses. + await act(async () => { + await vi.advanceTimersByTimeAsync(8000); + }); + expect(boardService.dismissCard).not.toHaveBeenCalled(); + }); + + it("comes back with Undo", async () => { + await renderBoard(); + + fireEvent.click(screen.getByRole("button", { name: /remove the task pool card/i })); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + + expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument(); + }); + + it("is switched off and on again from the rail", async () => { + await renderBoard(); + + fireEvent.click(screen.getByRole("button", { name: "Hide the task pool" })); + expect(screen.queryByText("Fix the flaky login test")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Show the task pool" })); + expect(screen.getByText("Fix the flaky login test")).toBeInTheDocument(); + expect(boardService.dismissCard).not.toHaveBeenCalled(); + }); + + it("offers no switch on a board without a pool card", async () => { + vi.mocked(boardService.fetchBoard).mockResolvedValue(boardWith(note)); + await renderBoard(); + + expect(screen.queryByRole("button", { name: /the task pool/i })).not.toBeInTheDocument(); + }); +});