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:
118
src/stores/batch-store.ts
Normal file
118
src/stores/batch-store.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/stores/capture-store.ts
Normal file
55
src/stores/capture-store.ts
Normal 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
105
src/stores/focus-store.ts
Normal 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
20
src/stores/nav-store.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user