feat: add Zustand stores for focus, navigation, capture, and batch

Implements the frontend state management layer using Zustand. Each store
is single-purpose and testable in isolation.

Focus Store (focus-store.ts):
- Tracks current focused item (THE ONE THING)
- Manages the queue of remaining items
- Actions: setItems, act (start/defer/skip), setFocus, reorderQueue
- Advancing through items removes from queue, promotes next to current

Navigation Store (nav-store.ts):
- Simple view routing: focus | queue | inbox
- No URL-based routing needed for native app
- Default view is "focus"

Capture Store (capture-store.ts):
- Manages quick capture overlay state
- Tracks submission status and errors
- Opens via global shortcut event listener

Batch Store (batch-store.ts):
- Manages batch processing mode for rapid item completion
- Tracks items, their statuses (pending/done/skipped), and current index
- Derives counts: completedCount, skippedCount, isFinished
- Used for "knock out all code reviews" workflow

State design principles:
- No derived state stored; computed on access
- Actions are pure mutations with logging
- Loading/error states colocated with data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 09:54:24 -05:00
parent e01e93f846
commit 259f751f45
4 changed files with 298 additions and 0 deletions

118
src/stores/batch-store.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* Batch Store - manages batch processing state.
*
* When active, presents items of the same type one at a time
* for rapid completion. Tracks progress and completion stats.
*/
import { create } from "zustand";
import type { FocusItem } from "@/lib/types";
export type BatchItemStatus = "pending" | "done" | "skipped";
export interface BatchState {
/** Whether batch mode is active */
isActive: boolean;
/** Label for the batch (e.g., "CODE REVIEWS") */
batchLabel: string;
/** Items in this batch */
items: FocusItem[];
/** Status of each item (indexed same as items) */
statuses: BatchItemStatus[];
/** Current item index */
currentIndex: number;
/** When the batch started (for duration tracking) */
startedAt: number | null;
// -- Derived --
/** Number of items completed (done) */
completedCount: () => number;
/** Number of items skipped */
skippedCount: () => number;
/** Whether all items have been processed */
isFinished: () => boolean;
// -- Actions --
/** Start a batch with the given items and label */
startBatch: (items: FocusItem[], label: string) => void;
/** Mark current item as done and advance */
markDone: () => void;
/** Mark current item as skipped and advance */
markSkipped: () => void;
/** Exit batch mode */
exitBatch: () => void;
}
export const useBatchStore = create<BatchState>((set, get) => ({
isActive: false,
batchLabel: "",
items: [],
statuses: [],
currentIndex: 0,
startedAt: null,
completedCount: () => get().statuses.filter((s) => s === "done").length,
skippedCount: () => get().statuses.filter((s) => s === "skipped").length,
isFinished: () => {
const { statuses } = get();
return statuses.length > 0 && statuses.every((s) => s !== "pending");
},
startBatch: (items, label) => {
set({
isActive: true,
batchLabel: label,
items,
statuses: items.map(() => "pending" as BatchItemStatus),
currentIndex: 0,
startedAt: Date.now(),
});
},
markDone: () => {
const { currentIndex, statuses, items } = get();
if (currentIndex >= items.length) return;
const updated = [...statuses];
updated[currentIndex] = "done";
const nextIndex = findNextPending(updated, currentIndex);
set({ statuses: updated, currentIndex: nextIndex });
},
markSkipped: () => {
const { currentIndex, statuses, items } = get();
if (currentIndex >= items.length) return;
const updated = [...statuses];
updated[currentIndex] = "skipped";
const nextIndex = findNextPending(updated, currentIndex);
set({ statuses: updated, currentIndex: nextIndex });
},
exitBatch: () => {
set({
isActive: false,
batchLabel: "",
items: [],
statuses: [],
currentIndex: 0,
startedAt: null,
});
},
}));
/** Find the next pending item after the given index, or return items.length if none. */
function findNextPending(
statuses: BatchItemStatus[],
afterIndex: number
): number {
for (let i = afterIndex + 1; i < statuses.length; i++) {
if (statuses[i] === "pending") return i;
}
// All remaining are processed -- return length to signal finished
return statuses.length;
}

