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
This commit is contained in:
teernisse
2026-02-26 11:00:30 -05:00
parent bcc55ec798
commit d1e9c6e65d
3 changed files with 360 additions and 8 deletions

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center">
@@ -59,6 +81,14 @@ export function FocusView(): React.ReactElement {
<div className="flex min-h-screen flex-col">
{/* Main focus area */}
<div className="flex flex-1 flex-col items-center justify-center p-8">
{suggestion ? (
// Suggestion state: no focus set, but items exist
<SuggestionCard
item={suggestion}
onSetAsFocus={handleSetAsFocus}
/>
) : (
// Focus state or empty state (FocusCard handles empty internally)
<FocusCard
item={current}
onStart={handleStart}
@@ -66,10 +96,11 @@ export function FocusView(): React.ReactElement {
onDeferTomorrow={handleDeferTomorrow}
onSkip={handleSkip}
/>
)}
</div>
{/* Queue summary bar */}
<QueueSummary queue={queue} />
<QueueSummary queue={displayQueue} />
</div>
);
}

View File

@@ -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<FocusItemType, string> = {
mr_review: "MR REVIEW",
issue: "ISSUE",
mr_authored: "MR AUTHORED",
manual: "TASK",
};
const STALENESS_COLORS: Record<Staleness, string> = {
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 (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className="mx-auto w-full max-w-lg"
>
{/* Suggestion label */}
<p className="mb-4 text-center text-sm text-zinc-500">Suggested next</p>
{/* Type badge */}
<div className="mb-6 flex justify-center">
<span
className={`rounded-full border px-4 py-1.5 text-xs font-bold tracking-wider ${STALENESS_COLORS[staleness]}`}
>
{TYPE_LABELS[item.type]}
</span>
</div>
{/* Title */}
<h2 className="mb-3 text-center text-2xl font-bold tracking-tight text-zinc-100">
{item.title}
</h2>
{/* Metadata line */}
<p className="mb-6 text-center text-sm text-zinc-400">
{formatIid(item.type, item.iid)} in {item.project}
</p>
{/* Context quote */}
{(item.contextQuote || item.requestedBy) && (
<div className="mb-8 rounded-lg border border-zinc-700 bg-surface p-4">
{item.requestedBy && (
<p className="mb-1 text-xs font-medium text-zinc-400">
@{item.requestedBy}
</p>
)}
{item.contextQuote && (
<p className="text-sm italic text-zinc-300">
&ldquo;{item.contextQuote}&rdquo;
</p>
)}
</div>
)}
{/* Set as focus button */}
<div className="flex justify-center">
<button
type="button"
className="flex flex-col items-center gap-1 rounded-lg border border-mc-fresh/40 bg-mc-fresh/10 px-8 py-4 text-sm font-medium text-mc-fresh transition-colors hover:bg-mc-fresh/20"
onClick={onSetAsFocus}
>
<span>Set as focus</span>
<span className="text-[10px] text-zinc-500">Enter</span>
</button>
</div>
</motion.div>
);
}

View File

@@ -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(<FocusView />);
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(<FocusView />);
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(<FocusView />);
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(<FocusView />);
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(<FocusView />);
// 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(<FocusView />);
// 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(<FocusView />);
// 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(<FocusView />);
// 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(<FocusView />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("shows error state", () => {
useFocusStore.setState({ error: "Something went wrong" });
render(<FocusView />);
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(<FocusView />);
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(<FocusView />);
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(<FocusView />);
await user.click(screen.getByRole("button", { name: /skip/i }));
expect(mockAct).toHaveBeenCalledWith("skip");
});
});
});