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:
@@ -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,17 +81,26 @@ 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">
|
||||
<FocusCard
|
||||
item={current}
|
||||
onStart={handleStart}
|
||||
onDefer1h={handleDefer1h}
|
||||
onDeferTomorrow={handleDeferTomorrow}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
{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}
|
||||
onDefer1h={handleDefer1h}
|
||||
onDeferTomorrow={handleDeferTomorrow}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Queue summary bar */}
|
||||
<QueueSummary queue={queue} />
|
||||
<QueueSummary queue={displayQueue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
98
src/components/SuggestionCard.tsx
Normal file
98
src/components/SuggestionCard.tsx
Normal 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">
|
||||
“{item.contextQuote}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
223
tests/components/FocusView.test.tsx
Normal file
223
tests/components/FocusView.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user