Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 67 additions & 10 deletions dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -38,8 +38,20 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
}) => {
const textContainerRef = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -79,6 +91,10 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
badgeText: "text-signal-600 dark:text-signal-300",
pingBg1: "bg-signal-400",
pingBg2: "bg-signal-500",
coffeeFocusRing: "focus-visible:ring-signal-400 dark:focus-visible:ring-signal-300",
coffeeReminderBorder: "border-signal-500/20 dark:border-signal-400/20",
coffeeReminderBg: "bg-signal-500/[0.08] dark:bg-signal-400/[0.09]",
coffeeReminderShadow: "shadow-[0_10px_24px_rgba(0,224,160,0.08)]",
},
ember: {
shipContainer: "#FF6B00",
Expand All @@ -88,6 +104,10 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
badgeText: "text-ember-600 dark:text-ember-400",
pingBg1: "bg-ember-400",
pingBg2: "bg-ember-500",
coffeeFocusRing: "focus-visible:ring-ember-400 dark:focus-visible:ring-ember-400",
coffeeReminderBorder: "border-ember-500/20 dark:border-ember-400/20",
coffeeReminderBg: "bg-ember-500/[0.08] dark:bg-ember-400/[0.09]",
coffeeReminderShadow: "shadow-[0_10px_24px_rgba(255,107,0,0.08)]",
},
};

Expand Down Expand Up @@ -183,6 +203,7 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
<div
className="absolute inset-y-8 left-0 w-full transform-gpu transition-[transform,opacity,visibility] ease-linear motion-reduce:transition-none"
data-testid="planning-ship-traveler"
data-coffee-break={isCoffeeBreak ? "true" : "false"}
data-ship-phase={shipVisual.phase}
data-ship-visible={shipVisual.visible ? "true" : "false"}
style={{
Expand All @@ -193,14 +214,41 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
}}
>
<div className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="absolute left-[-74px] top-[30px] h-3 w-24 rounded-full bg-gradient-to-l from-transparent via-white/55 to-transparent blur-[2px] motion-safe:animate-pulse motion-reduce:animate-none dark:via-signal-300/22" />
<svg width="132" height="72" viewBox="-66 -38 132 72" className="drop-shadow-[0_12px_16px_rgba(15,23,42,0.18)] dark:drop-shadow-[0_14px_18px_rgba(0,0,0,0.45)]">
{feedback.shipType === "container" ? (
<ContainerShip accentColor={theme.shipContainer} isMoving={!reducedMotion} isDark={isDark} />
) : (
<WoodenShip accentColor={theme.shipWooden} isMoving={!reducedMotion} isDark={isDark} />
)}
</svg>
<button
type="button"
aria-label={isCoffeeBreak ? "Coffee break reminder is visible" : "Turn planning vessel into a coffee break reminder"}
aria-pressed={isCoffeeBreak ? "true" : "false"}
data-testid="planning-vessel-button"
onClick={(e) => {
e.stopPropagation();
setIsCoffeeBreak(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar" || e.code === "Space") {
e.preventDefault();
e.stopPropagation();
setIsCoffeeBreak(true);
}
}}
className={`pointer-events-auto relative block h-[72px] w-[132px] cursor-pointer rounded-[1.5rem] border-0 bg-transparent p-0 text-left outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.99] motion-reduce:transition-none dark:focus-visible:ring-offset-void-800 ${theme.coffeeFocusRing}`}
>
<span className="absolute left-[-74px] top-[30px] h-3 w-24 rounded-full bg-gradient-to-l from-transparent via-white/55 to-transparent blur-[2px] motion-safe:animate-pulse motion-reduce:animate-none dark:via-signal-300/22" aria-hidden="true" />
<svg
width="132"
height="72"
viewBox="-66 -38 132 72"
aria-hidden="true"
className="drop-shadow-[0_12px_16px_rgba(15,23,42,0.18)] dark:drop-shadow-[0_14px_18px_rgba(0,0,0,0.45)]"
>
{isCoffeeBreak ? (
<CoffeeCup accentColor={theme.shipContainer} isMoving={!reducedMotion} isDark={isDark} />
) : feedback.shipType === "container" ? (
<ContainerShip accentColor={theme.shipContainer} isMoving={!reducedMotion} isDark={isDark} />
) : (
<WoodenShip accentColor={theme.shipWooden} isMoving={!reducedMotion} isDark={isDark} />
)}
</svg>
</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -245,6 +293,15 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
<p className="mx-auto max-w-xs text-sm leading-relaxed text-slate-500 dark:text-slate-400">
{getDescriptionText()}
</p>
{isCoffeeBreak && (
<p
className={`mx-auto max-w-sm rounded-full border px-4 py-2 text-sm font-medium leading-relaxed text-slate-600 dark:text-slate-200 ${theme.coffeeReminderBorder} ${theme.coffeeReminderBg} ${theme.coffeeReminderShadow}`}
role="status"
aria-live="polite"
>
Coffee break unlocked. Grab a fresh cup while planning keeps moving.
</p>
)}
<p className="mx-auto max-w-sm text-xs leading-relaxed text-slate-400 dark:text-slate-500">
You can minimize this panel and keep the request running, or cancel it from here.
</p>
Expand Down
89 changes: 89 additions & 0 deletions dashboard/src/v2/components/ui/PlanningShip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,92 @@ export const WoodenShip: FunctionComponent<ShipProps> = (props) => {
</g>
);
};

