614 Zeilen
14 KiB
JavaScript
614 Zeilen
14 KiB
JavaScript
/**
|
|
* 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;
|