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.
|
* Connects to the Zustand store and Tauri backend.
|
||||||
* Handles "Start" by opening the URL in the browser via Tauri shell.
|
* 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 { useCallback } from "react";
|
||||||
import { FocusCard } from "./FocusCard";
|
import { FocusCard } from "./FocusCard";
|
||||||
|
import { SuggestionCard } from "./SuggestionCard";
|
||||||
import { QueueSummary } from "./QueueSummary";
|
import { QueueSummary } from "./QueueSummary";
|
||||||
import { useFocusStore } from "@/stores/focus-store";
|
import { useFocusStore } from "@/stores/focus-store";
|
||||||
import { open } from "@tauri-apps/plugin-shell";
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
@@ -17,6 +23,15 @@ export function FocusView(): React.ReactElement {
|
|||||||
const isLoading = useFocusStore((s) => s.isLoading);
|
const isLoading = useFocusStore((s) => s.isLoading);
|
||||||
const error = useFocusStore((s) => s.error);
|
const error = useFocusStore((s) => s.error);
|
||||||
const act = useFocusStore((s) => s.act);
|
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(() => {
|
const handleStart = useCallback(() => {
|
||||||
if (current?.url) {
|
if (current?.url) {
|
||||||
@@ -39,6 +54,13 @@ export function FocusView(): React.ReactElement {
|
|||||||
act("skip");
|
act("skip");
|
||||||
}, [act]);
|
}, [act]);
|
||||||
|
|
||||||
|
// Handle setting suggestion as focus
|
||||||
|
const handleSetAsFocus = useCallback(() => {
|
||||||
|
if (suggestion) {
|
||||||
|
setFocus(suggestion.id);
|
||||||
|
}
|
||||||
|
}, [suggestion, setFocus]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<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">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Main focus area */}
|
{/* Main focus area */}
|
||||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
<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
|
<FocusCard
|
||||||
item={current}
|
item={current}
|
||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
@@ -66,10 +96,11 @@ export function FocusView(): React.ReactElement {
|
|||||||
onDeferTomorrow={handleDeferTomorrow}
|
onDeferTomorrow={handleDeferTomorrow}
|
||||||
onSkip={handleSkip}
|
onSkip={handleSkip}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Queue summary bar */}
|
{/* Queue summary bar */}
|
||||||
<QueueSummary queue={queue} />
|
<QueueSummary queue={displayQueue} />
|
||||||
</div>
|
</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