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:
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
|
||||
* @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
|
||||
@@ -23,7 +23,6 @@ export async function apiRequest(endpoint, options = {}) {
|
||||
...options.headers
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (auth.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`;
|
||||
}
|
||||
@@ -34,11 +33,9 @@ export async function apiRequest(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 });
|
||||
}
|
||||
@@ -78,7 +75,6 @@ async function refreshAccessToken(refreshToken) {
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Update auth store
|
||||
authStore.setTokens(result.data);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from './client.js';
|
||||
import { apiRequest, API_BASE } from './client.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').Task} Task
|
||||
@@ -8,12 +8,8 @@ import { apiRequest } from './client.js';
|
||||
* @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[]>}
|
||||
*/
|
||||
@@ -33,20 +29,12 @@ export const tasks = {
|
||||
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get single task by UUID
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<Task>} */
|
||||
async get(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new task
|
||||
* @param {Partial<Task>} task
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {Partial<Task>} task @returns {Promise<Task>} */
|
||||
async create(task) {
|
||||
return apiRequest('/tasks', {
|
||||
method: 'POST',
|
||||
@@ -55,7 +43,6 @@ export const tasks = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Update existing task
|
||||
* @param {string} uuid
|
||||
* @param {Partial<Task>} updates
|
||||
* @returns {Promise<Task>}
|
||||
@@ -67,44 +54,27 @@ export const tasks = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete task
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<void>} */
|
||||
async delete(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete task
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<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>}
|
||||
*/
|
||||
/** @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>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<Task>} */
|
||||
async stop(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse CLI input and create task
|
||||
* @param {string} input - Raw opal CLI syntax
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
@@ -115,29 +85,20 @@ export const tasks = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* List tasks by report name
|
||||
* @param {string} reportName
|
||||
* @returns {Promise<Task[]>}
|
||||
*/
|
||||
/** @param {string} reportName @returns {Promise<Task[]>} */
|
||||
async listByReport(reportName) {
|
||||
const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`);
|
||||
return result.tasks ?? result;
|
||||
}
|
||||
};
|
||||
|
||||
// Tags API
|
||||
export const tags = {
|
||||
/**
|
||||
* List all unique tags
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
/** @returns {Promise<string[]>} */
|
||||
async list() {
|
||||
return apiRequest('/tags');
|
||||
},
|
||||
|
||||
/**
|
||||
* Add tag to task
|
||||
* @param {string} uuid
|
||||
* @param {string} tag
|
||||
* @returns {Promise<void>}
|
||||
@@ -150,7 +111,6 @@ export const tags = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove tag from task
|
||||
* @param {string} uuid
|
||||
* @param {string} tag
|
||||
* @returns {Promise<void>}
|
||||
@@ -162,21 +122,15 @@ export const tags = {
|
||||
}
|
||||
};
|
||||
|
||||
// Projects API
|
||||
export const projects = {
|
||||
/**
|
||||
* List all projects
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
/** @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[]>}
|
||||
@@ -189,7 +143,6 @@ export const sync = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
* @param {Task[]} tasks
|
||||
* @param {string} clientId
|
||||
* @returns {Promise<{processed: number, conflicts: number}>}
|
||||
@@ -202,12 +155,8 @@ export const sync = {
|
||||
}
|
||||
};
|
||||
|
||||
// Auth API
|
||||
export const auth = {
|
||||
/**
|
||||
* Get OAuth login URL
|
||||
* @returns {Promise<{url: string, state: string}>}
|
||||
*/
|
||||
/** @returns {Promise<{url: string, state: string}>} */
|
||||
async getLoginUrl() {
|
||||
const response = await fetch(`${API_BASE}/auth/login`);
|
||||
const result = await response.json();
|
||||
@@ -218,7 +167,6 @@ export const auth = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Exchange OAuth code for tokens
|
||||
* @param {string} code
|
||||
* @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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout (revoke refresh token)
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
/** @param {string} refreshToken @returns {Promise<void>} */
|
||||
async logout(refreshToken) {
|
||||
return apiRequest('/auth/logout', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
/** @type {HTMLDivElement|null} */
|
||||
let sheetEl = null;
|
||||
|
||||
// Body scroll lock — managed in afterUpdate to avoid SSR document access
|
||||
afterUpdate(() => {
|
||||
if (!mounted) return;
|
||||
if (open) {
|
||||
@@ -51,7 +50,6 @@
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
// Focus trap
|
||||
if (e.key === 'Tab' && sheetEl) {
|
||||
const focusable = sheetEl.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
@@ -115,10 +113,8 @@
|
||||
|
||||
if (dragging) {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
// Only allow dragging down (positive)
|
||||
dragOffset = Math.max(0, deltaY);
|
||||
|
||||
// Track velocity
|
||||
const now = Date.now();
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0 && lastY !== null) {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script>
|
||||
import { activeFilter, setFilter } from '$lib/stores/filters.js';
|
||||
|
||||
/**
|
||||
* Remove a single token from the active filter string.
|
||||
* If it's the last token, clear the entire filter.
|
||||
* @param {string} token
|
||||
*/
|
||||
/** @param {string} token */
|
||||
function removeToken(token) {
|
||||
if (!$activeFilter) return;
|
||||
const tokens = $activeFilter.trim().split(/\s+/);
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
/** @type {FilterModal} */
|
||||
let filterModal;
|
||||
|
||||
/** Map backend report names to display labels */
|
||||
const reportLabels = /** @type {Record<string, string>} */ ({
|
||||
list: 'Pending',
|
||||
next: 'Next',
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || loading) return;
|
||||
|
||||
// Merge user input with active filter, deduplicating tokens
|
||||
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
|
||||
|
||||
try {
|
||||
@@ -61,22 +60,17 @@
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor position
|
||||
* @param {string} text
|
||||
*/
|
||||
/** @param {string} text */
|
||||
function insertAtCursor(text) {
|
||||
if (!inputEl) return;
|
||||
const start = inputEl.selectionStart ?? 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 insert = (needsSpace ? ' ' : '') + text;
|
||||
|
||||
value = value.slice(0, start) + insert + value.slice(end);
|
||||
|
||||
// Restore focus and cursor position after the inserted text
|
||||
const newPos = start + insert.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (inputEl) {
|
||||
@@ -86,18 +80,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current input value (for PropertyPills smart replace)
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @returns {string} */
|
||||
export function getInputValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the input value (for PropertyPills smart replace)
|
||||
* @param {string} newValue
|
||||
*/
|
||||
/** @param {string} newValue */
|
||||
export function setInputValue(newValue) {
|
||||
value = newValue;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
*/
|
||||
export let onInsert;
|
||||
|
||||
/** Current input value for smart replace */
|
||||
export let inputValue = '';
|
||||
|
||||
/** Callback to update the input value when doing smart replace */
|
||||
export let onInputChange = /** @type {(value: string) => void} */ (() => {});
|
||||
|
||||
export let visible = false;
|
||||
@@ -29,7 +26,6 @@
|
||||
* @param {{ text: string, isTag: boolean }} pill
|
||||
*/
|
||||
function handleInsert(pill) {
|
||||
// Tags are always additive — no smart replace
|
||||
if (!pill.isTag && inputValue) {
|
||||
const prefix = pill.text; // e.g. "due:"
|
||||
const cleaned = removeTokenByPrefix(inputValue, prefix);
|
||||
|
||||
@@ -49,12 +49,10 @@
|
||||
const deltaY = touch.clientY - startY;
|
||||
|
||||
if (!locked && !swiping) {
|
||||
// Angle-based lock-in: horizontal must dominate
|
||||
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
|
||||
swiping = true;
|
||||
locked = true;
|
||||
} else if (Math.abs(deltaY) > 10) {
|
||||
// Vertical scroll — abort
|
||||
startX = null;
|
||||
startY = null;
|
||||
return;
|
||||
@@ -74,14 +72,12 @@
|
||||
}
|
||||
|
||||
if (offsetX >= THRESHOLD) {
|
||||
// Right swipe — complete (row collapses)
|
||||
completed = true;
|
||||
offsetX = window.innerWidth;
|
||||
setTimeout(() => {
|
||||
onSwipeRight();
|
||||
}, 200);
|
||||
} else if (offsetX <= -THRESHOLD) {
|
||||
// Left swipe — start/stop (row stays)
|
||||
triggered = true;
|
||||
offsetX = -window.innerWidth;
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
/** @type {() => void} */
|
||||
export let onClose;
|
||||
|
||||
// Editing state — only one field at a time
|
||||
/** @type {string|null} */
|
||||
let editingField = null;
|
||||
|
||||
@@ -37,7 +36,6 @@
|
||||
let editProject = '';
|
||||
let editTagInput = '';
|
||||
|
||||
// Recurring instance: remember user choice for this sheet session
|
||||
/** @type {'instance'|'template'|null} */
|
||||
let recurringChoice = null;
|
||||
|
||||
@@ -67,17 +65,12 @@
|
||||
0: 3
|
||||
});
|
||||
|
||||
/**
|
||||
* Format a unix timestamp as yyyy-MM-dd for date input
|
||||
* @param {number} ts
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @param {number} ts @returns {string} */
|
||||
function tsToDateValue(ts) {
|
||||
return format(fromUnix(ts), 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field edit on a recurring instance needs the instance/template prompt
|
||||
* @param {string} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
@@ -124,14 +117,10 @@
|
||||
|
||||
function saveCurrentEdit() {
|
||||
if (!editingField) return;
|
||||
// Each field handles its own save via its input events
|
||||
editingField = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UUID to update based on recurring choice
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @returns {string} */
|
||||
function getTargetUuid() {
|
||||
if (recurringChoice === 'template' && task.parent_uuid) {
|
||||
return task.parent_uuid;
|
||||
@@ -194,7 +183,6 @@
|
||||
async function removeTag(tag) {
|
||||
try {
|
||||
await tagsAPI.remove(getTargetUuid(), tag);
|
||||
// Optimistic: update local
|
||||
task = { ...task, tags: task.tags.filter(t => t !== tag) };
|
||||
} catch (error) {
|
||||
console.error('Failed to remove tag:', error);
|
||||
@@ -207,7 +195,6 @@
|
||||
editTagInput = '';
|
||||
try {
|
||||
await tagsAPI.add(getTargetUuid(), tag);
|
||||
// Optimistic: update local
|
||||
task = { ...task, tags: [...task.tags, tag] };
|
||||
} catch (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) {
|
||||
const field = pendingEditField;
|
||||
pendingEditField = null;
|
||||
if (field === 'priority-cycle') {
|
||||
// Direct cycle
|
||||
const next = priorityCycle[task.priority] ?? 1;
|
||||
onUpdate(getTargetUuid(), { priority: /** @type {import('$lib/api/types.js').TaskPriority} */ (next) });
|
||||
} 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 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -19,10 +19,16 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
|
||||
const STORAGE_KEY = 'opal_auth';
|
||||
const DEV_MODE = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Load auth state from localStorage
|
||||
* @returns {AuthState}
|
||||
*/
|
||||
/** @type {AuthState} */
|
||||
const EMPTY_STATE = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
|
||||
/** @returns {AuthState} */
|
||||
function loadAuth() {
|
||||
// In dev mode, auto-authenticate with a dev user.
|
||||
// API requests still go to the real backend (which runs with auth disabled).
|
||||
@@ -36,21 +42,11 @@ function loadAuth() {
|
||||
};
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
return {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
if (!browser) return EMPTY_STATE;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -60,28 +56,21 @@ function loadAuth() {
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth store
|
||||
*/
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable(loadAuth());
|
||||
|
||||
/** Persist state to localStorage */
|
||||
function persist(/** @type {AuthState} */ state) {
|
||||
if (browser) setItem(STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Set authentication tokens
|
||||
* @param {AuthTokens} tokens
|
||||
*/
|
||||
/** @param {AuthTokens} tokens */
|
||||
setTokens(tokens) {
|
||||
update(state => {
|
||||
const newState = {
|
||||
@@ -91,33 +80,21 @@ function createAuthStore() {
|
||||
expiresAt: tokens.expires_at,
|
||||
isAuthenticated: true
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
setItem(STORAGE_KEY, newState);
|
||||
}
|
||||
|
||||
persist(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set user info
|
||||
* @param {User} user
|
||||
*/
|
||||
/** @param {User} user */
|
||||
setUser(user) {
|
||||
update(state => {
|
||||
const newState = { ...state, user };
|
||||
if (browser) {
|
||||
setItem(STORAGE_KEY, newState);
|
||||
}
|
||||
persist(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set full auth data (tokens + user)
|
||||
* @param {AuthTokens & {user: User}} data
|
||||
*/
|
||||
/** @param {AuthTokens & {user: User}} data */
|
||||
setAuth(data) {
|
||||
const newState = {
|
||||
accessToken: data.access_token,
|
||||
@@ -126,28 +103,13 @@ function createAuthStore() {
|
||||
user: data.user,
|
||||
isAuthenticated: true
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
setItem(STORAGE_KEY, newState);
|
||||
}
|
||||
|
||||
persist(newState);
|
||||
set(newState);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear auth (logout)
|
||||
*/
|
||||
clear() {
|
||||
if (browser) {
|
||||
removeItem(STORAGE_KEY);
|
||||
}
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
});
|
||||
if (browser) removeItem(STORAGE_KEY);
|
||||
set(EMPTY_STATE);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ const RECENT_KEY = 'opal_recent_filters';
|
||||
const MAX_RECENT = 8;
|
||||
|
||||
/**
|
||||
* Create a localStorage-backed writable store
|
||||
* @template T
|
||||
* @param {string} key
|
||||
* @param {T} fallback
|
||||
@@ -21,10 +20,7 @@ function persisted(key, fallback) {
|
||||
export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null));
|
||||
export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([]));
|
||||
|
||||
/**
|
||||
* Set the active filter and add it to recents
|
||||
* @param {string} str
|
||||
*/
|
||||
/** @param {string} str */
|
||||
export function setFilter(str) {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) {
|
||||
@@ -39,17 +35,11 @@ export function setFilter(str) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active filter
|
||||
*/
|
||||
export function clearFilter() {
|
||||
activeFilter.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific entry from recents
|
||||
* @param {string} str
|
||||
*/
|
||||
/** @param {string} str */
|
||||
export function removeRecent(str) {
|
||||
recentFilters.update(recents => recents.filter(r => r !== str));
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ import { generateUUID } from '$lib/utils/uuid.js';
|
||||
const SYNC_STATE_KEY = 'opal_sync_state';
|
||||
const CLIENT_ID_KEY = 'opal_client_id';
|
||||
|
||||
/**
|
||||
* Get or create client ID
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @returns {string} */
|
||||
function getClientId() {
|
||||
let clientId = getItem(CLIENT_ID_KEY);
|
||||
if (!clientId) {
|
||||
@@ -33,10 +30,7 @@ function getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sync state
|
||||
* @returns {SyncState}
|
||||
*/
|
||||
/** @returns {SyncState} */
|
||||
function loadSyncState() {
|
||||
const stored = getItem(SYNC_STATE_KEY);
|
||||
return {
|
||||
@@ -48,19 +42,13 @@ function loadSyncState() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sync store
|
||||
*/
|
||||
function createSyncStore() {
|
||||
const { subscribe, set, update } = writable(loadSyncState());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Perform sync
|
||||
* @returns {Promise<SyncResult>}
|
||||
*/
|
||||
/** @returns {Promise<SyncResult>} */
|
||||
async sync() {
|
||||
update(state => ({ ...state, status: 'syncing', error: null }));
|
||||
|
||||
@@ -76,7 +64,6 @@ function createSyncStore() {
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Push queued changes
|
||||
if (queue.length > 0) {
|
||||
const tasks = queue.map(q => q.data);
|
||||
try {
|
||||
@@ -88,7 +75,6 @@ function createSyncStore() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pull changes from server
|
||||
try {
|
||||
const changes = await syncAPI.getChanges(state.lastSync, state.clientId);
|
||||
result.pulled = changes.length;
|
||||
@@ -97,7 +83,6 @@ function createSyncStore() {
|
||||
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 });
|
||||
|
||||
@@ -120,9 +105,6 @@ function createSyncStore() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update queue size
|
||||
*/
|
||||
updateQueueSize() {
|
||||
update(state => ({
|
||||
...state,
|
||||
|
||||
@@ -7,17 +7,22 @@ import { queueChange } from '$lib/utils/sync-queue.js';
|
||||
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create tasks store
|
||||
*/
|
||||
function createTasksStore() {
|
||||
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 {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Load tasks by report name
|
||||
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
|
||||
*/
|
||||
async loadReport(reportName) {
|
||||
@@ -31,7 +36,6 @@ function createTasksStore() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse CLI input and create a task
|
||||
* @param {string} input - Raw opal CLI syntax
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
@@ -47,10 +51,7 @@ function createTasksStore() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all tasks from API
|
||||
* @param {TaskFilters} [filters]
|
||||
*/
|
||||
/** @param {TaskFilters} [filters] */
|
||||
async load(filters = {}) {
|
||||
try {
|
||||
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
|
||||
*/
|
||||
async add(task) {
|
||||
@@ -71,101 +72,55 @@ function createTasksStore() {
|
||||
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
|
||||
queueChange({ type: 'create', task_uuid: task.uuid, data: task });
|
||||
update(tasks => [...tasks, task]);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task (optimistic update)
|
||||
* Optimistic update — queues offline on failure.
|
||||
* @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;
|
||||
});
|
||||
updateByUuid(uuid, t => ({ ...t, ...updates, modified: Date.now() / 1000 }));
|
||||
|
||||
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;
|
||||
});
|
||||
updateByUuid(uuid, () => updated);
|
||||
} catch (error) {
|
||||
// Queue for offline sync
|
||||
queueChange({
|
||||
type: 'update',
|
||||
task_uuid: uuid,
|
||||
data: updates
|
||||
});
|
||||
queueChange({ type: 'update', task_uuid: uuid, data: updates });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete task
|
||||
* @param {string} uuid
|
||||
*/
|
||||
/** @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: {}
|
||||
});
|
||||
queueChange({ type: 'delete', task_uuid: uuid, data: {} });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start task timer (optimistic)
|
||||
* @param {string} uuid
|
||||
*/
|
||||
/** @param {string} uuid */
|
||||
async startTask(uuid) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
update(tasks => tasks.map(t =>
|
||||
t.uuid === uuid ? { ...t, start: now } : t
|
||||
));
|
||||
updateByUuid(uuid, t => ({ ...t, start: now }));
|
||||
try {
|
||||
const updated = await tasksAPI.start(uuid);
|
||||
update(tasks => tasks.map(t =>
|
||||
t.uuid === uuid ? updated : t
|
||||
));
|
||||
updateByUuid(uuid, () => updated);
|
||||
} catch (error) {
|
||||
update(tasks => tasks.map(t =>
|
||||
t.uuid === uuid ? { ...t, start: null } : t
|
||||
));
|
||||
updateByUuid(uuid, t => ({ ...t, start: null }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop task timer (optimistic)
|
||||
* @param {string} uuid
|
||||
*/
|
||||
/** @param {string} uuid */
|
||||
async stopTask(uuid) {
|
||||
/** @type {number|null} */
|
||||
let prevStart = null;
|
||||
@@ -178,21 +133,14 @@ function createTasksStore() {
|
||||
}));
|
||||
try {
|
||||
const updated = await tasksAPI.stop(uuid);
|
||||
update(tasks => tasks.map(t =>
|
||||
t.uuid === uuid ? updated : t
|
||||
));
|
||||
updateByUuid(uuid, () => updated);
|
||||
} catch (error) {
|
||||
update(tasks => tasks.map(t =>
|
||||
t.uuid === uuid ? { ...t, start: prevStart } : t
|
||||
));
|
||||
updateByUuid(uuid, t => ({ ...t, start: prevStart }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete task
|
||||
* @param {string} uuid
|
||||
*/
|
||||
/** @param {string} uuid */
|
||||
async complete(uuid) {
|
||||
try {
|
||||
await tasksAPI.complete(uuid);
|
||||
@@ -211,7 +159,6 @@ function createTasksStore() {
|
||||
|
||||
export const tasksStore = createTasksStore();
|
||||
|
||||
// Derived stores for filtered views
|
||||
export const pendingTasks = derived(
|
||||
tasksStore,
|
||||
$tasks => $tasks.filter(t => t.status === 'P')
|
||||
|
||||
@@ -11,10 +11,7 @@ const DEFAULT_THEME = 'obsidian';
|
||||
/** @type {ThemeName[]} */
|
||||
export const THEMES = ['obsidian', 'paper', 'midnight'];
|
||||
|
||||
/**
|
||||
* Read stored theme, falling back to default
|
||||
* @returns {ThemeName}
|
||||
*/
|
||||
/** @returns {ThemeName} */
|
||||
function getInitial() {
|
||||
if (!browser) return DEFAULT_THEME;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -27,7 +24,6 @@ function getInitial() {
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable(getInitial());
|
||||
|
||||
/** Apply theme to the document */
|
||||
function apply(/** @type {ThemeName} */ theme) {
|
||||
if (browser) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
@@ -35,7 +31,6 @@ function createThemeStore() {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply on every change
|
||||
subscribe(apply);
|
||||
|
||||
return {
|
||||
@@ -44,7 +39,6 @@ function createThemeStore() {
|
||||
set(theme) {
|
||||
set(theme);
|
||||
},
|
||||
/** Cycle to the next theme */
|
||||
cycle() {
|
||||
update(current => {
|
||||
const idx = THEMES.indexOf(current);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { format, formatDistance, isToday, isTomorrow, isPast } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Format Unix timestamp to readable date
|
||||
* @param {number|null} timestamp - Unix timestamp (seconds)
|
||||
* @param {string} formatStr - date-fns format 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
|
||||
* @param {number|null} timestamp - Unix timestamp (seconds)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRelative(timestamp) {
|
||||
@@ -27,8 +25,7 @@ export function formatRelative(timestamp) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if timestamp is overdue
|
||||
* @param {number|null} timestamp
|
||||
* @param {number|null} timestamp - Unix timestamp (seconds)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOverdue(timestamp) {
|
||||
@@ -36,20 +33,12 @@ export function isOverdue(timestamp) {
|
||||
return isPast(new Date(timestamp * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Date object to Unix timestamp
|
||||
* @param {Date} date
|
||||
* @returns {number}
|
||||
*/
|
||||
/** @param {Date} date @returns {number} */
|
||||
export function toUnix(date) {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Unix timestamp to Date object
|
||||
* @param {number} timestamp
|
||||
* @returns {Date}
|
||||
*/
|
||||
/** @param {number} timestamp @returns {Date} */
|
||||
export function fromUnix(timestamp) {
|
||||
return new Date(timestamp * 1000);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* Valid filter attribute keys (these actually work as query filters in the engine)
|
||||
*/
|
||||
/** Valid filter attribute keys that work as query filters in the engine */
|
||||
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.
|
||||
* 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
|
||||
* @returns {ParsedFilter}
|
||||
*/
|
||||
@@ -41,7 +38,6 @@ export function parseFilterString(str) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed filter to TaskFilters for the API
|
||||
* @param {ParsedFilter} parsed
|
||||
* @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.
|
||||
* Used by PropertyPills smart replace: e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:")
|
||||
* returns "buy milk"
|
||||
* e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:") → "buy milk"
|
||||
* @param {string} input
|
||||
* @param {string} prefix
|
||||
* @returns {string}
|
||||
@@ -72,7 +67,6 @@ export function removeTokenByPrefix(input, prefix) {
|
||||
|
||||
/**
|
||||
* 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} filterStr
|
||||
* @returns {string}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Get item from localStorage
|
||||
* @param {string} key
|
||||
* @returns {any}
|
||||
*/
|
||||
@@ -18,7 +17,6 @@ export function getItem(key) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
@@ -32,10 +30,7 @@ export function setItem(key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from localStorage
|
||||
* @param {string} key
|
||||
*/
|
||||
/** @param {string} key */
|
||||
export function removeItem(key) {
|
||||
if (!browser) return;
|
||||
|
||||
@@ -46,9 +41,6 @@ export function removeItem(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items
|
||||
*/
|
||||
export function clear() {
|
||||
if (!browser) return;
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { getItem, setItem } from './storage.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';
|
||||
|
||||
/**
|
||||
* Add change to sync queue
|
||||
* @param {Omit<QueuedChange, 'id'|'timestamp'>} change
|
||||
*/
|
||||
/** @param {Omit<QueuedChange, 'id'|'timestamp'>} change */
|
||||
export function queueChange(change) {
|
||||
const queue = getQueue();
|
||||
|
||||
@@ -23,33 +18,16 @@ export function queueChange(change) {
|
||||
setItem(QUEUE_KEY, queue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queued changes
|
||||
* @returns {QueuedChange[]}
|
||||
*/
|
||||
/** @returns {QueuedChange[]} */
|
||||
export function getQueue() {
|
||||
return getItem(QUEUE_KEY) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear sync queue
|
||||
*/
|
||||
export function clearQueue() {
|
||||
setItem(QUEUE_KEY, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue size
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getQueueSize() {
|
||||
return getQueue().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific change from queue
|
||||
* @param {string} id
|
||||
*/
|
||||
/** @param {string} id */
|
||||
export function removeFromQueue(id) {
|
||||
const queue = getQueue().filter((change) => change.id !== id);
|
||||
setItem(QUEUE_KEY, queue);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Generate UUID v4
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @returns {string} */
|
||||
export function generateUUID() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
let saving = false;
|
||||
let error = '';
|
||||
|
||||
/**
|
||||
* Save API key as manual auth
|
||||
*/
|
||||
async function saveApiKey() {
|
||||
if (!apiKey.trim()) {
|
||||
error = 'API key is required';
|
||||
@@ -24,7 +21,6 @@
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Store API key as access token (for manual auth mode)
|
||||
authStore.setAuth({
|
||||
access_token: apiKey,
|
||||
refresh_token: '',
|
||||
@@ -45,9 +41,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async function logout() {
|
||||
if ($authStore.refreshToken) {
|
||||
try {
|
||||
@@ -61,9 +54,6 @@
|
||||
goto('/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual sync
|
||||
*/
|
||||
async function triggerSync() {
|
||||
try {
|
||||
await syncStore.sync();
|
||||
|
||||
Reference in New Issue
Block a user