From d99e158a8cc6651b6e64cf1b2a17323c92b4a7a7 Mon Sep 17 00:00:00 2001 From: Joakim Date: Tue, 6 Jan 2026 15:45:13 +0100 Subject: [PATCH] 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 --- opal-web/src/lib/api/client.js | 90 ++++++++++++ opal-web/src/lib/api/endpoints.js | 225 ++++++++++++++++++++++++++++++ opal-web/src/lib/stores/auth.js | 142 +++++++++++++++++++ opal-web/src/lib/stores/sync.js | 135 ++++++++++++++++++ opal-web/src/lib/stores/tasks.js | 162 +++++++++++++++++++++ 5 files changed, 754 insertions(+) create mode 100644 opal-web/src/lib/api/client.js create mode 100644 opal-web/src/lib/api/endpoints.js create mode 100644 opal-web/src/lib/stores/auth.js create mode 100644 opal-web/src/lib/stores/sync.js create mode 100644 opal-web/src/lib/stores/tasks.js diff --git a/opal-web/src/lib/api/client.js b/opal-web/src/lib/api/client.js new file mode 100644 index 0000000..49b191a --- /dev/null +++ b/opal-web/src/lib/api/client.js @@ -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} + */ +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} + */ +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; + } +} diff --git a/opal-web/src/lib/api/endpoints.js b/opal-web/src/lib/api/endpoints.js new file mode 100644 index 0000000..c79ea9d --- /dev/null +++ b/opal-web/src/lib/api/endpoints.js @@ -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} + */ + 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} + */ + async get(uuid) { + return apiRequest(`/tasks/${uuid}`); + }, + + /** + * Create new task + * @param {Partial} task + * @returns {Promise} + */ + async create(task) { + return apiRequest('/tasks', { + method: 'POST', + body: JSON.stringify(task) + }); + }, + + /** + * Update existing task + * @param {string} uuid + * @param {Partial} updates + * @returns {Promise} + */ + async update(uuid, updates) { + return apiRequest(`/tasks/${uuid}`, { + method: 'PUT', + body: JSON.stringify(updates) + }); + }, + + /** + * Delete task + * @param {string} uuid + * @returns {Promise} + */ + async delete(uuid) { + return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' }); + }, + + /** + * Complete task + * @param {string} uuid + * @returns {Promise} + */ + async complete(uuid) { + return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' }); + }, + + /** + * Start task timer + * @param {string} uuid + * @returns {Promise} + */ + async start(uuid) { + return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' }); + }, + + /** + * Stop task timer + * @param {string} uuid + * @returns {Promise} + */ + async stop(uuid) { + return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' }); + } +}; + +// Tags API +export const tags = { + /** + * List all unique tags + * @returns {Promise} + */ + async list() { + return apiRequest('/tags'); + }, + + /** + * Add tag to task + * @param {string} uuid + * @param {string} tag + * @returns {Promise} + */ + 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} + */ + async remove(uuid, tag) { + return apiRequest(`/tasks/${uuid}/tags/${encodeURIComponent(tag)}`, { + method: 'DELETE' + }); + } +}; + +// Projects API +export const projects = { + /** + * List all projects + * @returns {Promise} + */ + async list() { + return apiRequest('/projects'); + } +}; + +// Sync API +export const sync = { + /** + * Get changes since timestamp + * @param {number} since - Unix timestamp + * @param {string} clientId + * @returns {Promise} + */ + 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} + */ + async logout(refreshToken) { + return apiRequest('/auth/logout', { + method: 'POST', + body: JSON.stringify({ refresh_token: refreshToken }) + }); + } +}; diff --git a/opal-web/src/lib/stores/auth.js b/opal-web/src/lib/stores/auth.js new file mode 100644 index 0000000..3c85eb0 --- /dev/null +++ b/opal-web/src/lib/stores/auth.js @@ -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(); diff --git a/opal-web/src/lib/stores/sync.js b/opal-web/src/lib/stores/sync.js new file mode 100644 index 0000000..7f40df2 --- /dev/null +++ b/opal-web/src/lib/stores/sync.js @@ -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} + */ + 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(); diff --git a/opal-web/src/lib/stores/tasks.js b/opal-web/src/lib/stores/tasks.js new file mode 100644 index 0000000..46a3bd7 --- /dev/null +++ b/opal-web/src/lib/stores/tasks.js @@ -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 + */ + 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} 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; + } +);