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:
2026-01-06 15:45:13 +01:00
parent 41795d1827
commit d99e158a8c
5 changed files with 754 additions and 0 deletions
+90
View File
@@ -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;
}
}
+225
View File
@@ -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 })
});
}
};
+142
View File
@@ -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();
+135
View File
@@ -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();
+162
View File
@@ -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;
}
);