export const CoffeeCup: FunctionComponent<ShipProps> = ({ 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 (
<g transform="scale(0.86)" data-testid="planning-coffee-cup">
<ellipse cx={0} cy={27} rx={48} ry={8} fill="#0f172a" opacity={isDark ? 0.3 : 0.12} />
<ellipse cx={0} cy={23} rx={39} ry={7} fill={accentColor} opacity={0.16}>
{shouldAnimate && <animate attributeName="rx" values="37;41;37" dur="2.8s" repeatCount="indefinite" />}
</ellipse>
<ellipse cx={0} cy={21} rx={50} ry={8} fill={saucerFill} stroke={cupStroke} strokeWidth={1.1} opacity={0.95} />
<ellipse cx={0} cy={20} rx={29} ry={3.8} fill={accentColor} opacity={0.18} />
<path
d="M-38 -8 C-36 3 -32 17 -24 22 H20 C29 19 35 3 37 -8 Z"
fill={cupFill}
stroke={cupStroke}
strokeWidth={1.5}
/>
<path
d="M-35 -8 C-31 0 -23 5 -12 5 H16 C25 5 32 0 35 -8"
fill="none"
stroke={accentColor}
strokeWidth={2}
strokeLinecap="round"
opacity={0.76}
/>
<ellipse cx={0} cy={-9} rx={38} ry={10} fill={cupFill} stroke={cupStroke} strokeWidth={1.4} />
<ellipse cx={0} cy={-9} rx={29} ry={6.5} fill={coffeeFill} opacity={0.92} />
<path d="M-18 -11 C-8 -15 7 -15 19 -11" stroke="#fed7aa" strokeWidth={1.4} strokeLinecap="round" opacity={0.56} />
<path
d="M37 -4 C55 -5 60 9 51 18 C44 25 32 21 31 13"
fill="none"
stroke={cupStroke}
strokeWidth={6}
strokeLinecap="round"
opacity={0.86}
/>
<path
d="M39 -3 C51 -3 54 8 48 14 C44 18 37 17 35 11"
fill="none"
stroke={isDark ? "#111827" : "#ffffff"}
strokeWidth={2.8}
strokeLinecap="round"
opacity={0.8}
/>
<path d="M-25 -2 C-24 9 -20 15 -13 17" stroke={highlightFill} strokeWidth={2.2} strokeLinecap="round" opacity={isDark ? 0.16 : 0.58} />
<g fill="none" stroke={steamColor} strokeWidth={2.2} strokeLinecap="round" opacity={isDark ? 0.66 : 0.44}>
{steamPaths.map((path) => (
<path key={path.d} d={path.d}>
{shouldAnimate && (
<>
<animateTransform
attributeName="transform"
type="translate"
values="0 2;0 -5;0 2"
dur="3.4s"
begin={path.delay}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.24;0.78;0.24"
dur="3.4s"
begin={path.delay}
repeatCount="indefinite"
/>
</>
)}
</path>
))}
</g>
<circle cx={-30} cy={10} r={3} fill={accentColor} opacity={0.72} />
<circle cx={-19} cy={14} r={2} fill={accentColor} opacity={0.38} />
</g>
);
};
10 changes: 8 additions & 2 deletions tests/dashboard/v2/sprint-composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SprintComposer {...defaultProps} onSubmit={mockOnSubmit} onCancelPlanningRequest={mockOnCancelPlanningRequest} />
);

Expand All @@ -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
Expand Down Expand Up @@ -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(
<SprintComposer
{...defaultProps}
onClose={mockOnClose}
Expand Down Expand Up @@ -673,6 +676,9 @@ describe("SprintComposer", () => {
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);
Expand Down
122 changes: 122 additions & 0 deletions tests/dashboard/v2/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PlanningProgressOverlay
isBusy
feedback={feedback}
planningEta={60000}
elapsedMs={1000}
isDark={false}
actionType="plan_only"
onDismiss={onDismiss}
onCancel={onCancel}
secondaryActionLabel="New Sprint"
onSecondaryAction={onSecondaryAction}
/>
);

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(
<PlanningProgressOverlay
isBusy
feedback={{ ...getPlanningFeedback("improve", SHIP_LOOP_MS * 0.45), text: "Improve Test" }}
planningEta={60000}
elapsedMs={1000}
isDark={false}
actionType="improve"
onDismiss={onDismiss}
onCancel={onCancel}
/>
);

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(
<PlanningProgressOverlay
isBusy={false}
feedback={null}
planningEta={60000}
elapsedMs={0}
isDark={false}
actionType="improve"
onDismiss={onDismiss}
/>
);
rerender(
<PlanningProgressOverlay
isBusy
feedback={{ ...getPlanningFeedback("improve", SHIP_LOOP_MS * 0.45), text: "Fresh Improve Test" }}
planningEta={60000}
elapsedMs={1000}
isDark={false}
actionType="improve"
onDismiss={onDismiss}
/>
);

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(
<PlanningProgressOverlay
isBusy
feedback={feedback}
planningEta={60000}
elapsedMs={1000}
isDark={false}
actionType="plan_only"
onDismiss={() => {}}
/>
);

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();
Expand Down