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