refactor: clean up opal-web duplication, dead code, and comment noise

- Deduplicate API_BASE (was defined in both client.js and endpoints.js)
- Extract EMPTY_STATE and persist() helper in auth store (DRY)
- Extract updateByUuid() in tasks store, normalize to .map() pattern
- Remove unused getQueueSize(), Select.svelte, and lib/index.js
- Modernize uuid.js to prefer crypto.randomUUID()
- Strip ~60 redundant comments that restated self-evident code

No behavior changes. Build passes, pre-existing type errors unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 01:03:08 +01:00
parent 41a12fe7a9
commit 8693681660
22 changed files with 110 additions and 470 deletions
+1 -5
View File
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
* @typedef {import('./types.js').AuthTokens} AuthTokens * @typedef {import('./types.js').AuthTokens} AuthTokens
*/ */
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080'; export const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
/** /**
* Make authenticated API request * Make authenticated API request
@@ -23,7 +23,6 @@ export async function apiRequest(endpoint, options = {}) {
...options.headers ...options.headers
}; };
// Add auth token if available
if (auth.accessToken) { if (auth.accessToken) {
headers['Authorization'] = `Bearer ${auth.accessToken}`; headers['Authorization'] = `Bearer ${auth.accessToken}`;
} }
@@ -34,11 +33,9 @@ export async function apiRequest(endpoint, options = {}) {
headers headers
}); });
// Token expired - try refresh
if (response.status === 401 && auth.refreshToken) { if (response.status === 401 && auth.refreshToken) {
const refreshed = await refreshAccessToken(auth.refreshToken); const refreshed = await refreshAccessToken(auth.refreshToken);
if (refreshed) { if (refreshed) {
// Retry with new token
headers['Authorization'] = `Bearer ${refreshed.access_token}`; headers['Authorization'] = `Bearer ${refreshed.access_token}`;
return apiRequest(endpoint, { ...options, headers }); return apiRequest(endpoint, { ...options, headers });
} }
@@ -78,7 +75,6 @@ async function refreshAccessToken(refreshToken) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Update auth store
authStore.setTokens(result.data); authStore.setTokens(result.data);
return result.data; return result.data;
} }
+12 -68
View File
@@ -1,4 +1,4 @@
import { apiRequest } from './client.js'; import { apiRequest, API_BASE } from './client.js';
/** /**
* @typedef {import('./types.js').Task} Task * @typedef {import('./types.js').Task} Task
@@ -8,12 +8,8 @@ import { apiRequest } from './client.js';
* @typedef {import('./types.js').User} User * @typedef {import('./types.js').User} User
*/ */
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
// Tasks API
export const tasks = { export const tasks = {
/** /**
* List all tasks with optional filters
* @param {TaskFilters} [filters] * @param {TaskFilters} [filters]
* @returns {Promise<Task[]>} * @returns {Promise<Task[]>}
*/ */
@@ -33,20 +29,12 @@ export const tasks = {
return apiRequest(`/tasks${query ? `?${query}` : ''}`); return apiRequest(`/tasks${query ? `?${query}` : ''}`);
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Get single task by UUID
* @param {string} uuid
* @returns {Promise<Task>}
*/
async get(uuid) { async get(uuid) {
return apiRequest(`/tasks/${uuid}`); return apiRequest(`/tasks/${uuid}`);
}, },
/** /** @param {Partial<Task>} task @returns {Promise<Task>} */
* Create new task
* @param {Partial<Task>} task
* @returns {Promise<Task>}
*/
async create(task) { async create(task) {
return apiRequest('/tasks', { return apiRequest('/tasks', {
method: 'POST', method: 'POST',
@@ -55,7 +43,6 @@ export const tasks = {
}, },
/** /**
* Update existing task
* @param {string} uuid * @param {string} uuid
* @param {Partial<Task>} updates * @param {Partial<Task>} updates
* @returns {Promise<Task>} * @returns {Promise<Task>}
@@ -67,44 +54,27 @@ export const tasks = {
}); });
}, },
/** /** @param {string} uuid @returns {Promise<void>} */
* Delete task
* @param {string} uuid
* @returns {Promise<void>}
*/
async delete(uuid) { async delete(uuid) {
return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' }); return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' });
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Complete task
* @param {string} uuid
* @returns {Promise<Task>}
*/
async complete(uuid) { async complete(uuid) {
return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' });
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Start task timer
* @param {string} uuid
* @returns {Promise<Task>}
*/
async start(uuid) { async start(uuid) {
return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' });
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Stop task timer
* @param {string} uuid
* @returns {Promise<Task>}
*/
async stop(uuid) { async stop(uuid) {
return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' });
}, },
/** /**
* Parse CLI input and create task
* @param {string} input - Raw opal CLI syntax * @param {string} input - Raw opal CLI syntax
* @returns {Promise<Task>} * @returns {Promise<Task>}
*/ */
@@ -115,29 +85,20 @@ export const tasks = {
}); });
}, },
/** /** @param {string} reportName @returns {Promise<Task[]>} */
* List tasks by report name
* @param {string} reportName
* @returns {Promise<Task[]>}
*/
async listByReport(reportName) { async listByReport(reportName) {
const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`); const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`);
return result.tasks ?? result; return result.tasks ?? result;
} }
}; };
// Tags API
export const tags = { export const tags = {
/** /** @returns {Promise<string[]>} */
* List all unique tags
* @returns {Promise<string[]>}
*/
async list() { async list() {
return apiRequest('/tags'); return apiRequest('/tags');
}, },
/** /**
* Add tag to task
* @param {string} uuid * @param {string} uuid
* @param {string} tag * @param {string} tag
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -150,7 +111,6 @@ export const tags = {
}, },
/** /**
* Remove tag from task
* @param {string} uuid * @param {string} uuid
* @param {string} tag * @param {string} tag
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -162,21 +122,15 @@ export const tags = {
} }
}; };
// Projects API
export const projects = { export const projects = {
/** /** @returns {Promise<string[]>} */
* List all projects
* @returns {Promise<string[]>}
*/
async list() { async list() {
return apiRequest('/projects'); return apiRequest('/projects');
} }
}; };
// Sync API
export const sync = { export const sync = {
/** /**
* Get changes since timestamp
* @param {number} since - Unix timestamp * @param {number} since - Unix timestamp
* @param {string} clientId * @param {string} clientId
* @returns {Promise<any[]>} * @returns {Promise<any[]>}
@@ -189,7 +143,6 @@ export const sync = {
}, },
/** /**
* Push local changes to server
* @param {Task[]} tasks * @param {Task[]} tasks
* @param {string} clientId * @param {string} clientId
* @returns {Promise<{processed: number, conflicts: number}>} * @returns {Promise<{processed: number, conflicts: number}>}
@@ -202,12 +155,8 @@ export const sync = {
} }
}; };
// Auth API
export const auth = { export const auth = {
/** /** @returns {Promise<{url: string, state: string}>} */
* Get OAuth login URL
* @returns {Promise<{url: string, state: string}>}
*/
async getLoginUrl() { async getLoginUrl() {
const response = await fetch(`${API_BASE}/auth/login`); const response = await fetch(`${API_BASE}/auth/login`);
const result = await response.json(); const result = await response.json();
@@ -218,7 +167,6 @@ export const auth = {
}, },
/** /**
* Exchange OAuth code for tokens
* @param {string} code * @param {string} code
* @returns {Promise<{access_token: string, refresh_token: string, expires_at: number, token_type: string, user: User}>} * @returns {Promise<{access_token: string, refresh_token: string, expires_at: number, token_type: string, user: User}>}
*/ */
@@ -236,11 +184,7 @@ export const auth = {
return result.data; return result.data;
}, },
/** /** @param {string} refreshToken @returns {Promise<void>} */
* Logout (revoke refresh token)
* @param {string} refreshToken
* @returns {Promise<void>}
*/
async logout(refreshToken) { async logout(refreshToken) {
return apiRequest('/auth/logout', { return apiRequest('/auth/logout', {
method: 'POST', method: 'POST',
@@ -29,7 +29,6 @@
/** @type {HTMLDivElement|null} */ /** @type {HTMLDivElement|null} */
let sheetEl = null; let sheetEl = null;
// Body scroll lock — managed in afterUpdate to avoid SSR document access
afterUpdate(() => { afterUpdate(() => {
if (!mounted) return; if (!mounted) return;
if (open) { if (open) {
@@ -51,7 +50,6 @@
e.preventDefault(); e.preventDefault();
onClose(); onClose();
} }
// Focus trap
if (e.key === 'Tab' && sheetEl) { if (e.key === 'Tab' && sheetEl) {
const focusable = sheetEl.querySelectorAll( const focusable = sheetEl.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
@@ -115,10 +113,8 @@
if (dragging) { if (dragging) {
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
// Only allow dragging down (positive)
dragOffset = Math.max(0, deltaY); dragOffset = Math.max(0, deltaY);
// Track velocity
const now = Date.now(); const now = Date.now();
const dt = now - lastTime; const dt = now - lastTime;
if (dt > 0 && lastY !== null) { if (dt > 0 && lastY !== null) {
@@ -1,11 +1,7 @@
<script> <script>
import { activeFilter, setFilter } from '$lib/stores/filters.js'; import { activeFilter, setFilter } from '$lib/stores/filters.js';
/** /** @param {string} token */
* Remove a single token from the active filter string.
* If it's the last token, clear the entire filter.
* @param {string} token
*/
function removeToken(token) { function removeToken(token) {
if (!$activeFilter) return; if (!$activeFilter) return;
const tokens = $activeFilter.trim().split(/\s+/); const tokens = $activeFilter.trim().split(/\s+/);
@@ -19,7 +19,6 @@
/** @type {FilterModal} */ /** @type {FilterModal} */
let filterModal; let filterModal;
/** Map backend report names to display labels */
const reportLabels = /** @type {Record<string, string>} */ ({ const reportLabels = /** @type {Record<string, string>} */ ({
list: 'Pending', list: 'Pending',
next: 'Next', next: 'Next',
+3 -15
View File
@@ -25,7 +25,6 @@
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || loading) return; if (!trimmed || loading) return;
// Merge user input with active filter, deduplicating tokens
const merged = mergeInputWithFilter(trimmed, $activeFilter || ''); const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
try { try {
@@ -61,22 +60,17 @@
}, 150); }, 150);
} }
/** /** @param {string} text */
* Insert text at cursor position
* @param {string} text
*/
function insertAtCursor(text) { function insertAtCursor(text) {
if (!inputEl) return; if (!inputEl) return;
const start = inputEl.selectionStart ?? value.length; const start = inputEl.selectionStart ?? value.length;
const end = inputEl.selectionEnd ?? value.length; const end = inputEl.selectionEnd ?? value.length;
// Add leading space if cursor isn't at start and prev char isn't a space
const needsSpace = start > 0 && value[start - 1] !== ' '; const needsSpace = start > 0 && value[start - 1] !== ' ';
const insert = (needsSpace ? ' ' : '') + text; const insert = (needsSpace ? ' ' : '') + text;
value = value.slice(0, start) + insert + value.slice(end); value = value.slice(0, start) + insert + value.slice(end);
// Restore focus and cursor position after the inserted text
const newPos = start + insert.length; const newPos = start + insert.length;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (inputEl) { if (inputEl) {
@@ -86,18 +80,12 @@
}); });
} }
/** /** @returns {string} */
* Get the current input value (for PropertyPills smart replace)
* @returns {string}
*/
export function getInputValue() { export function getInputValue() {
return value; return value;
} }
/** /** @param {string} newValue */
* Set the input value (for PropertyPills smart replace)
* @param {string} newValue
*/
export function setInputValue(newValue) { export function setInputValue(newValue) {
value = newValue; value = newValue;
} }
@@ -6,10 +6,7 @@
*/ */
export let onInsert; export let onInsert;
/** Current input value for smart replace */
export let inputValue = ''; export let inputValue = '';
/** Callback to update the input value when doing smart replace */
export let onInputChange = /** @type {(value: string) => void} */ (() => {}); export let onInputChange = /** @type {(value: string) => void} */ (() => {});
export let visible = false; export let visible = false;
@@ -29,7 +26,6 @@
* @param {{ text: string, isTag: boolean }} pill * @param {{ text: string, isTag: boolean }} pill
*/ */
function handleInsert(pill) { function handleInsert(pill) {
// Tags are always additive — no smart replace
if (!pill.isTag && inputValue) { if (!pill.isTag && inputValue) {
const prefix = pill.text; // e.g. "due:" const prefix = pill.text; // e.g. "due:"
const cleaned = removeTokenByPrefix(inputValue, prefix); const cleaned = removeTokenByPrefix(inputValue, prefix);
@@ -49,12 +49,10 @@
const deltaY = touch.clientY - startY; const deltaY = touch.clientY - startY;
if (!locked && !swiping) { if (!locked && !swiping) {
// Angle-based lock-in: horizontal must dominate
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) { if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
swiping = true; swiping = true;
locked = true; locked = true;
} else if (Math.abs(deltaY) > 10) { } else if (Math.abs(deltaY) > 10) {
// Vertical scroll — abort
startX = null; startX = null;
startY = null; startY = null;
return; return;
@@ -74,14 +72,12 @@
} }
if (offsetX >= THRESHOLD) { if (offsetX >= THRESHOLD) {
// Right swipe — complete (row collapses)
completed = true; completed = true;
offsetX = window.innerWidth; offsetX = window.innerWidth;
setTimeout(() => { setTimeout(() => {
onSwipeRight(); onSwipeRight();
}, 200); }, 200);
} else if (offsetX <= -THRESHOLD) { } else if (offsetX <= -THRESHOLD) {
// Left swipe — start/stop (row stays)
triggered = true; triggered = true;
offsetX = -window.innerWidth; offsetX = -window.innerWidth;
setTimeout(() => { setTimeout(() => {
+2 -17
View File
@@ -28,7 +28,6 @@
/** @type {() => void} */ /** @type {() => void} */
export let onClose; export let onClose;
// Editing state — only one field at a time
/** @type {string|null} */ /** @type {string|null} */
let editingField = null; let editingField = null;
@@ -37,7 +36,6 @@
let editProject = ''; let editProject = '';
let editTagInput = ''; let editTagInput = '';
// Recurring instance: remember user choice for this sheet session
/** @type {'instance'|'template'|null} */ /** @type {'instance'|'template'|null} */
let recurringChoice = null; let recurringChoice = null;
@@ -67,17 +65,12 @@
0: 3 0: 3
}); });
/** /** @param {number} ts @returns {string} */
* Format a unix timestamp as yyyy-MM-dd for date input
* @param {number} ts
* @returns {string}
*/
function tsToDateValue(ts) { function tsToDateValue(ts) {
return format(fromUnix(ts), 'yyyy-MM-dd'); return format(fromUnix(ts), 'yyyy-MM-dd');
} }
/** /**
* Check if a field edit on a recurring instance needs the instance/template prompt
* @param {string} field * @param {string} field
* @returns {boolean} * @returns {boolean}
*/ */
@@ -124,14 +117,10 @@
function saveCurrentEdit() { function saveCurrentEdit() {
if (!editingField) return; if (!editingField) return;
// Each field handles its own save via its input events
editingField = null; editingField = null;
} }
/** /** @returns {string} */
* Get the UUID to update based on recurring choice
* @returns {string}
*/
function getTargetUuid() { function getTargetUuid() {
if (recurringChoice === 'template' && task.parent_uuid) { if (recurringChoice === 'template' && task.parent_uuid) {
return task.parent_uuid; return task.parent_uuid;
@@ -194,7 +183,6 @@
async function removeTag(tag) { async function removeTag(tag) {
try { try {
await tagsAPI.remove(getTargetUuid(), tag); await tagsAPI.remove(getTargetUuid(), tag);
// Optimistic: update local
task = { ...task, tags: task.tags.filter(t => t !== tag) }; task = { ...task, tags: task.tags.filter(t => t !== tag) };
} catch (error) { } catch (error) {
console.error('Failed to remove tag:', error); console.error('Failed to remove tag:', error);
@@ -207,7 +195,6 @@
editTagInput = ''; editTagInput = '';
try { try {
await tagsAPI.add(getTargetUuid(), tag); await tagsAPI.add(getTargetUuid(), tag);
// Optimistic: update local
task = { ...task, tags: [...task.tags, tag] }; task = { ...task, tags: [...task.tags, tag] };
} catch (error) { } catch (error) {
console.error('Failed to add tag:', error); console.error('Failed to add tag:', error);
@@ -259,12 +246,10 @@
} }
} }
// After recurring choice is made, proceed with the pending edit
$: if (recurringChoice && pendingEditField) { $: if (recurringChoice && pendingEditField) {
const field = pendingEditField; const field = pendingEditField;
pendingEditField = null; pendingEditField = null;
if (field === 'priority-cycle') { if (field === 'priority-cycle') {
// Direct cycle
const next = priorityCycle[task.priority] ?? 1; const next = priorityCycle[task.priority] ?? 1;
onUpdate(getTargetUuid(), { priority: /** @type {import('$lib/api/types.js').TaskPriority} */ (next) }); onUpdate(getTargetUuid(), { priority: /** @type {import('$lib/api/types.js').TaskPriority} */ (next) });
} else { } else {
@@ -1,73 +0,0 @@
<script>
/**
* @type {Array<{value: string, label: string}>}
*/
export let options = [];
export let value = '';
export let label = '';
export let placeholder = 'Select...';
export let disabled = false;
export let id = '';
</script>
<div class="select-group">
{#if label}
<label for={id} class="label">{label}</label>
{/if}
<select
{id}
bind:value
{disabled}
class="select"
on:change
>
<option value="" disabled selected>{placeholder}</option>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<style>
.select-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.select {
width: 100%;
padding: 0.75rem;
font-size: var(--font-size-base);
font-family: inherit;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1.5rem;
padding-right: 2.5rem;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
}
.select:disabled {
background-color: var(--bg-tertiary);
cursor: not-allowed;
}
</style>
-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
+25 -63
View File
@@ -19,10 +19,16 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
const STORAGE_KEY = 'opal_auth'; const STORAGE_KEY = 'opal_auth';
const DEV_MODE = import.meta.env.DEV; const DEV_MODE = import.meta.env.DEV;
/** /** @type {AuthState} */
* Load auth state from localStorage const EMPTY_STATE = {
* @returns {AuthState} accessToken: null,
*/ refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
};
/** @returns {AuthState} */
function loadAuth() { function loadAuth() {
// In dev mode, auto-authenticate with a dev user. // In dev mode, auto-authenticate with a dev user.
// API requests still go to the real backend (which runs with auth disabled). // API requests still go to the real backend (which runs with auth disabled).
@@ -36,21 +42,11 @@ function loadAuth() {
}; };
} }
if (!browser) { if (!browser) return EMPTY_STATE;
return {
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
};
}
const stored = getItem(STORAGE_KEY); const stored = getItem(STORAGE_KEY);
if (stored) { if (stored) {
// Check if token expired
if (stored.expiresAt && stored.expiresAt < Date.now() / 1000) { if (stored.expiresAt && stored.expiresAt < Date.now() / 1000) {
// Token expired - clear
removeItem(STORAGE_KEY); removeItem(STORAGE_KEY);
return loadAuth(); return loadAuth();
} }
@@ -60,28 +56,21 @@ function loadAuth() {
}; };
} }
return { return EMPTY_STATE;
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
};
} }
/**
* Create auth store
*/
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update } = writable(loadAuth()); const { subscribe, set, update } = writable(loadAuth());
/** Persist state to localStorage */
function persist(/** @type {AuthState} */ state) {
if (browser) setItem(STORAGE_KEY, state);
}
return { return {
subscribe, subscribe,
/** /** @param {AuthTokens} tokens */
* Set authentication tokens
* @param {AuthTokens} tokens
*/
setTokens(tokens) { setTokens(tokens) {
update(state => { update(state => {
const newState = { const newState = {
@@ -91,33 +80,21 @@ function createAuthStore() {
expiresAt: tokens.expires_at, expiresAt: tokens.expires_at,
isAuthenticated: true isAuthenticated: true
}; };
persist(newState);
if (browser) {
setItem(STORAGE_KEY, newState);
}
return newState; return newState;
}); });
}, },
/** /** @param {User} user */
* Set user info
* @param {User} user
*/
setUser(user) { setUser(user) {
update(state => { update(state => {
const newState = { ...state, user }; const newState = { ...state, user };
if (browser) { persist(newState);
setItem(STORAGE_KEY, newState);
}
return newState; return newState;
}); });
}, },
/** /** @param {AuthTokens & {user: User}} data */
* Set full auth data (tokens + user)
* @param {AuthTokens & {user: User}} data
*/
setAuth(data) { setAuth(data) {
const newState = { const newState = {
accessToken: data.access_token, accessToken: data.access_token,
@@ -126,28 +103,13 @@ function createAuthStore() {
user: data.user, user: data.user,
isAuthenticated: true isAuthenticated: true
}; };
persist(newState);
if (browser) {
setItem(STORAGE_KEY, newState);
}
set(newState); set(newState);
}, },
/**
* Clear auth (logout)
*/
clear() { clear() {
if (browser) { if (browser) removeItem(STORAGE_KEY);
removeItem(STORAGE_KEY); set(EMPTY_STATE);
}
set({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
});
} }
}; };
} }
+2 -12
View File
@@ -6,7 +6,6 @@ const RECENT_KEY = 'opal_recent_filters';
const MAX_RECENT = 8; const MAX_RECENT = 8;
/** /**
* Create a localStorage-backed writable store
* @template T * @template T
* @param {string} key * @param {string} key
* @param {T} fallback * @param {T} fallback
@@ -21,10 +20,7 @@ function persisted(key, fallback) {
export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null)); export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null));
export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([])); export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([]));
/** /** @param {string} str */
* Set the active filter and add it to recents
* @param {string} str
*/
export function setFilter(str) { export function setFilter(str) {
const trimmed = str.trim(); const trimmed = str.trim();
if (!trimmed) { if (!trimmed) {
@@ -39,17 +35,11 @@ export function setFilter(str) {
}); });
} }
/**
* Clear the active filter
*/
export function clearFilter() { export function clearFilter() {
activeFilter.set(null); activeFilter.set(null);
} }
/** /** @param {string} str */
* Remove a specific entry from recents
* @param {string} str
*/
export function removeRecent(str) { export function removeRecent(str) {
recentFilters.update(recents => recents.filter(r => r !== str)); recentFilters.update(recents => recents.filter(r => r !== str));
} }
+3 -21
View File
@@ -20,10 +20,7 @@ import { generateUUID } from '$lib/utils/uuid.js';
const SYNC_STATE_KEY = 'opal_sync_state'; const SYNC_STATE_KEY = 'opal_sync_state';
const CLIENT_ID_KEY = 'opal_client_id'; const CLIENT_ID_KEY = 'opal_client_id';
/** /** @returns {string} */
* Get or create client ID
* @returns {string}
*/
function getClientId() { function getClientId() {
let clientId = getItem(CLIENT_ID_KEY); let clientId = getItem(CLIENT_ID_KEY);
if (!clientId) { if (!clientId) {
@@ -33,10 +30,7 @@ function getClientId() {
return clientId; return clientId;
} }
/** /** @returns {SyncState} */
* Load sync state
* @returns {SyncState}
*/
function loadSyncState() { function loadSyncState() {
const stored = getItem(SYNC_STATE_KEY); const stored = getItem(SYNC_STATE_KEY);
return { return {
@@ -48,19 +42,13 @@ function loadSyncState() {
}; };
} }
/**
* Create sync store
*/
function createSyncStore() { function createSyncStore() {
const { subscribe, set, update } = writable(loadSyncState()); const { subscribe, set, update } = writable(loadSyncState());
return { return {
subscribe, subscribe,
/** /** @returns {Promise<SyncResult>} */
* Perform sync
* @returns {Promise<SyncResult>}
*/
async sync() { async sync() {
update(state => ({ ...state, status: 'syncing', error: null })); update(state => ({ ...state, status: 'syncing', error: null }));
@@ -76,7 +64,6 @@ function createSyncStore() {
errors: [] errors: []
}; };
// Push queued changes
if (queue.length > 0) { if (queue.length > 0) {
const tasks = queue.map(q => q.data); const tasks = queue.map(q => q.data);
try { try {
@@ -88,7 +75,6 @@ function createSyncStore() {
} }
} }
// Pull changes from server
try { try {
const changes = await syncAPI.getChanges(state.lastSync, state.clientId); const changes = await syncAPI.getChanges(state.lastSync, state.clientId);
result.pulled = changes.length; result.pulled = changes.length;
@@ -97,7 +83,6 @@ function createSyncStore() {
result.errors.push(`Failed to pull changes: ${error.message}`); result.errors.push(`Failed to pull changes: ${error.message}`);
} }
// Update sync state
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
setItem(SYNC_STATE_KEY, { lastSync: now }); setItem(SYNC_STATE_KEY, { lastSync: now });
@@ -120,9 +105,6 @@ function createSyncStore() {
} }
}, },
/**
* Update queue size
*/
updateQueueSize() { updateQueueSize() {
update(state => ({ update(state => ({
...state, ...state,
+26 -79
View File
@@ -7,17 +7,22 @@ import { queueChange } from '$lib/utils/sync-queue.js';
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters * @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
*/ */
/**
* Create tasks store
*/
function createTasksStore() { function createTasksStore() {
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([])); const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
/**
* Replace a single task in the array by UUID.
* @param {string} uuid
* @param {(task: Task) => Task} fn
*/
function updateByUuid(uuid, fn) {
update(tasks => tasks.map(t => t.uuid === uuid ? fn(t) : t));
}
return { return {
subscribe, subscribe,
/** /**
* Load tasks by report name
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed') * @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
*/ */
async loadReport(reportName) { async loadReport(reportName) {
@@ -31,7 +36,6 @@ function createTasksStore() {
}, },
/** /**
* Parse CLI input and create a task
* @param {string} input - Raw opal CLI syntax * @param {string} input - Raw opal CLI syntax
* @returns {Promise<Task>} * @returns {Promise<Task>}
*/ */
@@ -47,10 +51,7 @@ function createTasksStore() {
} }
}, },
/** /** @param {TaskFilters} [filters] */
* Load all tasks from API
* @param {TaskFilters} [filters]
*/
async load(filters = {}) { async load(filters = {}) {
try { try {
const tasks = await tasksAPI.list(filters); const tasks = await tasksAPI.list(filters);
@@ -62,7 +63,7 @@ function createTasksStore() {
}, },
/** /**
* Add new task (optimistic update) * Optimistic create — queues offline on failure.
* @param {Partial<Task>} task * @param {Partial<Task>} task
*/ */
async add(task) { async add(task) {
@@ -71,101 +72,55 @@ function createTasksStore() {
update(tasks => [...tasks, created]); update(tasks => [...tasks, created]);
return created; return created;
} catch (error) { } catch (error) {
// Queue for offline sync queueChange({ type: 'create', task_uuid: task.uuid, data: task });
queueChange({
type: 'create',
task_uuid: task.uuid,
data: task
});
// Still update UI optimistically
update(tasks => [...tasks, task]); update(tasks => [...tasks, task]);
throw error; throw error;
} }
}, },
/** /**
* Update task (optimistic update) * Optimistic update — queues offline on failure.
* @param {string} uuid * @param {string} uuid
* @param {Partial<Task>} updates * @param {Partial<Task>} updates
*/ */
async updateTask(uuid, updates) { async updateTask(uuid, updates) {
// Optimistic update updateByUuid(uuid, t => ({ ...t, ...updates, modified: Date.now() / 1000 }));
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 { try {
const updated = await tasksAPI.update(uuid, updates); const updated = await tasksAPI.update(uuid, updates);
// Sync with server response updateByUuid(uuid, () => updated);
update(tasks => {
const index = tasks.findIndex(t => t.uuid === uuid);
if (index >= 0) {
tasks[index] = updated;
}
return tasks;
});
} catch (error) { } catch (error) {
// Queue for offline sync queueChange({ type: 'update', task_uuid: uuid, data: updates });
queueChange({
type: 'update',
task_uuid: uuid,
data: updates
});
throw error; throw error;
} }
}, },
/** /** @param {string} uuid */
* Delete task
* @param {string} uuid
*/
async deleteTask(uuid) { async deleteTask(uuid) {
// Optimistic removal
update(tasks => tasks.filter(t => t.uuid !== uuid)); update(tasks => tasks.filter(t => t.uuid !== uuid));
try { try {
await tasksAPI.delete(uuid); await tasksAPI.delete(uuid);
} catch (error) { } catch (error) {
queueChange({ queueChange({ type: 'delete', task_uuid: uuid, data: {} });
type: 'delete',
task_uuid: uuid,
data: {}
});
throw error; throw error;
} }
}, },
/** /** @param {string} uuid */
* Start task timer (optimistic)
* @param {string} uuid
*/
async startTask(uuid) { async startTask(uuid) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
update(tasks => tasks.map(t => updateByUuid(uuid, t => ({ ...t, start: now }));
t.uuid === uuid ? { ...t, start: now } : t
));
try { try {
const updated = await tasksAPI.start(uuid); const updated = await tasksAPI.start(uuid);
update(tasks => tasks.map(t => updateByUuid(uuid, () => updated);
t.uuid === uuid ? updated : t
));
} catch (error) { } catch (error) {
update(tasks => tasks.map(t => updateByUuid(uuid, t => ({ ...t, start: null }));
t.uuid === uuid ? { ...t, start: null } : t
));
throw error; throw error;
} }
}, },
/** /** @param {string} uuid */
* Stop task timer (optimistic)
* @param {string} uuid
*/
async stopTask(uuid) { async stopTask(uuid) {
/** @type {number|null} */ /** @type {number|null} */
let prevStart = null; let prevStart = null;
@@ -178,21 +133,14 @@ function createTasksStore() {
})); }));
try { try {
const updated = await tasksAPI.stop(uuid); const updated = await tasksAPI.stop(uuid);
update(tasks => tasks.map(t => updateByUuid(uuid, () => updated);
t.uuid === uuid ? updated : t
));
} catch (error) { } catch (error) {
update(tasks => tasks.map(t => updateByUuid(uuid, t => ({ ...t, start: prevStart }));
t.uuid === uuid ? { ...t, start: prevStart } : t
));
throw error; throw error;
} }
}, },
/** /** @param {string} uuid */
* Complete task
* @param {string} uuid
*/
async complete(uuid) { async complete(uuid) {
try { try {
await tasksAPI.complete(uuid); await tasksAPI.complete(uuid);
@@ -211,7 +159,6 @@ function createTasksStore() {
export const tasksStore = createTasksStore(); export const tasksStore = createTasksStore();
// Derived stores for filtered views
export const pendingTasks = derived( export const pendingTasks = derived(
tasksStore, tasksStore,
$tasks => $tasks.filter(t => t.status === 'P') $tasks => $tasks.filter(t => t.status === 'P')
+1 -7
View File
@@ -11,10 +11,7 @@ const DEFAULT_THEME = 'obsidian';
/** @type {ThemeName[]} */ /** @type {ThemeName[]} */
export const THEMES = ['obsidian', 'paper', 'midnight']; export const THEMES = ['obsidian', 'paper', 'midnight'];
/** /** @returns {ThemeName} */
* Read stored theme, falling back to default
* @returns {ThemeName}
*/
function getInitial() { function getInitial() {
if (!browser) return DEFAULT_THEME; if (!browser) return DEFAULT_THEME;
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@@ -27,7 +24,6 @@ function getInitial() {
function createThemeStore() { function createThemeStore() {
const { subscribe, set, update } = writable(getInitial()); const { subscribe, set, update } = writable(getInitial());
/** Apply theme to the document */
function apply(/** @type {ThemeName} */ theme) { function apply(/** @type {ThemeName} */ theme) {
if (browser) { if (browser) {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
@@ -35,7 +31,6 @@ function createThemeStore() {
} }
} }
// Apply on every change
subscribe(apply); subscribe(apply);
return { return {
@@ -44,7 +39,6 @@ function createThemeStore() {
set(theme) { set(theme) {
set(theme); set(theme);
}, },
/** Cycle to the next theme */
cycle() { cycle() {
update(current => { update(current => {
const idx = THEMES.indexOf(current); const idx = THEMES.indexOf(current);
+4 -15
View File
@@ -1,7 +1,6 @@
import { format, formatDistance, isToday, isTomorrow, isPast } from 'date-fns'; import { format, formatDistance, isToday, isTomorrow, isPast } from 'date-fns';
/** /**
* Format Unix timestamp to readable date
* @param {number|null} timestamp - Unix timestamp (seconds) * @param {number|null} timestamp - Unix timestamp (seconds)
* @param {string} formatStr - date-fns format string * @param {string} formatStr - date-fns format string
* @returns {string} * @returns {string}
@@ -12,8 +11,7 @@ export function formatDate(timestamp, formatStr = 'MMM d, yyyy') {
} }
/** /**
* Format Unix timestamp to relative time * @param {number|null} timestamp - Unix timestamp (seconds)
* @param {number|null} timestamp
* @returns {string} * @returns {string}
*/ */
export function formatRelative(timestamp) { export function formatRelative(timestamp) {
@@ -27,8 +25,7 @@ export function formatRelative(timestamp) {
} }
/** /**
* Check if timestamp is overdue * @param {number|null} timestamp - Unix timestamp (seconds)
* @param {number|null} timestamp
* @returns {boolean} * @returns {boolean}
*/ */
export function isOverdue(timestamp) { export function isOverdue(timestamp) {
@@ -36,20 +33,12 @@ export function isOverdue(timestamp) {
return isPast(new Date(timestamp * 1000)); return isPast(new Date(timestamp * 1000));
} }
/** /** @param {Date} date @returns {number} */
* Convert Date object to Unix timestamp
* @param {Date} date
* @returns {number}
*/
export function toUnix(date) { export function toUnix(date) {
return Math.floor(date.getTime() / 1000); return Math.floor(date.getTime() / 1000);
} }
/** /** @param {number} timestamp @returns {Date} */
* Convert Unix timestamp to Date object
* @param {number} timestamp
* @returns {Date}
*/
export function fromUnix(timestamp) { export function fromUnix(timestamp) {
return new Date(timestamp * 1000); return new Date(timestamp * 1000);
} }
+2 -8
View File
@@ -1,6 +1,4 @@
/** /** Valid filter attribute keys that work as query filters in the engine */
* Valid filter attribute keys (these actually work as query filters in the engine)
*/
const FILTER_ATTRS = new Set(['status', 'project', 'priority']); const FILTER_ATTRS = new Set(['status', 'project', 'priority']);
/** /**
@@ -13,7 +11,6 @@ const FILTER_ATTRS = new Set(['status', 'project', 'priority']);
/** /**
* Parse a filter string into structured tokens. * Parse a filter string into structured tokens.
* Recognizes +tag, -tag, and key:value for supported filter attributes. * Recognizes +tag, -tag, and key:value for supported filter attributes.
* Unknown tokens (like due:3d) are preserved as raw tokens for pass-through.
* @param {string} str * @param {string} str
* @returns {ParsedFilter} * @returns {ParsedFilter}
*/ */
@@ -41,7 +38,6 @@ export function parseFilterString(str) {
} }
/** /**
* Convert a parsed filter to TaskFilters for the API
* @param {ParsedFilter} parsed * @param {ParsedFilter} parsed
* @returns {import('$lib/api/types.js').TaskFilters} * @returns {import('$lib/api/types.js').TaskFilters}
*/ */
@@ -58,8 +54,7 @@ export function filterToParams(parsed) {
/** /**
* Remove a token matching a given prefix from a string. * Remove a token matching a given prefix from a string.
* Used by PropertyPills smart replace: e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:") * e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:") "buy milk"
* returns "buy milk"
* @param {string} input * @param {string} input
* @param {string} prefix * @param {string} prefix
* @returns {string} * @returns {string}
@@ -72,7 +67,6 @@ export function removeTokenByPrefix(input, prefix) {
/** /**
* Deduplicate filter tokens from user input that are already in the active filter. * Deduplicate filter tokens from user input that are already in the active filter.
* Prevents submitting "+grocer +grocer" when filter is +grocer and user also typed +grocer.
* @param {string} userInput * @param {string} userInput
* @param {string} filterStr * @param {string} filterStr
* @returns {string} * @returns {string}
+1 -9
View File
@@ -1,7 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
/** /**
* Get item from localStorage
* @param {string} key * @param {string} key
* @returns {any} * @returns {any}
*/ */
@@ -18,7 +17,6 @@ export function getItem(key) {
} }
/** /**
* Set item in localStorage
* @param {string} key * @param {string} key
* @param {any} value * @param {any} value
*/ */
@@ -32,10 +30,7 @@ export function setItem(key, value) {
} }
} }
/** /** @param {string} key */
* Remove item from localStorage
* @param {string} key
*/
export function removeItem(key) { export function removeItem(key) {
if (!browser) return; if (!browser) return;
@@ -46,9 +41,6 @@ export function removeItem(key) {
} }
} }
/**
* Clear all items
*/
export function clear() { export function clear() {
if (!browser) return; if (!browser) return;
+4 -26
View File
@@ -1,16 +1,11 @@
import { getItem, setItem } from './storage.js'; import { getItem, setItem } from './storage.js';
import { generateUUID } from './uuid.js'; import { generateUUID } from './uuid.js';
/** /** @typedef {import('$lib/api/types.js').QueuedChange} QueuedChange */
* @typedef {import('$lib/api/types.js').QueuedChange} QueuedChange
*/
const QUEUE_KEY = 'opal_sync_queue'; const QUEUE_KEY = 'opal_sync_queue';
/** /** @param {Omit<QueuedChange, 'id'|'timestamp'>} change */
* Add change to sync queue
* @param {Omit<QueuedChange, 'id'|'timestamp'>} change
*/
export function queueChange(change) { export function queueChange(change) {
const queue = getQueue(); const queue = getQueue();
@@ -23,33 +18,16 @@ export function queueChange(change) {
setItem(QUEUE_KEY, queue); setItem(QUEUE_KEY, queue);
} }
/** /** @returns {QueuedChange[]} */
* Get all queued changes
* @returns {QueuedChange[]}
*/
export function getQueue() { export function getQueue() {
return getItem(QUEUE_KEY) || []; return getItem(QUEUE_KEY) || [];
} }
/**
* Clear sync queue
*/
export function clearQueue() { export function clearQueue() {
setItem(QUEUE_KEY, []); setItem(QUEUE_KEY, []);
} }
/** /** @param {string} id */
* Get queue size
* @returns {number}
*/
export function getQueueSize() {
return getQueue().length;
}
/**
* Remove specific change from queue
* @param {string} id
*/
export function removeFromQueue(id) { export function removeFromQueue(id) {
const queue = getQueue().filter((change) => change.id !== id); const queue = getQueue().filter((change) => change.id !== id);
setItem(QUEUE_KEY, queue); setItem(QUEUE_KEY, queue);
+4 -4
View File
@@ -1,8 +1,8 @@
/** /** @returns {string} */
* Generate UUID v4
* @returns {string}
*/
export function generateUUID() { export function generateUUID() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0; const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8; const v = c === 'x' ? r : (r & 0x3) | 0x8;
-10
View File
@@ -11,9 +11,6 @@
let saving = false; let saving = false;
let error = ''; let error = '';
/**
* Save API key as manual auth
*/
async function saveApiKey() { async function saveApiKey() {
if (!apiKey.trim()) { if (!apiKey.trim()) {
error = 'API key is required'; error = 'API key is required';
@@ -24,7 +21,6 @@
error = ''; error = '';
try { try {
// Store API key as access token (for manual auth mode)
authStore.setAuth({ authStore.setAuth({
access_token: apiKey, access_token: apiKey,
refresh_token: '', refresh_token: '',
@@ -45,9 +41,6 @@
} }
} }
/**
* Logout
*/
async function logout() { async function logout() {
if ($authStore.refreshToken) { if ($authStore.refreshToken) {
try { try {
@@ -61,9 +54,6 @@
goto('/auth/login'); goto('/auth/login');
} }
/**
* Trigger manual sync
*/
async function triggerSync() { async function triggerSync() {
try { try {
await syncStore.sync(); await syncStore.sync();