From d1e9c6e65d16a96315f095c6dd45af46b61f1a6c Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 11:00:30 -0500 Subject: [PATCH] feat(bd-1cu): implement FocusView container with focus selection Add suggestion state when no focus is set but items exist in queue: - FocusView now shows SuggestionCard when current is null but queue has items - SuggestionCard displays suggested item with 'Set as focus' button - Clicking 'Set as focus' promotes the suggestion to current focus - Auto-advances to next item after completing current focus - Shows empty state celebration when all items are complete TDD: 14 tests covering focus, suggestion, empty states, and actions --- src/components/FocusView.tsx | 47 +++++- src/components/SuggestionCard.tsx | 98 ++++++++++++ tests/components/FocusView.test.tsx | 223 ++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 src/components/SuggestionCard.tsx create mode 100644 tests/components/FocusView.test.tsx diff --git a/src/components/FocusView.tsx b/src/components/FocusView.tsx index 5bd01c9..10cff76 100644 --- a/src/components/FocusView.tsx +++ b/src/components/FocusView.tsx @@ -3,10 +3,16 @@ * * Connects to the Zustand store and Tauri backend. * Handles "Start" by opening the URL in the browser via Tauri shell. + * + * Focus selection logic: + * 1. If user has set a focus (current) -> show FocusCard with that item + * 2. If no focus set but queue has items -> show suggestion from queue + * 3. If no focus and no items -> show empty/celebration state */ import { useCallback } from "react"; import { FocusCard } from "./FocusCard"; +import { SuggestionCard } from "./SuggestionCard"; import { QueueSummary } from "./QueueSummary"; import { useFocusStore } from "@/stores/focus-store"; import { open } from "@tauri-apps/plugin-shell"; @@ -17,6 +23,15 @@ export function FocusView(): React.ReactElement { const isLoading = useFocusStore((s) => s.isLoading); const error = useFocusStore((s) => s.error); const act = useFocusStore((s) => s.act); + const setFocus = useFocusStore((s) => s.setFocus); + + // The suggestion is the first item in the queue when no focus is set + const suggestion = !current && queue.length > 0 ? queue[0] : null; + + // Determine what to show in the queue summary: + // - If we have a suggestion, show remaining queue (minus the suggestion) + // - Otherwise, show full queue + const displayQueue = suggestion ? queue.slice(1) : queue; const handleStart = useCallback(() => { if (current?.url) { @@ -39,6 +54,13 @@ export function FocusView(): React.ReactElement { act("skip"); }, [act]); + // Handle setting suggestion as focus + const handleSetAsFocus = useCallback(() => { + if (suggestion) { + setFocus(suggestion.id); + } + }, [suggestion, setFocus]); + if (isLoading) { return (
@@ -59,17 +81,26 @@ export function FocusView(): React.ReactElement {
{/* Main focus area */}
- + {suggestion ? ( + // Suggestion state: no focus set, but items exist + + ) : ( + // Focus state or empty state (FocusCard handles empty internally) + + )}
{/* Queue summary bar */} - +
); } diff --git a/src/components/SuggestionCard.tsx b/src/components/SuggestionCard.tsx new file mode 100644 index 0000000..1236763 --- /dev/null +++ b/src/components/SuggestionCard.tsx @@ -0,0 +1,98 @@ +/** + * SuggestionCard -- displays a suggested next item when no focus is set. + * + * Shows the item with a "Set as focus" button to promote it to THE ONE THING. + * This is used when the queue has items but the user hasn't picked one yet. + */ + +import { motion } from "framer-motion"; +import type { FocusItem, FocusItemType, Staleness } from "@/lib/types"; +import { computeStaleness } from "@/lib/types"; +import { formatIid } from "@/lib/format"; + +interface SuggestionCardProps { + item: FocusItem; + onSetAsFocus: () => void; +} + +const TYPE_LABELS: Record = { + mr_review: "MR REVIEW", + issue: "ISSUE", + mr_authored: "MR AUTHORED", + manual: "TASK", +}; + +const STALENESS_COLORS: Record = { + fresh: "bg-mc-fresh/20 text-mc-fresh border-mc-fresh/30", + normal: "bg-zinc-700/50 text-zinc-300 border-zinc-600", + amber: "bg-mc-amber/20 text-mc-amber border-mc-amber/30", + urgent: "bg-mc-urgent/20 text-mc-urgent border-mc-urgent/30", +}; + +export function SuggestionCard({ + item, + onSetAsFocus, +}: SuggestionCardProps): React.ReactElement { + const staleness = computeStaleness(item.updatedAt); + + return ( + + {/* Suggestion label */} +

Suggested next

+ + {/* Type badge */} +
+ + {TYPE_LABELS[item.type]} + +
+ + {/* Title */} +

+ {item.title} +

+ + {/* Metadata line */} +

+ {formatIid(item.type, item.iid)} in {item.project} +

+ + {/* Context quote */} + {(item.contextQuote || item.requestedBy) && ( +
+ {item.requestedBy && ( +

+ @{item.requestedBy} +

+ )} + {item.contextQuote && ( +

+ “{item.contextQuote}” +

+ )} +
+ )} + + {/* Set as focus button */} +
+ +
+
+ ); +} diff --git a/tests/components/FocusView.test.tsx b/tests/components/FocusView.test.tsx new file mode 100644 index 0000000..5288244 --- /dev/null +++ b/tests/components/FocusView.test.tsx @@ -0,0 +1,223 @@ +/** + * FocusView tests -- the main focus container. + * + * Tests: + * 1. Shows FocusCard when focus is set + * 2. Shows empty state when no focus and no items + * 3. Shows suggestion when no focus but items exist + * 4. Auto-advances to next item after complete + * 5. Shows celebration on last item complete + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FocusView } from "@/components/FocusView"; +import { useFocusStore } from "@/stores/focus-store"; +import { makeFocusItem } from "../helpers/fixtures"; + +// Mock the shell plugin for URL opening - must return Promise +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(() => Promise.resolve()), +})); + +describe("FocusView", () => { + beforeEach(() => { + localStorage.clear(); + useFocusStore.setState({ + current: null, + queue: [], + isLoading: false, + error: null, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("with focus set", () => { + it("shows FocusCard when focus is set", () => { + const item = makeFocusItem({ id: "1", title: "Test Item" }); + useFocusStore.setState({ current: item, queue: [] }); + + render(); + + expect(screen.getByText("Test Item")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /start/i })).toBeInTheDocument(); + }); + + it("shows queue summary when items exist in queue", () => { + const current = makeFocusItem({ id: "1", title: "Current" }); + const queued = makeFocusItem({ id: "2", title: "Queued", type: "issue" }); + useFocusStore.setState({ current, queue: [queued] }); + + render(); + + expect(screen.getByText(/Queue:/)).toBeInTheDocument(); + expect(screen.getByText(/1 issue/)).toBeInTheDocument(); + }); + }); + + describe("empty state", () => { + it("shows empty state when no focus and no items", () => { + useFocusStore.setState({ current: null, queue: [] }); + + render(); + + expect(screen.getByText(/all clear/i)).toBeInTheDocument(); + expect(screen.getByText(/nothing needs your attention/i)).toBeInTheDocument(); + }); + + it("shows celebration message in empty state", () => { + useFocusStore.setState({ current: null, queue: [] }); + + render(); + + expect(screen.getByText(/nice work/i)).toBeInTheDocument(); + }); + }); + + describe("suggestion state", () => { + it("shows suggestion when no focus but items exist in queue", () => { + const item = makeFocusItem({ id: "1", title: "Suggested Item" }); + useFocusStore.setState({ current: null, queue: [item] }); + + render(); + + // Should show the item as a suggestion + expect(screen.getByText("Suggested Item")).toBeInTheDocument(); + // Should have a "Set as focus" or "Start" button + expect( + screen.getByRole("button", { name: /set as focus|start/i }) + ).toBeInTheDocument(); + }); + + it("promotes suggestion to focus when user clicks set as focus", async () => { + const user = userEvent.setup(); + const item = makeFocusItem({ id: "1", title: "Suggested Item" }); + useFocusStore.setState({ current: null, queue: [item] }); + + render(); + + // Click the set as focus button + await user.click(screen.getByRole("button", { name: /set as focus|start/i })); + + // Item should now be the current focus + expect(useFocusStore.getState().current?.id).toBe("1"); + }); + }); + + describe("auto-advance behavior", () => { + it("auto-advances to next item after complete", async () => { + const user = userEvent.setup(); + const item1 = makeFocusItem({ id: "1", title: "First Item" }); + const item2 = makeFocusItem({ id: "2", title: "Second Item" }); + useFocusStore.setState({ current: item1, queue: [item2] }); + + render(); + + // Complete current focus by clicking start (which advances) + await user.click(screen.getByRole("button", { name: /start/i })); + + // Should show next item + await waitFor(() => { + expect(screen.getByText("Second Item")).toBeInTheDocument(); + }); + }); + + it("shows empty state after last item complete", async () => { + const user = userEvent.setup(); + const item = makeFocusItem({ id: "1", title: "Only Item" }); + useFocusStore.setState({ current: item, queue: [] }); + + render(); + + // Complete the only item + await user.click(screen.getByRole("button", { name: /start/i })); + + // Should show empty/celebration state + await waitFor(() => { + expect(screen.getByText(/all clear/i)).toBeInTheDocument(); + }); + }); + }); + + describe("focus selection", () => { + it("allows selecting a specific item as focus via setFocus", () => { + const item1 = makeFocusItem({ id: "1", title: "First" }); + const item2 = makeFocusItem({ id: "2", title: "Second" }); + const item3 = makeFocusItem({ id: "3", title: "Third" }); + useFocusStore.setState({ current: item1, queue: [item2, item3] }); + + // Use setFocus to promote item3 + useFocusStore.getState().setFocus("3"); + + const state = useFocusStore.getState(); + expect(state.current?.id).toBe("3"); + expect(state.queue.map((i) => i.id)).toContain("1"); + expect(state.queue.map((i) => i.id)).toContain("2"); + }); + }); + + describe("loading and error states", () => { + it("shows loading state", () => { + useFocusStore.setState({ isLoading: true }); + + render(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("shows error state", () => { + useFocusStore.setState({ error: "Something went wrong" }); + + render(); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + }); + + describe("action handlers", () => { + it("calls act with start action when Start is clicked", async () => { + const user = userEvent.setup(); + const item = makeFocusItem({ id: "1", title: "Test" }); + + // Create a mock act function to track calls + const mockAct = vi.fn((_action: string, _reason?: string) => null); + useFocusStore.setState({ current: item, queue: [], act: mockAct }); + + render(); + await user.click(screen.getByRole("button", { name: /start/i })); + + // act is called with "start" action + expect(mockAct).toHaveBeenCalledWith("start"); + }); + + it("calls act with defer_1h action when 1 hour is clicked", async () => { + const user = userEvent.setup(); + const item = makeFocusItem({ id: "1", title: "Test" }); + + const mockAct = vi.fn((_action: string, _reason?: string) => null); + useFocusStore.setState({ current: item, queue: [], act: mockAct }); + + render(); + await user.click(screen.getByRole("button", { name: /1 hour/i })); + + expect(mockAct).toHaveBeenCalledWith("defer_1h"); + }); + + it("calls act with skip action when Skip is clicked", async () => { + const user = userEvent.setup(); + const item = makeFocusItem({ id: "1", title: "Test" }); + + const mockAct = vi.fn((_action: string, _reason?: string) => null); + useFocusStore.setState({ current: item, queue: [], act: mockAct }); + + render(); + await user.click(screen.getByRole("button", { name: /skip/i })); + + expect(mockAct).toHaveBeenCalledWith("skip"); + }); + }); +});