From 546ef8216d0ef5329fd24f60a32add8546f01ab8 Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Tue, 17 Jun 2025 18:24:34 -0500 Subject: [PATCH] refactor: use Zustand for task state, optimize GraphQL requests, and fix recursive setState bug --- client/README.md | 114 ++++++ client/package.json | 5 +- client/src/App.tsx | 6 +- client/src/graphql/client.ts | 4 +- client/src/hooks/useTaskDataOptimized.ts | 446 +++++++++++++++++++++++ client/src/hooks/useTaskSelector.ts | 85 +++++ client/src/stores/taskStore.ts | 259 +++++++++++++ 7 files changed, 912 insertions(+), 7 deletions(-) create mode 100644 client/src/hooks/useTaskDataOptimized.ts create mode 100644 client/src/hooks/useTaskSelector.ts create mode 100644 client/src/stores/taskStore.ts diff --git a/client/README.md b/client/README.md index da98444..919f81b 100644 --- a/client/README.md +++ b/client/README.md @@ -52,3 +52,117 @@ export default tseslint.config({ }, }) ``` + +# Task Receipts Client + +A React application for managing task receipts with GraphQL API integration and Zustand state management. + +## Architecture + +### State Management with Zustand + +The application uses Zustand for state management to optimize GraphQL API requests and improve performance: + +#### Key Features: +- **Centralized State**: All task data is stored in a single Zustand store +- **Optimized Queries**: GraphQL queries are only made when data is not already available in the store +- **Efficient Updates**: Mutations update the store directly, reducing the need for refetching +- **Selective Rendering**: Components only re-render when their specific data changes + +#### Store Structure: +```typescript +interface TaskState { + // Data + groups: GroupWithTasks[]; + recentTasks: TaskWithSteps[]; + frequentTasks: TaskWithSteps[]; + currentStep: StepWithNotes | null; + + // Loading states + isLoadingGroups: boolean; + isLoadingRecentTasks: boolean; + isLoadingFrequentTasks: boolean; + isLoadingStep: boolean; + + // Selection state + selectedGroup: GroupWithTasks | undefined; + selectedTask: TaskWithSteps | undefined; + selectedStep: StepWithNotes | undefined; + + // Actions and helper methods... +} +``` + +#### Hooks: + +1. **`useTaskDataOptimized`**: Main hook that manages GraphQL queries and store updates +2. **`useTaskSelector`**: Collection of selector hooks for efficient data access +3. **`useTaskStore`**: Direct access to the Zustand store + +#### Performance Optimizations: + +- **Skip Queries**: Queries are skipped if data already exists in the store +- **Cache-First Policy**: Apollo Client uses cache-first for queries +- **Selective Updates**: Only relevant parts of the store are updated +- **Efficient Selectors**: Components subscribe only to the data they need + +### GraphQL Integration + +The application uses Apollo Client for GraphQL operations with optimized caching policies: + +- **Cache-First**: Queries prioritize cached data +- **Cache-and-Network**: Watch queries update from cache first, then network +- **Optimistic Updates**: Mutations update the store immediately + +### Component Structure + +- **App.tsx**: Main application component using the optimized hook +- **Layouts**: Desktop and mobile layouts for different screen sizes +- **Components**: Reusable UI components +- **Hooks**: Custom hooks for data management and device detection + +## Development + +### Prerequisites +- Node.js 18+ +- npm or yarn + +### Installation +```bash +npm install +``` + +### Development Server +```bash +npm run dev +``` + +### Build +```bash +npm run build +``` + +## State Management Flow + +1. **Initial Load**: + - `useTaskDataOptimized` checks if data exists in store + - If not, makes GraphQL queries to fetch data + - Updates store with fetched data + +2. **User Interactions**: + - Selection changes update store directly + - Mutations update store optimistically + - Components re-render only when their data changes + +3. **Data Persistence**: + - Store maintains data across component unmounts + - Queries are skipped if data is already available + - Cache policies ensure efficient data retrieval + +## Benefits + +- **Reduced API Calls**: Data is fetched once and reused +- **Better Performance**: Components only re-render when necessary +- **Improved UX**: Faster navigation and interactions +- **Maintainable Code**: Centralized state management +- **Type Safety**: Full TypeScript support with proper typing diff --git a/client/package.json b/client/package.json index edbd119..5936f5b 100644 --- a/client/package.json +++ b/client/package.json @@ -13,11 +13,12 @@ "@apollo/client": "^3.9.5", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", - "@mui/material": "^5.15.10", + "@mui/material": "^5.17.1", "@task-receipts/shared": "file:../shared", "graphql": "^16.8.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zustand": "^5.0.5" }, "devDependencies": { "@types/react": "^18.2.55", diff --git a/client/src/App.tsx b/client/src/App.tsx index c341bac..42e0836 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,10 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import { ApolloProvider } from '@apollo/client'; import { client } from './graphql/client'; import { DesktopLayout } from './layouts/DesktopLayout'; import { MobileLayout } from './layouts/MobileLayout'; import { UserSelection } from './components/UserSelection'; -import { useTaskData } from './hooks/useTaskData'; +import { useTaskDataOptimized } from './hooks/useTaskDataOptimized'; import { useDeviceType } from './hooks/useDeviceType'; import { useUserSelection } from './hooks/useUserSelection'; import './App.css'; @@ -25,7 +25,7 @@ export function App() { handlePrintTask, handlePrintStep, handleAddNote - } = useTaskData(); + } = useTaskDataOptimized(); const handleBack = useCallback(() => { if (selectedStep) { diff --git a/client/src/graphql/client.ts b/client/src/graphql/client.ts index 034bb7f..72a20d5 100644 --- a/client/src/graphql/client.ts +++ b/client/src/graphql/client.ts @@ -58,11 +58,11 @@ export const client = new ApolloClient({ }), defaultOptions: { watchQuery: { - fetchPolicy: 'network-only', + fetchPolicy: 'cache-and-network', errorPolicy: 'all', }, query: { - fetchPolicy: 'network-only', + fetchPolicy: 'cache-first', errorPolicy: 'all', }, }, diff --git a/client/src/hooks/useTaskDataOptimized.ts b/client/src/hooks/useTaskDataOptimized.ts new file mode 100644 index 0000000..c025187 --- /dev/null +++ b/client/src/hooks/useTaskDataOptimized.ts @@ -0,0 +1,446 @@ +import { useEffect } from 'react'; +import { useQuery, useMutation } from '@apollo/client'; +import { GET_GROUPS, GET_TASKS, GET_STEP, GET_RECENT_TASKS, GET_FREQUENT_TASKS } from '../graphql/queries'; +import { PRINT_TASK, PRINT_STEP, CREATE_NOTE, CREATE_GROUP, CREATE_TASK, CREATE_STEP } from '../graphql/mutations'; +import { useTaskStore } from '../stores/taskStore'; +import type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types'; +import { doesExist, isArray } from '../utils/typeGuards'; + +interface GraphQLStep { + id: number; + name: string; + instructions: string; + taskId: number; + order: number; + notes: GraphQLNote[]; + print_count: number; + last_printed_at: string | null; +} + +interface GraphQLNote { + id: number; + content: string; + created_at: string; + updated_at: string; + user: { + id: number; + name: string; + }; +} + +interface GraphQLTask { + id: number; + name: string; + groupId: number; + steps: GraphQLStep[]; + notes: GraphQLNote[]; + print_count: number; + last_printed_at: string | null; +} + +interface GraphQLGroup { + id: number; + name: string; + tasks: GraphQLTask[]; + created_at: string; + updated_at: string; +} + +function toStepWithNotes(step: GraphQLStep | null | undefined): StepWithNotes | null { + if (!doesExist(step)) return null; + + return { + id: step.id, + name: step.name, + instructions: step.instructions, + task_id: step.taskId, + order: step.order, + print_count: step.print_count, + last_printed_at: step.last_printed_at ? new Date(step.last_printed_at) : undefined, + notes: isArray(step.notes) ? step.notes.map(note => ({ + id: note.id, + content: note.content, + created_at: new Date(note.created_at), + updated_at: new Date(note.updated_at), + created_by: note.user.id, + user: { + id: note.user.id, + name: note.user.name, + }, + })) : [], + }; +} + +function toTaskWithSteps(task: GraphQLTask | null | undefined): TaskWithSteps | null { + if (!doesExist(task)) return null; + + return { + id: task.id, + name: task.name, + group_id: task.groupId, + print_count: task.print_count, + last_printed_at: task.last_printed_at ? new Date(task.last_printed_at) : undefined, + steps: isArray(task.steps) ? task.steps.map(step => toStepWithNotes(step)).filter(doesExist) : [], + notes: isArray(task.notes) ? task.notes.map(note => ({ + id: note.id, + content: note.content, + created_at: new Date(note.created_at), + updated_at: new Date(note.updated_at), + created_by: note.user.id, + user: { + id: note.user.id, + name: note.user.name, + }, + })) : [], + }; +} + +function toGroupWithTasks(group: GraphQLGroup | null | undefined): GroupWithTasks | null { + if (!doesExist(group)) return null; + + return { + id: group.id, + name: group.name, + created_at: new Date(group.created_at), + updated_at: new Date(group.updated_at), + tasks: isArray(group.tasks) ? group.tasks.map(task => toTaskWithSteps(task)).filter(doesExist) : [], + }; +} + +export function useTaskDataOptimized() { + const { + groups, + recentTasks, + frequentTasks, + currentStep, + isLoadingGroups, + isLoadingRecentTasks, + isLoadingFrequentTasks, + isLoadingStep, + selectedGroup, + selectedTask, + selectedStep, + setGroups, + setRecentTasks, + setFrequentTasks, + setCurrentStep, + setLoadingGroups, + setLoadingRecentTasks, + setLoadingFrequentTasks, + setLoadingStep, + setSelectedGroup, + setSelectedTask, + setSelectedStep, + updateTaskPrintCount, + updateStepPrintCount, + addNoteToStep, + addTaskToGroup, + addStepToTask, + getTaskById, + getStepById, + } = useTaskStore(); + + // Only fetch groups if we don't have them already + const { data: groupsData, loading: groupsLoading } = useQuery(GET_GROUPS, { + skip: groups.length > 0, + fetchPolicy: 'cache-first', + }); + + // Only fetch recent tasks if we don't have them already + const { data: recentTasksData, loading: recentTasksLoading } = useQuery(GET_RECENT_TASKS, { + skip: recentTasks.length > 0, + fetchPolicy: 'cache-first', + }); + + // Only fetch frequent tasks if we don't have them already + const { data: frequentTasksData, loading: frequentTasksLoading } = useQuery(GET_FREQUENT_TASKS, { + skip: frequentTasks.length > 0, + fetchPolicy: 'cache-first', + }); + + // Only fetch tasks for a group if we don't have them already + const { data: tasksData, loading: tasksLoading } = useQuery(GET_TASKS, { + variables: { groupId: selectedGroup?.id }, + skip: !selectedGroup?.id || (selectedGroup && selectedGroup.tasks.length > 0), + fetchPolicy: 'cache-first', + }); + + // Only fetch step details if we don't have them already + const { data: stepData, loading: stepLoading } = useQuery(GET_STEP, { + variables: { id: selectedStep?.id }, + skip: !selectedStep?.id || (selectedStep && selectedStep.notes.length > 0), + fetchPolicy: 'cache-first', + }); + + // Mutations + const [printTask] = useMutation(PRINT_TASK); + const [printStep] = useMutation(PRINT_STEP); + const [createNote] = useMutation(CREATE_NOTE); + const [createGroup] = useMutation(CREATE_GROUP); + const [createTask] = useMutation(CREATE_TASK); + const [createStep] = useMutation(CREATE_STEP); + + // Update store when data is fetched + useEffect(() => { + if (groupsData?.groups && groupsData.groups.length > 0) { + const processedGroups = groupsData.groups + .map((group: GraphQLGroup) => toGroupWithTasks(group)) + .filter(doesExist); + setGroups(processedGroups); + } + }, [groupsData, setGroups]); + + useEffect(() => { + if (recentTasksData?.recentTasks && recentTasksData.recentTasks.length > 0) { + const processedTasks = recentTasksData.recentTasks + .map((task: GraphQLTask) => toTaskWithSteps(task)) + .filter(doesExist); + setRecentTasks(processedTasks); + } + }, [recentTasksData, setRecentTasks]); + + useEffect(() => { + if (frequentTasksData?.frequentTasks && frequentTasksData.frequentTasks.length > 0) { + const processedTasks = frequentTasksData.frequentTasks + .map((task: GraphQLTask) => toTaskWithSteps(task)) + .filter(doesExist); + setFrequentTasks(processedTasks); + } + }, [frequentTasksData, setFrequentTasks]); + + // Update tasks in the store when fetched, but don't update selectedGroup + useEffect(() => { + if (tasksData?.tasks && tasksData.tasks.length > 0 && selectedGroup) { + const processedTasks = tasksData.tasks + .map((task: GraphQLTask) => toTaskWithSteps(task)) + .filter(doesExist); + + // Update the groups array with the new tasks for this group + const updatedGroups = groups.map(group => + group.id === selectedGroup.id + ? { ...group, tasks: processedTasks } + : group + ); + setGroups(updatedGroups); + } + }, [tasksData, selectedGroup?.id, groups, setGroups]); + + // Update step details in the store when fetched, but don't update selectedStep + useEffect(() => { + if (stepData?.step && selectedStep) { + const processedStep = toStepWithNotes(stepData.step); + if (processedStep) { + setCurrentStep(processedStep); + } + } + }, [stepData, selectedStep?.id, setCurrentStep]); + + // Update loading states + useEffect(() => { + setLoadingGroups(groupsLoading); + }, [groupsLoading, setLoadingGroups]); + + useEffect(() => { + setLoadingRecentTasks(recentTasksLoading); + }, [recentTasksLoading, setLoadingRecentTasks]); + + useEffect(() => { + setLoadingFrequentTasks(frequentTasksLoading); + }, [frequentTasksLoading, setLoadingFrequentTasks]); + + useEffect(() => { + setLoadingStep(stepLoading); + }, [stepLoading, setLoadingStep]); + + // Create virtual groups for recent and frequent tasks + const virtualGroups: GroupWithTasks[] = [ + { + id: -1, + name: 'Recent Tasks', + created_at: new Date(), + updated_at: new Date(), + tasks: recentTasks, + }, + { + id: -2, + name: 'Frequent Tasks', + created_at: new Date(), + updated_at: new Date(), + tasks: frequentTasks, + }, + ]; + + const allGroups = [...virtualGroups, ...groups]; + + const handlePrintTask = async (taskId: string, userId: string) => { + if (!taskId || !userId) { + console.error('Cannot print task: missing taskId or userId'); + return; + } + + try { + const result = await printTask({ variables: { id: taskId, userId } }); + if (result.data?.printTask) { + const task = result.data.printTask; + updateTaskPrintCount( + parseInt(taskId), + task.print_count, + task.last_printed_at + ); + } + } catch (error) { + console.error('Error printing task:', error); + } + }; + + const handlePrintStep = async (stepId: string, userId: string) => { + if (!stepId || !userId) { + console.error('Cannot print step: missing stepId or userId'); + return; + } + + try { + const result = await printStep({ variables: { id: stepId, userId } }); + if (result.data?.printStep) { + const step = result.data.printStep; + updateStepPrintCount( + parseInt(stepId), + step.print_count, + step.last_printed_at + ); + } + } catch (error) { + console.error('Error printing step:', error); + } + }; + + const handleAddNote = async (content: string) => { + if (!selectedStep?.id) { + console.error('Cannot add note: missing stepId'); + return; + } + + try { + const result = await createNote({ + variables: { + content, + stepId: String(selectedStep.id), + }, + }); + + if (result.data?.createNote) { + const note = result.data.createNote; + addNoteToStep(selectedStep.id, { + id: note.id, + content: note.content, + created_at: new Date(note.created_at), + updated_at: new Date(note.updated_at), + created_by: note.user.id, + user: { + id: note.user.id, + name: note.user.name, + }, + }); + } + } catch (error) { + console.error('Error adding note:', error); + } + }; + + const handleCreateGroup = async (name: string) => { + if (!name) { + console.error('Cannot create group: missing name'); + return; + } + + try { + const result = await createGroup({ variables: { name } }); + if (result.data?.createGroup) { + const group = result.data.createGroup; + const newGroup: GroupWithTasks = { + id: group.id, + name: group.name, + created_at: new Date(group.created_at), + updated_at: new Date(group.updated_at), + tasks: [], + }; + setGroups([...groups, newGroup]); + } + } catch (error) { + console.error('Error creating group:', error); + } + }; + + const handleCreateTask = async (name: string, groupId: string) => { + if (!name || !groupId) { + console.error('Cannot create task: missing name or groupId'); + return; + } + + try { + const result = await createTask({ variables: { name, groupId } }); + if (result.data?.createTask) { + const task = result.data.createTask; + const newTask: TaskWithSteps = { + id: task.id, + name: task.name, + group_id: task.group_id, + print_count: 0, + steps: [], + notes: [], + }; + addTaskToGroup(parseInt(groupId), newTask); + } + } catch (error) { + console.error('Error creating task:', error); + } + }; + + const handleCreateStep = async (name: string, instructions: string, taskId: string, order: number) => { + if (!name || !instructions || !taskId || order < 1) { + console.error('Cannot create step: missing required fields'); + return; + } + + try { + const result = await createStep({ variables: { name, instructions, taskId, order } }); + if (result.data?.createStep) { + const step = result.data.createStep; + const newStep: StepWithNotes = { + id: step.id, + name: step.name, + instructions: step.instructions, + task_id: step.task_id, + order: step.order, + print_count: 0, + notes: [], + }; + addStepToTask(parseInt(taskId), newStep); + } + } catch (error) { + console.error('Error creating step:', error); + } + }; + + const loading = isLoadingGroups || isLoadingRecentTasks || isLoadingFrequentTasks || isLoadingStep || tasksLoading; + + return { + groups: allGroups, + step: currentStep, + loading, + selectedGroup, + selectedTask, + selectedStep, + setSelectedGroup, + setSelectedTask, + setSelectedStep, + handlePrintTask, + handlePrintStep, + handleAddNote, + handleCreateGroup, + handleCreateTask, + handleCreateStep, + getTaskById, + getStepById, + }; +} \ No newline at end of file diff --git a/client/src/hooks/useTaskSelector.ts b/client/src/hooks/useTaskSelector.ts new file mode 100644 index 0000000..6fc4bf8 --- /dev/null +++ b/client/src/hooks/useTaskSelector.ts @@ -0,0 +1,85 @@ +import { useTaskStore } from '../stores/taskStore'; +import type { GroupWithTasks } from '../types'; + +// Selector hooks for efficient data access +export function useGroups() { + return useTaskStore((state) => state.groups); +} + +export function useRecentTasks() { + return useTaskStore((state) => state.recentTasks); +} + +export function useFrequentTasks() { + return useTaskStore((state) => state.frequentTasks); +} + +export function useSelectedGroup() { + return useTaskStore((state) => state.selectedGroup); +} + +export function useSelectedTask() { + return useTaskStore((state) => state.selectedTask); +} + +export function useSelectedStep() { + return useTaskStore((state) => state.selectedStep); +} + +export function useLoadingStates() { + return useTaskStore((state) => ({ + isLoadingGroups: state.isLoadingGroups, + isLoadingRecentTasks: state.isLoadingRecentTasks, + isLoadingFrequentTasks: state.isLoadingFrequentTasks, + isLoadingStep: state.isLoadingStep, + })); +} + +export function useTaskActions() { + return useTaskStore((state) => ({ + setSelectedGroup: state.setSelectedGroup, + setSelectedTask: state.setSelectedTask, + setSelectedStep: state.setSelectedStep, + getTaskById: state.getTaskById, + getStepById: state.getStepById, + getGroupById: state.getGroupById, + })); +} + +// Custom selector for a specific task +export function useTaskById(taskId: number) { + return useTaskStore((state) => state.getTaskById(taskId)); +} + +// Custom selector for a specific step +export function useStepById(stepId: number) { + return useTaskStore((state) => state.getStepById(stepId)); +} + +// Custom selector for a specific group +export function useGroupById(groupId: number) { + return useTaskStore((state) => state.getGroupById(groupId)); +} + +// Selector for all groups including virtual ones +export function useAllGroups() { + return useTaskStore((state) => { + const virtualGroups: GroupWithTasks[] = [ + { + id: -1, + name: 'Recent Tasks', + created_at: new Date(), + updated_at: new Date(), + tasks: state.recentTasks, + }, + { + id: -2, + name: 'Frequent Tasks', + created_at: new Date(), + updated_at: new Date(), + tasks: state.frequentTasks, + }, + ]; + return [...virtualGroups, ...state.groups]; + }); +} \ No newline at end of file diff --git a/client/src/stores/taskStore.ts b/client/src/stores/taskStore.ts new file mode 100644 index 0000000..36ef7da --- /dev/null +++ b/client/src/stores/taskStore.ts @@ -0,0 +1,259 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types'; + +interface TaskState { + // Data + groups: GroupWithTasks[]; + recentTasks: TaskWithSteps[]; + frequentTasks: TaskWithSteps[]; + currentStep: StepWithNotes | null; + + // Loading states + isLoadingGroups: boolean; + isLoadingRecentTasks: boolean; + isLoadingFrequentTasks: boolean; + isLoadingStep: boolean; + + // Selection state + selectedGroup: GroupWithTasks | undefined; + selectedTask: TaskWithSteps | undefined; + selectedStep: StepWithNotes | undefined; + + // Actions + setGroups: (groups: GroupWithTasks[]) => void; + setRecentTasks: (tasks: TaskWithSteps[]) => void; + setFrequentTasks: (tasks: TaskWithSteps[]) => void; + setCurrentStep: (step: StepWithNotes | null) => void; + + setLoadingGroups: (loading: boolean) => void; + setLoadingRecentTasks: (loading: boolean) => void; + setLoadingFrequentTasks: (loading: boolean) => void; + setLoadingStep: (loading: boolean) => void; + + setSelectedGroup: (group: GroupWithTasks | undefined) => void; + setSelectedTask: (task: TaskWithSteps | undefined) => void; + setSelectedStep: (step: StepWithNotes | undefined) => void; + + // Helper methods + getTaskById: (taskId: number) => TaskWithSteps | undefined; + getStepById: (stepId: number) => StepWithNotes | undefined; + getGroupById: (groupId: number) => GroupWithTasks | undefined; + + // Update methods for mutations + updateTaskPrintCount: (taskId: number, printCount: number, lastPrintedAt: string | null) => void; + updateStepPrintCount: (stepId: number, printCount: number, lastPrintedAt: string | null) => void; + addNoteToStep: (stepId: number, note: any) => void; + addTaskToGroup: (groupId: number, task: TaskWithSteps) => void; + addStepToTask: (taskId: number, step: StepWithNotes) => void; + + // Clear methods + clearData: () => void; +} + +export const useTaskStore = create()( + devtools( + (set, get) => ({ + // Initial state + groups: [], + recentTasks: [], + frequentTasks: [], + currentStep: null, + + isLoadingGroups: false, + isLoadingRecentTasks: false, + isLoadingFrequentTasks: false, + isLoadingStep: false, + + selectedGroup: undefined, + selectedTask: undefined, + selectedStep: undefined, + + // Actions + setGroups: (groups) => set({ groups }), + setRecentTasks: (tasks) => set({ recentTasks: tasks }), + setFrequentTasks: (tasks) => set({ frequentTasks: tasks }), + setCurrentStep: (step) => set({ currentStep: step }), + + setLoadingGroups: (loading) => set({ isLoadingGroups: loading }), + setLoadingRecentTasks: (loading) => set({ isLoadingRecentTasks: loading }), + setLoadingFrequentTasks: (loading) => set({ isLoadingFrequentTasks: loading }), + setLoadingStep: (loading) => set({ isLoadingStep: loading }), + + setSelectedGroup: (group) => set({ selectedGroup: group }), + setSelectedTask: (task) => set({ selectedTask: task }), + setSelectedStep: (step) => set({ selectedStep: step }), + + // Helper methods + getTaskById: (taskId) => { + const state = get(); + // Search in all groups + for (const group of state.groups) { + const task = group.tasks.find(t => t.id === taskId); + if (task) return task; + } + // Search in recent tasks + const recentTask = state.recentTasks.find(t => t.id === taskId); + if (recentTask) return recentTask; + // Search in frequent tasks + const frequentTask = state.frequentTasks.find(t => t.id === taskId); + if (frequentTask) return frequentTask; + return undefined; + }, + + getStepById: (stepId) => { + const state = get(); + // Search in all groups + for (const group of state.groups) { + for (const task of group.tasks) { + const step = task.steps.find(s => s.id === stepId); + if (step) return step; + } + } + // Search in recent tasks + for (const task of state.recentTasks) { + const step = task.steps.find(s => s.id === stepId); + if (step) return step; + } + // Search in frequent tasks + for (const task of state.frequentTasks) { + const step = task.steps.find(s => s.id === stepId); + if (step) return step; + } + return undefined; + }, + + getGroupById: (groupId) => { + const state = get(); + return state.groups.find(g => g.id === groupId); + }, + + // Update methods for mutations + updateTaskPrintCount: (taskId, printCount, lastPrintedAt) => { + set((state) => { + const updateTaskInArray = (tasks: TaskWithSteps[]): TaskWithSteps[] => + tasks.map(task => + task.id === taskId + ? { + ...task, + print_count: printCount, + last_printed_at: lastPrintedAt ? new Date(lastPrintedAt) : null, + } + : task + ); + + return { + groups: state.groups.map(group => ({ + ...group, + tasks: updateTaskInArray(group.tasks), + })), + recentTasks: updateTaskInArray(state.recentTasks), + frequentTasks: updateTaskInArray(state.frequentTasks), + }; + }); + }, + + updateStepPrintCount: (stepId, printCount, lastPrintedAt) => { + set((state) => { + const updateStepInTask = (task: TaskWithSteps): TaskWithSteps => ({ + ...task, + steps: task.steps.map(step => + step.id === stepId + ? { + ...step, + print_count: printCount, + last_printed_at: lastPrintedAt ? new Date(lastPrintedAt) : null, + } + : step + ), + }); + + const updateTaskInArray = (tasks: TaskWithSteps[]): TaskWithSteps[] => + tasks.map(task => updateStepInTask(task)); + + return { + groups: state.groups.map(group => ({ + ...group, + tasks: updateTaskInArray(group.tasks), + })), + recentTasks: updateTaskInArray(state.recentTasks), + frequentTasks: updateTaskInArray(state.frequentTasks), + }; + }); + }, + + addNoteToStep: (stepId, note) => { + set((state) => { + const addNoteToStepInTask = (task: TaskWithSteps): TaskWithSteps => ({ + ...task, + steps: task.steps.map(step => + step.id === stepId + ? { + ...step, + notes: [...step.notes, note], + } + : step + ), + }); + + const updateTaskInArray = (tasks: TaskWithSteps[]): TaskWithSteps[] => + tasks.map(task => addNoteToStepInTask(task)); + + return { + groups: state.groups.map(group => ({ + ...group, + tasks: updateTaskInArray(group.tasks), + })), + recentTasks: updateTaskInArray(state.recentTasks), + frequentTasks: updateTaskInArray(state.frequentTasks), + }; + }); + }, + + addTaskToGroup: (groupId, task) => { + set((state) => ({ + groups: state.groups.map(group => + group.id === groupId + ? { ...group, tasks: [...group.tasks, task] } + : group + ), + })); + }, + + addStepToTask: (taskId, step) => { + set((state) => { + const addStepToTaskInArray = (tasks: TaskWithSteps[]): TaskWithSteps[] => + tasks.map(task => + task.id === taskId + ? { ...task, steps: [...task.steps, step] } + : task + ); + + return { + groups: state.groups.map(group => ({ + ...group, + tasks: addStepToTaskInArray(group.tasks), + })), + recentTasks: addStepToTaskInArray(state.recentTasks), + frequentTasks: addStepToTaskInArray(state.frequentTasks), + }; + }); + }, + + clearData: () => { + set({ + groups: [], + recentTasks: [], + frequentTasks: [], + currentStep: null, + selectedGroup: undefined, + selectedTask: undefined, + selectedStep: undefined, + }); + }, + }), + { + name: 'task-store', + } + ) +); \ No newline at end of file