From e0f2cf3d240618baf28bbf8e5f0b6a41367fef87 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 12:58:53 +0000 Subject: [PATCH] feat(task T03): implement via codex --- .../components/ui/PlanningProgressOverlay.tsx | 77 +++++++++-- .../src/v2/components/ui/PlanningShip.tsx | 89 +++++++++++++ tests/dashboard/v2/sprint-composer.test.tsx | 10 +- tests/dashboard/v2/ui-components.test.tsx | 122 ++++++++++++++++++ 4 files changed, 286 insertions(+), 12 deletions(-) diff --git a/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx b/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx index 1210b13281..39e969b7e8 100644 --- a/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx +++ b/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx @@ -1,9 +1,9 @@ import type { FunctionComponent } from "preact"; import { X } from "lucide-preact"; -import { useRef, useLayoutEffect, useEffect } from "preact/hooks"; +import { useRef, useLayoutEffect, useEffect, useState } from "preact/hooks"; import gsap from "gsap"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; -import { ContainerShip, WoodenShip } from "./PlanningShip.js"; +import { CoffeeCup, ContainerShip, WoodenShip } from "./PlanningShip.js"; import { type PlanningActionType, type PlanningFeedback, PLANNING_ACTION_LABELS } from "../../lib/sprint-planning-feedback.js"; import { MODAL_MOTION } from "../../lib/motion/modal-motion.js"; @@ -38,8 +38,20 @@ export const PlanningProgressOverlay: FunctionComponent { const textContainerRef = useRef(null); const prevTextRef = useRef(feedback?.text); + const prevBusyStateRef = useRef<{ isBusy: boolean; actionType: PlanningProgressOverlayProps["actionType"] }>({ isBusy, actionType }); + const [isCoffeeBreak, setIsCoffeeBreak] = useState(false); const reducedMotion = useReducedMotion(); + useEffect(() => { + const previous = prevBusyStateRef.current; + if (!isBusy) { + setIsCoffeeBreak(false); + } else if (!previous.isBusy || previous.actionType !== actionType) { + setIsCoffeeBreak(false); + } + prevBusyStateRef.current = { isBusy, actionType }; + }, [actionType, isBusy]); + useEffect(() => { if (!isBusy || isDismissed) { return; @@ -79,6 +91,10 @@ export const PlanningProgressOverlay: FunctionComponent
-
- - {feedback.shipType === "container" ? ( - - ) : ( - - )} - +
@@ -245,6 +293,15 @@ export const PlanningProgressOverlay: FunctionComponent {getDescriptionText()}

+ {isCoffeeBreak && ( +

+ Coffee break unlocked. Grab a fresh cup while planning keeps moving. +

+ )}

You can minimize this panel and keep the request running, or cancel it from here.

diff --git a/dashboard/src/v2/components/ui/PlanningShip.tsx b/dashboard/src/v2/components/ui/PlanningShip.tsx index d7afe63adb..4a4919713d 100644 --- a/dashboard/src/v2/components/ui/PlanningShip.tsx +++ b/dashboard/src/v2/components/ui/PlanningShip.tsx @@ -117,3 +117,92 @@ export const WoodenShip: FunctionComponent = (props) => { ); }; + +export const CoffeeCup: FunctionComponent = ({ accentColor, isMoving, isDark }) => { + const isReducedMotion = useReducedMotion(); + const shouldAnimate = isMoving && !isReducedMotion; + const cupFill = isDark ? "#162337" : "#f8fafc"; + const cupStroke = isDark ? "#6b7fa0" : "#94a3b8"; + const coffeeFill = isDark ? "#c0842f" : "#7c3f16"; + const saucerFill = isDark ? "#0f1a2c" : "#e2e8f0"; + const steamColor = isDark ? "#e0f2fe" : "#64748b"; + const highlightFill = isDark ? "#ffffff" : "#ffffff"; + + const steamPaths = [ + { d: "M-20 -18 C-30 -31 -12 -36 -22 -49", delay: "0s" }, + { d: "M0 -17 C-10 -30 10 -36 0 -50", delay: "0.22s" }, + { d: "M20 -18 C10 -31 30 -36 18 -49", delay: "0.44s" }, + ]; + + return ( + + + + {shouldAnimate && } + + + + + + + + + + + + + {steamPaths.map((path) => ( + + {shouldAnimate && ( + <> + + + + )} + + ))} + + + + + ); +}; diff --git a/tests/dashboard/v2/sprint-composer.test.tsx b/tests/dashboard/v2/sprint-composer.test.tsx index 067bb5d4d6..d691c73022 100644 --- a/tests/dashboard/v2/sprint-composer.test.tsx +++ b/tests/dashboard/v2/sprint-composer.test.tsx @@ -590,7 +590,7 @@ describe("SprintComposer", () => { const mockOnCancelPlanningRequest = vi.fn(); const mockOnSubmit = vi.fn(async () => new Promise(() => undefined)); - const { getByText, getByPlaceholderText, queryByText, getAllByText } = render( + const { getByText, getByPlaceholderText, queryByText, getAllByText, getByRole } = render( ); @@ -611,6 +611,9 @@ describe("SprintComposer", () => { expect(mockOnSubmit).toHaveBeenCalled(); + fireEvent.click(getByRole("button", { name: /turn planning vessel into a coffee break reminder/i })); + expect(getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument(); + // Click Cancel Active Request through the overlay specifically. const cancelBtns = getAllByText("Cancel Active Request"); // Click the one inside the overlay @@ -644,7 +647,7 @@ describe("SprintComposer", () => { const mockOnStartNewSprint = vi.fn(); const mockOnClose = vi.fn(); - const { getByText, getByPlaceholderText, getAllByText } = render( + const { getByText, getByPlaceholderText, getAllByText, getByRole } = render( { const firstSignal = mockOnSubmit.mock.calls[0]?.[0]?.signal as AbortSignal; expect(firstSignal).toBeInstanceOf(AbortSignal); + fireEvent.click(getByRole("button", { name: /turn planning vessel into a coffee break reminder/i })); + expect(getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument(); + // Click New Sprint const newSprintBtn = getByText("New Sprint"); fireEvent.click(newSprintBtn); diff --git a/tests/dashboard/v2/ui-components.test.tsx b/tests/dashboard/v2/ui-components.test.tsx index 8b1e47cd69..82ea6279a2 100644 --- a/tests/dashboard/v2/ui-components.test.tsx +++ b/tests/dashboard/v2/ui-components.test.tsx @@ -326,6 +326,128 @@ describe("UI Components Coverage", () => { expect(document.body.textContent).toContain("New Sprint"); }); + it("activates the planning vessel coffee reminder accessibly without breaking overlay controls", async () => { + const user = userEvent.setup(); + const onDismiss = vi.fn(); + const onCancel = vi.fn(); + const onSecondaryAction = vi.fn(); + const feedback = { ...getPlanningFeedback("plan_only", SHIP_LOOP_MS * 0.45), text: "Coffee Test" }; + const { rerender } = render( + + ); + + const vesselButton = screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i }); + expect(vesselButton).toHaveAttribute("aria-pressed", "false"); + + await user.click(vesselButton); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument(); + expect(screen.getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument(); + expect(screen.getByText("ETA")).toBeInTheDocument(); + expect(screen.getByText("Elapsed")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Minimize" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New Sprint" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel Active Request" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "New Sprint" })); + expect(onSecondaryAction).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Cancel Active Request" })); + expect(onCancel).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("dialog")); + expect(onDismiss).toHaveBeenCalledTimes(1); + + rerender( + + ); + + await waitFor(() => { + expect(screen.queryByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).not.toBeInTheDocument(); + }); + + const resetVesselButton = screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i }); + resetVesselButton.focus(); + await user.keyboard("{Enter}"); + expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument(); + expect(screen.getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument(); + + rerender( + + ); + rerender( + + ); + + await waitFor(() => { + expect(screen.queryByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).not.toBeInTheDocument(); + }); + + const freshVesselButton = screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i }); + freshVesselButton.focus(); + fireEvent.keyDown(freshVesselButton, { key: " ", code: "Space" }); + expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument(); + expect(screen.getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument(); + }); + + it("renders static coffee steam when reduced motion is enabled", async () => { + vi.mocked(useReducedMotion).mockReturnValue(true); + const user = userEvent.setup(); + const feedback = { ...getPlanningFeedback("plan_only", SHIP_LOOP_MS * 0.45), text: "Static Coffee Test" }; + const { container } = render( + {}} + /> + ); + + await user.click(screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i })); + + expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument(); + expect(container.querySelector("animate")).toBeNull(); + expect(container.querySelector("animateTransform")).toBeNull(); + }); + it("handles keyboard navigation in FilterStrip", () => { const options = [{ value: "1", label: "Opt 1" }, { value: "2", label: "Opt 2" }]; const onChange = vi.fn();