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
|
* @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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 +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 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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,26 +42,20 @@ 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 }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const state = loadSyncState();
|
const state = loadSyncState();
|
||||||
const queue = getQueue();
|
const queue = getQueue();
|
||||||
|
|
||||||
let result = {
|
let result = {
|
||||||
pulled: 0,
|
pulled: 0,
|
||||||
pushed: 0,
|
pushed: 0,
|
||||||
@@ -75,8 +63,7 @@ function createSyncStore() {
|
|||||||
queued_offline: 0,
|
queued_offline: 0,
|
||||||
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 {
|
||||||
@@ -87,8 +74,7 @@ function createSyncStore() {
|
|||||||
result.errors.push(`Failed to push queue: ${error.message}`);
|
result.errors.push(`Failed to push queue: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -96,11 +82,10 @@ function createSyncStore() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
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 });
|
||||||
|
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@@ -108,7 +93,7 @@ function createSyncStore() {
|
|||||||
queueSize: 0,
|
queueSize: 0,
|
||||||
error: null
|
error: null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
update(state => ({
|
update(state => ({
|
||||||
@@ -119,10 +104,7 @@ function createSyncStore() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Update queue size
|
|
||||||
*/
|
|
||||||
updateQueueSize() {
|
updateQueueSize() {
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,23 +11,21 @@ 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) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
|
|
||||||
if (isToday(date)) return 'Today';
|
if (isToday(date)) return 'Today';
|
||||||
if (isTomorrow(date)) return 'Tomorrow';
|
if (isTomorrow(date)) return 'Tomorrow';
|
||||||
|
|
||||||
return formatDistance(date, new Date(), { addSuffix: true });
|
return formatDistance(date, new Date(), { addSuffix: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +1,12 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get item from localStorage
|
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function getItem(key) {
|
export function getItem(key) {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = localStorage.getItem(key);
|
const item = localStorage.getItem(key);
|
||||||
return item ? JSON.parse(item) : null;
|
return item ? JSON.parse(item) : null;
|
||||||
@@ -18,13 +17,12 @@ export function getItem(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set item in localStorage
|
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
* @param {any} value
|
* @param {any} value
|
||||||
*/
|
*/
|
||||||
export function setItem(key, value) {
|
export function setItem(key, value) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -32,13 +30,10 @@ 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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -46,12 +41,9 @@ export function removeItem(key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all items
|
|
||||||
*/
|
|
||||||
export function clear() {
|
export function clear() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,55 +1,33 @@
|
|||||||
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();
|
||||||
|
|
||||||
queue.push({
|
queue.push({
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
...change
|
...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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user