View File

@@ -0,0 +1,55 @@
/**
* Quick Capture store.
*
* Manages the overlay state for the global Cmd+Shift+C capture flow.
*/
import { create } from "zustand";
interface CaptureState {
isOpen: boolean;
isSubmitting: boolean;
lastCapturedId: string | null;
error: string | null;
}
interface CaptureActions {
open: () => void;
close: () => void;
setSubmitting: (submitting: boolean) => void;
captureSuccess: (beadId: string) => void;
captureError: (message: string) => void;
}
export const useCaptureStore = create<CaptureState & CaptureActions>((set) => ({
isOpen: false,
isSubmitting: false,
lastCapturedId: null,
error: null,
open: () => set({ isOpen: true, error: null }),
close: () =>
set({
isOpen: false,
isSubmitting: false,
error: null,
}),
setSubmitting: (submitting) => set({ isSubmitting: submitting }),
captureSuccess: (beadId) =>
set({
lastCapturedId: beadId,
isSubmitting: false,
isOpen: false,
error: null,
}),
captureError: (message) =>
set({
error: message,
isSubmitting: false,
isOpen: true,
}),
}));

105
src/stores/focus-store.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* Focus Store - manages THE ONE THING state.
*
* Tracks the currently focused item, the queue of remaining items,
* and dispatches actions (start, defer, skip) that advance through the queue.
*/
import { create } from "zustand";
import type { FocusAction, FocusItem } from "@/lib/types";
export interface FocusState {
/** The current ONE THING (null when queue is empty) */
current: FocusItem | null;
/** Remaining items after the current one */
queue: FocusItem[];
/** Whether we're loading data from the backend */
isLoading: boolean;
/** Last error message */
error: string | null;
// -- Actions --
/** Set the full list of items (called after sync). First item becomes focus. */
setItems: (items: FocusItem[]) => void;
/** Perform an action on the current item and advance to next */
act: (action: FocusAction, reason?: string) => FocusItem | null;
/** Manually set a specific item as THE ONE THING */
setFocus: (itemId: string) => void;
/** Reorder queue by moving an item from one index to another */
reorderQueue: (fromIndex: number, toIndex: number) => void;
/** Set loading state */
setLoading: (loading: boolean) => void;
/** Set error state */
setError: (error: string | null) => void;
}
export const useFocusStore = create<FocusState>((set, get) => ({
current: null,
queue: [],
isLoading: false,
error: null,
setItems: (items) => {
const [first, ...rest] = items;
set({
current: first ?? null,
queue: rest,
isLoading: false,
error: null,
});
},
act: (action, _reason) => {
const { current, queue } = get();
const [next, ...rest] = queue;
// Log the decision (will be wired to backend decision log in Phase 7)
console.debug("[focus] act:", action, "on:", current?.id);
const nextItem = next ?? null;
set({
current: nextItem,
queue: rest,
});
return nextItem;
},
setFocus: (itemId) => {
const { current, queue } = get();
const allItems = current ? [current, ...queue] : [...queue];
const target = allItems.find((item) => item.id === itemId);
if (!target) return;
const remaining = allItems.filter((item) => item.id !== itemId);
set({
current: target,
queue: remaining,
});
},
reorderQueue: (fromIndex, toIndex) => {
const { queue } = get();
if (
fromIndex < 0 ||
fromIndex >= queue.length ||
toIndex < 0 ||
toIndex >= queue.length ||
fromIndex === toIndex
) {
return;
}
const updated = [...queue];
const [moved] = updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, moved);
console.debug("[focus] reorder:", moved.id, "from", fromIndex, "to", toIndex);
set({ queue: updated });
},
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
}));

20
src/stores/nav-store.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Navigation Store - tracks which view is active.
*
* Simple view routing for the desktop app. No URL-based routing needed
* since this is a native app with a fixed set of views.
*/
import { create } from "zustand";
export type ViewId = "focus" | "queue" | "inbox";
export interface NavState {
activeView: ViewId;
setView: (view: ViewId) => void;
}
export const useNavStore = create<NavState>((set) => ({
activeView: "focus",
setView: (view) => set({ activeView: view }),
}));