feat(frontend): add API client and Svelte stores
- Create API client with auto-retry and token refresh support - Add comprehensive API endpoints for tasks, tags, projects, sync, and auth - Implement authStore for authentication state management - Implement tasksStore with optimistic updates and offline queue - Add derived stores for filtered task views (pending, completed, by project) - Implement syncStore for managing sync state and queue - Add client ID generation and persistence for sync tracking
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
import { authStore } from '$lib/stores/auth.js';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./types.js').APIResponse} APIResponse
|
||||||
|
* @typedef {import('./types.js').AuthTokens} AuthTokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make authenticated API request
|
||||||
|
* @template T
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
export async function apiRequest(endpoint, options = {}) {
|
||||||
|
const auth = get(authStore);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add auth token if available
|
||||||
|
if (auth.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${auth.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token expired - try refresh
|
||||||
|
if (response.status === 401 && auth.refreshToken) {
|
||||||
|
const refreshed = await refreshAccessToken(auth.refreshToken);
|
||||||
|
if (refreshed) {
|
||||||
|
// Retry with new token
|
||||||
|
headers['Authorization'] = `Bearer ${refreshed.access_token}`;
|
||||||
|
return apiRequest(endpoint, { ...options, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API Error [${endpoint}]:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @returns {Promise<AuthTokens|null>}
|
||||||
|
*/
|
||||||
|
async function refreshAccessToken(refreshToken) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
// Update auth store
|
||||||
|
authStore.setTokens(result.data);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { apiRequest } from './client.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./types.js').Task} Task
|
||||||
|
* @typedef {import('./types.js').TaskFilters} TaskFilters
|
||||||
|
* @typedef {import('./types.js').SyncResult} SyncResult
|
||||||
|
* @typedef {import('./types.js').AuthTokens} AuthTokens
|
||||||
|
* @typedef {import('./types.js').User} User
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
// Tasks API
|
||||||
|
export const tasks = {
|
||||||
|
/**
|
||||||
|
* List all tasks with optional filters
|
||||||
|
* @param {TaskFilters} [filters]
|
||||||
|
* @returns {Promise<Task[]>}
|
||||||
|
*/
|
||||||
|
async list(filters = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
if (filters.project) params.set('project', filters.project);
|
||||||
|
if (filters.priority) params.set('priority', filters.priority);
|
||||||
|
if (filters.tags) {
|
||||||
|
filters.tags.forEach(tag => params.append('tag', tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single task by UUID
|
||||||
|
* @param {string} uuid
|
||||||
|
* @returns {Promise<Task>}
|
||||||
|
*/
|
||||||
|
async get(uuid) {
|
||||||
|
return apiRequest(`/tasks/${uuid}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new task
|
||||||
|
* @param {Partial<Task>} task
|
||||||
|
* @returns {Promise<Task>}
|
||||||
|
*/
|
||||||
|
async create(task) {
|
||||||
|
return apiRequest('/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(task)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing task
|
||||||
|
* @param {string} uuid
|
||||||
|
* @param {Partial<Task>} updates
|
||||||
|
* @returns {Promise<Task>}
|
||||||
|
*/
|
||||||
|
async update(uuid, updates) {
|
||||||
|
return apiRequest(`/tasks/${uuid}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete task
|
||||||
|
* @param {string} uuid
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async delete(uuid) {
|
||||||
|
return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete task
|
||||||
|
* @param {string} uuid
|
||||||
|
* @returns {Promise<Task>}
|
||||||
|
*/
|
||||||
|
async complete(uuid) {
|
||||||
|
return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start task timer
|
||||||
|
* @param {string} uuid
|
||||||
|
* @returns {Promise<Task>}
|
||||||
|
*/
|
||||||
|
async start(uuid) {
|
||||||
|
return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop task timer
|
||||||
|
* @param {string} uuid
|
||||||
|
* @returns {Promise<Task>}
|
||||||
|
*/
|
||||||
|
async stop(uuid) {
|
||||||
|
return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tags API
|
||||||
|
export const tags = {
|
||||||
|
/**
|
||||||
|
* List all unique tags
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
async list() {
|
||||||
|
return apiRequest('/tags');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add tag to task
|
||||||
|
* @param {string} uuid
|
||||||
|
* @param {string} tag
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async add(uuid, tag) {
|
||||||
|
return apiRequest(`/tasks/${uuid}/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tag })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tag from task
|
||||||
|
* @param {string} uuid
|
||||||
|
* @param {string} tag
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async remove(uuid, tag) {
|
||||||
|
return apiRequest(`/tasks/${uuid}/tags/${encodeURIComponent(tag)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Projects API
|
||||||
|
export const projects = {
|
||||||
|
/**
|
||||||
|
* List all projects
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
async list() {
|
||||||
|
return apiRequest('/projects');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync API
|
||||||
|
export const sync = {
|
||||||
|
/**
|
||||||
|
* Get changes since timestamp
|
||||||
|
* @param {number} since - Unix timestamp
|
||||||
|
* @param {string} clientId
|
||||||
|
* @returns {Promise<any[]>}
|
||||||
|
*/
|
||||||
|
async getChanges(since, clientId) {
|
||||||
|
return apiRequest('/sync/changes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ since, client_id: clientId })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push local changes to server
|
||||||
|
* @param {Task[]} tasks
|
||||||
|
* @param {string} clientId
|
||||||
|
* @returns {Promise<{processed: number, conflicts: number}>}
|
||||||
|
*/
|
||||||
|
async push(tasks, clientId) {
|
||||||
|
return apiRequest('/sync/push', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tasks, client_id: clientId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const auth = {
|
||||||
|
/**
|
||||||
|
* Get OAuth login URL
|
||||||
|
* @returns {Promise<{url: string, state: string}>}
|
||||||
|
*/
|
||||||
|
async getLoginUrl() {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/login`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to get login URL');
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange OAuth code for tokens
|
||||||
|
* @param {string} code
|
||||||
|
* @returns {Promise<{access_token: string, refresh_token: string, expires_at: number, token_type: string, user: User}>}
|
||||||
|
*/
|
||||||
|
async callback(code) {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/callback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to authenticate');
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout (revoke refresh token)
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async logout(refreshToken) {
|
||||||
|
return apiRequest('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/api/types.js').AuthTokens} AuthTokens
|
||||||
|
* @typedef {import('$lib/api/types.js').User} User
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AuthState
|
||||||
|
* @property {string|null} accessToken
|
||||||
|
* @property {string|null} refreshToken
|
||||||
|
* @property {number|null} expiresAt
|
||||||
|
* @property {User|null} user
|
||||||
|
* @property {boolean} isAuthenticated
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'opal_auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load auth state from localStorage
|
||||||
|
* @returns {AuthState}
|
||||||
|
*/
|
||||||
|
function loadAuth() {
|
||||||
|
if (!browser) {
|
||||||
|
return {
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
// Check if token expired
|
||||||
|
if (stored.expiresAt && stored.expiresAt < Date.now() / 1000) {
|
||||||
|
// Token expired - clear
|
||||||
|
removeItem(STORAGE_KEY);
|
||||||
|
return loadAuth();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...stored,
|
||||||
|
isAuthenticated: Boolean(stored.accessToken)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create auth store
|
||||||
|
*/
|
||||||
|
function createAuthStore() {
|
||||||
|
const { subscribe, set, update } = writable(loadAuth());
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set authentication tokens
|
||||||
|
* @param {AuthTokens} tokens
|
||||||
|
*/
|
||||||
|
setTokens(tokens) {
|
||||||
|
update(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt: tokens.expires_at,
|
||||||
|
isAuthenticated: true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
setItem(STORAGE_KEY, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user info
|
||||||
|
* @param {User} user
|
||||||
|
*/
|
||||||
|
setUser(user) {
|
||||||
|
update(state => {
|
||||||
|
const newState = { ...state, user };
|
||||||
|
if (browser) {
|
||||||
|
setItem(STORAGE_KEY, newState);
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set full auth data (tokens + user)
|
||||||
|
* @param {AuthTokens & {user: User}} data
|
||||||
|
*/
|
||||||
|
setAuth(data) {
|
||||||
|
const newState = {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
expiresAt: data.expires_at,
|
||||||
|
user: data.user,
|
||||||
|
isAuthenticated: true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
setItem(STORAGE_KEY, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(newState);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear auth (logout)
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
if (browser) {
|
||||||
|
removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = createAuthStore();
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { sync as syncAPI } from '$lib/api/endpoints.js';
|
||||||
|
import { getQueue, clearQueue } from '$lib/utils/sync-queue.js';
|
||||||
|
import { getItem, setItem } from '$lib/utils/storage.js';
|
||||||
|
import { generateUUID } from '$lib/utils/uuid.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/api/types.js').SyncResult} SyncResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SyncState
|
||||||
|
* @property {'idle'|'syncing'|'error'} status
|
||||||
|
* @property {number} lastSync - Unix timestamp
|
||||||
|
* @property {string|null} error
|
||||||
|
* @property {number} queueSize
|
||||||
|
* @property {string} clientId
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SYNC_STATE_KEY = 'opal_sync_state';
|
||||||
|
const CLIENT_ID_KEY = 'opal_client_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create client ID
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getClientId() {
|
||||||
|
let clientId = getItem(CLIENT_ID_KEY);
|
||||||
|
if (!clientId) {
|
||||||
|
clientId = generateUUID();
|
||||||
|
setItem(CLIENT_ID_KEY, clientId);
|
||||||
|
}
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load sync state
|
||||||
|
* @returns {SyncState}
|
||||||
|
*/
|
||||||
|
function loadSyncState() {
|
||||||
|
const stored = getItem(SYNC_STATE_KEY);
|
||||||
|
return {
|
||||||
|
status: 'idle',
|
||||||
|
lastSync: stored?.lastSync || 0,
|
||||||
|
error: null,
|
||||||
|
queueSize: getQueue().length,
|
||||||
|
clientId: getClientId()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sync store
|
||||||
|
*/
|
||||||
|
function createSyncStore() {
|
||||||
|
const { subscribe, set, update } = writable(loadSyncState());
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform sync
|
||||||
|
* @returns {Promise<SyncResult>}
|
||||||
|
*/
|
||||||
|
async sync() {
|
||||||
|
update(state => ({ ...state, status: 'syncing', error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = loadSyncState();
|
||||||
|
const queue = getQueue();
|
||||||
|
|
||||||
|
let result = {
|
||||||
|
pulled: 0,
|
||||||
|
pushed: 0,
|
||||||
|
conflicts_resolved: 0,
|
||||||
|
queued_offline: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Push queued changes
|
||||||
|
if (queue.length > 0) {
|
||||||
|
const tasks = queue.map(q => q.data);
|
||||||
|
try {
|
||||||
|
await syncAPI.push(tasks, state.clientId);
|
||||||
|
clearQueue();
|
||||||
|
result.pushed = queue.length;
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Failed to push queue: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull changes from server
|
||||||
|
try {
|
||||||
|
const changes = await syncAPI.getChanges(state.lastSync, state.clientId);
|
||||||
|
result.pulled = changes.length;
|
||||||
|
// TODO: Apply changes to local state
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Failed to pull changes: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
setItem(SYNC_STATE_KEY, { lastSync: now });
|
||||||
|
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
status: 'idle',
|
||||||
|
lastSync: now,
|
||||||
|
queueSize: 0,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update queue size
|
||||||
|
*/
|
||||||
|
updateQueueSize() {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
queueSize: getQueue().length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncStore = createSyncStore();
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import { tasks as tasksAPI } from '$lib/api/endpoints.js';
|
||||||
|
import { queueChange } from '$lib/utils/sync-queue.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/api/types.js').Task} Task
|
||||||
|
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tasks store
|
||||||
|
*/
|
||||||
|
function createTasksStore() {
|
||||||
|
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all tasks from API
|
||||||
|
* @param {TaskFilters} [filters]
|
||||||
|
*/
|
||||||
|
async load(filters = {}) {
|
||||||
|
try {
|
||||||
|
const tasks = await tasksAPI.list(filters);
|
||||||
|
set(tasks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tasks:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new task (optimistic update)
|
||||||
|
* @param {Partial<Task>} task
|
||||||
|
*/
|
||||||
|
async add(task) {
|
||||||
|
try {
|
||||||
|
const created = await tasksAPI.create(task);
|
||||||
|
update(tasks => [...tasks, created]);
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
// Queue for offline sync
|
||||||
|
queueChange({
|
||||||
|
type: 'create',
|
||||||
|
task_uuid: task.uuid,
|
||||||
|
data: task
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still update UI optimistically
|
||||||
|
update(tasks => [...tasks, task]);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update task (optimistic update)
|
||||||
|
* @param {string} uuid
|
||||||
|
* @param {Partial<Task>} updates
|
||||||
|
*/
|
||||||
|
async updateTask(uuid, updates) {
|
||||||
|
// Optimistic update
|
||||||
|
update(tasks => {
|
||||||
|
const index = tasks.findIndex(t => t.uuid === uuid);
|
||||||
|
if (index >= 0) {
|
||||||
|
tasks[index] = { ...tasks[index], ...updates, modified: Date.now() / 1000 };
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await tasksAPI.update(uuid, updates);
|
||||||
|
// Sync with server response
|
||||||
|
update(tasks => {
|
||||||
|
const index = tasks.findIndex(t => t.uuid === uuid);
|
||||||
|
if (index >= 0) {
|
||||||
|
tasks[index] = updated;
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Queue for offline sync
|
||||||
|
queueChange({
|
||||||
|
type: 'update',
|
||||||
|
task_uuid: uuid,
|
||||||
|
data: updates
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete task
|
||||||
|
* @param {string} uuid
|
||||||
|
*/
|
||||||
|
async deleteTask(uuid) {
|
||||||
|
// Optimistic removal
|
||||||
|
update(tasks => tasks.filter(t => t.uuid !== uuid));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tasksAPI.delete(uuid);
|
||||||
|
} catch (error) {
|
||||||
|
queueChange({
|
||||||
|
type: 'delete',
|
||||||
|
task_uuid: uuid,
|
||||||
|
data: {}
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete task
|
||||||
|
* @param {string} uuid
|
||||||
|
*/
|
||||||
|
async complete(uuid) {
|
||||||
|
try {
|
||||||
|
const completed = await tasksAPI.complete(uuid);
|
||||||
|
update(tasks => {
|
||||||
|
const index = tasks.findIndex(t => t.uuid === uuid);
|
||||||
|
if (index >= 0) {
|
||||||
|
tasks[index] = completed;
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
queueChange({
|
||||||
|
type: 'update',
|
||||||
|
task_uuid: uuid,
|
||||||
|
data: { status: 'C', end: Date.now() / 1000 }
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tasksStore = createTasksStore();
|
||||||
|
|
||||||
|
// Derived stores for filtered views
|
||||||
|
export const pendingTasks = derived(
|
||||||
|
tasksStore,
|
||||||
|
$tasks => $tasks.filter(t => t.status === 'P')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const completedTasks = derived(
|
||||||
|
tasksStore,
|
||||||
|
$tasks => $tasks.filter(t => t.status === 'C')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tasksByProject = derived(
|
||||||
|
tasksStore,
|
||||||
|
$tasks => {
|
||||||
|
const grouped = {};
|
||||||
|
$tasks.forEach(task => {
|
||||||
|
const project = task.project || 'No Project';
|
||||||
|
if (!grouped[project]) grouped[project] = [];
|
||||||
|
grouped[project].push(task);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user