/** * TASKMATE - State Store * ====================== * Centralized state management */ import { deepClone, deepMerge } from './utils.js'; class Store { constructor() { this.state = { // App state currentView: 'board', isOnline: navigator.onLine, isLoading: false, syncStatus: 'synced', // 'synced', 'syncing', 'offline', 'error' // User state currentUser: null, users: [], // Project state projects: [], currentProjectId: null, // Board state columns: [], tasks: [], labels: [], reminders: [], // Filters filters: { search: '', priority: 'all', assignee: 'all', label: 'all', dueDate: 'all', archived: false }, // Server search result IDs (bypass client filter for these) searchResultIds: [], // Selection selectedTaskIds: [], // Modal state openModals: [], editingTask: null, // UI state dragState: null, contextMenu: null, // Undo stack undoStack: [], redoStack: [], maxUndoHistory: 50 }; this.subscribers = new Map(); this.middlewares = []; } // Get current state getState() { return this.state; } // Get specific state path get(path) { return path.split('.').reduce((obj, key) => obj?.[key], this.state); } // Update state setState(updates, actionType = 'SET_STATE') { const prevState = deepClone(this.state); // Apply middlewares let processedUpdates = updates; for (const middleware of this.middlewares) { processedUpdates = middleware(prevState, processedUpdates, actionType); } // Merge updates this.state = deepMerge(this.state, processedUpdates); // Notify subscribers this.notifySubscribers(prevState, this.state, actionType); return this.state; } // Set specific state path set(path, value, actionType) { const keys = path.split('.'); const updates = keys.reduceRight((acc, key) => ({ [key]: acc }), value); return this.setState(updates, actionType || `SET_${path.toUpperCase()}`); } // Subscribe to state changes subscribe(selector, callback, immediate = false) { const id = Symbol(); this.subscribers.set(id, { selector, callback }); if (immediate) { const currentValue = typeof selector === 'function' ? selector(this.state) : this.get(selector); callback(currentValue, currentValue); } // Return unsubscribe function return () => this.subscribers.delete(id); } // Notify subscribers of changes notifySubscribers(prevState, newState, actionType) { this.subscribers.forEach(({ selector, callback }) => { const prevValue = typeof selector === 'function' ? selector(prevState) : selector.split('.').reduce((obj, key) => obj?.[key], prevState); const newValue = typeof selector === 'function' ? selector(newState) : selector.split('.').reduce((obj, key) => obj?.[key], newState); // Only call if value changed if (JSON.stringify(prevValue) !== JSON.stringify(newValue)) { callback(newValue, prevValue, actionType); } }); } // Add middleware use(middleware) { this.middlewares.push(middleware); } // Reset state reset() { this.state = { currentView: 'board', isOnline: navigator.onLine, isLoading: false, syncStatus: 'synced', currentUser: null, users: [], projects: [], currentProjectId: null, columns: [], tasks: [], labels: [], filters: { search: '', priority: 'all', assignee: 'all', label: 'all', dueDate: 'all', archived: false }, searchResultIds: [], selectedTaskIds: [], openModals: [], editingTask: null, dragState: null, contextMenu: null, undoStack: [], redoStack: [], maxUndoHistory: 50 }; } // ===================== // PROJECT ACTIONS // ===================== setProjects(projects) { this.setState({ projects }, 'SET_PROJECTS'); } addProject(project) { this.setState({ projects: [...this.state.projects, project] }, 'ADD_PROJECT'); } updateProject(projectId, updates) { this.setState({ projects: this.state.projects.map(p => p.id === projectId ? { ...p, ...updates } : p ) }, 'UPDATE_PROJECT'); } removeProject(projectId) { this.setState({ projects: this.state.projects.filter(p => p.id !== projectId) }, 'REMOVE_PROJECT'); } setCurrentProject(projectId) { this.setState({ currentProjectId: projectId }, 'SET_CURRENT_PROJECT'); localStorage.setItem('current_project_id', projectId); } getCurrentProject() { return this.state.projects.find(p => p.id === this.state.currentProjectId); } // ===================== // COLUMN ACTIONS // ===================== setColumns(columns) { this.setState({ columns }, 'SET_COLUMNS'); } addColumn(column) { this.setState({ columns: [...this.state.columns, column] }, 'ADD_COLUMN'); } updateColumn(columnId, updates) { this.setState({ columns: this.state.columns.map(c => c.id === columnId ? { ...c, ...updates } : c ) }, 'UPDATE_COLUMN'); } removeColumn(columnId) { this.setState({ columns: this.state.columns.filter(c => c.id !== columnId), tasks: this.state.tasks.filter(t => t.columnId !== columnId) }, 'REMOVE_COLUMN'); } reorderColumns(columnIds) { const columnsMap = new Map(this.state.columns.map(c => [c.id, c])); const reordered = columnIds.map((id, index) => ({ ...columnsMap.get(id), position: index })); this.setState({ columns: reordered }, 'REORDER_COLUMNS'); } // ===================== // TASK ACTIONS // ===================== setTasks(tasks) { this.setState({ tasks }, 'SET_TASKS'); } addTask(task) { this.setState({ tasks: [...this.state.tasks, task] }, 'ADD_TASK'); } updateTask(taskId, updates) { this.setState({ tasks: this.state.tasks.map(t => t.id === taskId ? { ...t, ...updates } : t ) }, 'UPDATE_TASK'); } removeTask(taskId) { this.setState({ tasks: this.state.tasks.filter(t => t.id !== taskId), selectedTaskIds: this.state.selectedTaskIds.filter(id => id !== taskId) }, 'REMOVE_TASK'); } moveTask(taskId, columnId, position) { const tasks = [...this.state.tasks]; const taskIndex = tasks.findIndex(t => t.id === taskId); if (taskIndex === -1) return; const task = { ...tasks[taskIndex], columnId: columnId, position }; tasks.splice(taskIndex, 1); // Find insert position const columnTasks = tasks.filter(t => t.columnId === columnId); const insertIndex = tasks.findIndex(t => t.columnId === columnId && t.position >= position); if (insertIndex === -1) { tasks.push(task); } else { tasks.splice(insertIndex, 0, task); } // Recalculate positions let pos = 0; tasks.forEach(t => { if (t.columnId === columnId) { t.position = pos++; } }); this.setState({ tasks }, 'MOVE_TASK'); } getTaskById(taskId) { return this.state.tasks.find(t => t.id === taskId); } getTasksByColumn(columnId) { // Priority order: high (0) > medium (1) > low (2) const priorityOrder = { high: 0, medium: 1, low: 2 }; return this.state.tasks .filter(t => t.columnId === columnId && !t.archived) .sort((a, b) => { // 1. First by position (manual drag&drop sorting) const posA = a.position ?? 999999; const posB = b.position ?? 999999; if (posA !== posB) return posA - posB; // 2. Then by priority (high > medium > low) const priA = priorityOrder[a.priority] ?? 1; const priB = priorityOrder[b.priority] ?? 1; if (priA !== priB) return priA - priB; // 3. Then by creation date (older first) const dateA = new Date(a.createdAt || 0).getTime(); const dateB = new Date(b.createdAt || 0).getTime(); return dateA - dateB; }); } // ===================== // LABEL ACTIONS // ===================== setLabels(labels) { this.setState({ labels }, 'SET_LABELS'); } addLabel(label) { this.setState({ labels: [...this.state.labels, label] }, 'ADD_LABEL'); } updateLabel(labelId, updates) { this.setState({ labels: this.state.labels.map(l => l.id === labelId ? { ...l, ...updates } : l ) }, 'UPDATE_LABEL'); } removeLabel(labelId) { this.setState({ labels: this.state.labels.filter(l => l.id !== labelId) }, 'REMOVE_LABEL'); } // ===================== // REMINDER ACTIONS // ===================== setReminders(reminders) { this.setState({ reminders }, 'SET_REMINDERS'); } addReminder(reminder) { this.setState({ reminders: [...this.state.reminders, reminder] }, 'ADD_REMINDER'); } updateReminder(reminderId, updates) { this.setState({ reminders: this.state.reminders.map(r => r.id === reminderId ? { ...r, ...updates } : r ) }, 'UPDATE_REMINDER'); } removeReminder(reminderId) { this.setState({ reminders: this.state.reminders.filter(r => r.id !== reminderId) }, 'REMOVE_REMINDER'); } // ===================== // FILTER ACTIONS // ===================== setFilter(key, value) { this.setState({ filters: { ...this.state.filters, [key]: value } }, 'SET_FILTER'); } setFilters(filters) { this.setState({ filters: { ...this.state.filters, ...filters } }, 'SET_FILTERS'); } resetFilters() { this.setState({ filters: { search: '', priority: 'all', assignee: 'all', label: 'all', dueDate: 'all', archived: false } }, 'RESET_FILTERS'); } // ===================== // SELECTION ACTIONS // ===================== selectTask(taskId, multi = false) { if (multi) { const selected = this.state.selectedTaskIds.includes(taskId) ? this.state.selectedTaskIds.filter(id => id !== taskId) : [...this.state.selectedTaskIds, taskId]; this.setState({ selectedTaskIds: selected }, 'SELECT_TASK'); } else { this.setState({ selectedTaskIds: [taskId] }, 'SELECT_TASK'); } } deselectTask(taskId) { this.setState({ selectedTaskIds: this.state.selectedTaskIds.filter(id => id !== taskId) }, 'DESELECT_TASK'); } clearSelection() { this.setState({ selectedTaskIds: [] }, 'CLEAR_SELECTION'); } selectAllInColumn(columnId) { const taskIds = this.getTasksByColumn(columnId).map(t => t.id); this.setState({ selectedTaskIds: taskIds }, 'SELECT_ALL_IN_COLUMN'); } // ===================== // UI STATE ACTIONS // ===================== setCurrentView(view) { this.setState({ currentView: view }, 'SET_VIEW'); } setLoading(isLoading) { this.setState({ isLoading }, 'SET_LOADING'); } setOnline(isOnline) { this.setState({ isOnline, syncStatus: isOnline ? 'synced' : 'offline' }, 'SET_ONLINE'); } setSyncStatus(status) { this.setState({ syncStatus: status }, 'SET_SYNC_STATUS'); } setDragState(dragState) { this.setState({ dragState }, 'SET_DRAG_STATE'); } setEditingTask(task) { this.setState({ editingTask: task }, 'SET_EDITING_TASK'); } // ===================== // MODAL ACTIONS // ===================== openModal(modalId) { if (!this.state.openModals.includes(modalId)) { this.setState({ openModals: [...this.state.openModals, modalId] }, 'OPEN_MODAL'); } } closeModal(modalId) { this.setState({ openModals: this.state.openModals.filter(id => id !== modalId) }, 'CLOSE_MODAL'); } closeAllModals() { this.setState({ openModals: [] }, 'CLOSE_ALL_MODALS'); } isModalOpen(modalId) { return this.state.openModals.includes(modalId); } // ===================== // UNDO/REDO ACTIONS // ===================== pushUndo(action) { const undoStack = [...this.state.undoStack, action]; // Limit stack size if (undoStack.length > this.state.maxUndoHistory) { undoStack.shift(); } this.setState({ undoStack, redoStack: [] // Clear redo stack on new action }, 'PUSH_UNDO'); } popUndo() { if (this.state.undoStack.length === 0) return null; const undoStack = [...this.state.undoStack]; const action = undoStack.pop(); this.setState({ undoStack, redoStack: [...this.state.redoStack, action] }, 'POP_UNDO'); return action; } popRedo() { if (this.state.redoStack.length === 0) return null; const redoStack = [...this.state.redoStack]; const action = redoStack.pop(); this.setState({ redoStack, undoStack: [...this.state.undoStack, action] }, 'POP_REDO'); return action; } canUndo() { return this.state.undoStack.length > 0; } canRedo() { return this.state.redoStack.length > 0; } // ===================== // USER ACTIONS // ===================== setCurrentUser(user) { this.setState({ currentUser: user }, 'SET_CURRENT_USER'); } setUsers(users) { this.setState({ users }, 'SET_USERS'); } getUserById(userId) { return this.state.users.find(u => u.id === userId); } } // Create singleton instance const store = new Store(); // Debug middleware (only in development) if (window.location.hostname === 'localhost') { store.use((prevState, updates, actionType) => { console.log(`[Store] ${actionType}:`, updates); return updates; }); } // Persistence middleware for filters (EXCLUDING search to prevent "hanging" search) store.subscribe('filters', (filters) => { // Save filters WITHOUT search - search should not persist across sessions const { search, ...filtersToSave } = filters; localStorage.setItem('task_filters', JSON.stringify(filtersToSave)); }); // Load persisted filters const savedFilters = localStorage.getItem('task_filters'); if (savedFilters) { try { const parsed = JSON.parse(savedFilters); // Ensure search is always empty on load store.setFilters({ ...parsed, search: '' }); } catch (e) { // Ignore parse errors } } export default store;