Internal
Public Access
Fixes subtasks appearing flattened into the main task list (missing parent filter), and adds everything else needed for full offline functionality: tag chips/assignment/creation, full field editing (description, due time, recurrence presets), time tracking with a live-updating timer, and properly nested subtasks -- all queued locally and synced via the existing /api/sync/ protocol. The offline page now reuses the real dashboard's app-layout/header/ sidebar/detail-pane structure instead of a bespoke layout, with an amber-tinted header and banner so it's unmistakable which mode you're in. The detail panel mirrors _task_detail.html's fields and actions. Also fixes a real backend gap this feature depends on: apply_conflict_data() in sync/views.py silently no-op'd on time_entry conflicts. And computes duration_seconds client-side on merge, since TimeEntrySyncSerializer doesn't include it in the wire format at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
119 lines
3.7 KiB
JavaScript
119 lines
3.7 KiB
JavaScript
/**
|
|
* KeepItGoing - Offline IndexedDB helpers
|
|
* Pure local storage access for offline task data. No network calls here —
|
|
* see offline-sync.js for the /api/sync/ replay logic.
|
|
*/
|
|
|
|
const OFFLINE_DB_NAME = 'keepitgoing-offline';
|
|
const OFFLINE_DB_VERSION = 2;
|
|
|
|
function openOfflineDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(OFFLINE_DB_NAME, OFFLINE_DB_VERSION);
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains('tasks')) {
|
|
db.createObjectStore('tasks', { keyPath: 'sync_id' });
|
|
}
|
|
if (!db.objectStoreNames.contains('meta')) {
|
|
db.createObjectStore('meta', { keyPath: 'key' });
|
|
}
|
|
if (!db.objectStoreNames.contains('tags')) {
|
|
db.createObjectStore('tags', { keyPath: 'sync_id' });
|
|
}
|
|
if (!db.objectStoreNames.contains('time_entries')) {
|
|
db.createObjectStore('time_entries', { keyPath: 'sync_id' });
|
|
}
|
|
};
|
|
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
function promisifyRequest(request) {
|
|
return new Promise((resolve, reject) => {
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async function getMeta(key) {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('meta', 'readonly');
|
|
const result = await promisifyRequest(tx.objectStore('meta').get(key));
|
|
return result ? result.value : undefined;
|
|
}
|
|
|
|
async function setMeta(key, value) {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('meta', 'readwrite');
|
|
await promisifyRequest(tx.objectStore('meta').put({ key, value }));
|
|
}
|
|
|
|
async function ensureDeviceId() {
|
|
let deviceId = await getMeta('device_id');
|
|
if (!deviceId) {
|
|
deviceId = 'web-' + crypto.randomUUID();
|
|
await setMeta('device_id', deviceId);
|
|
}
|
|
return deviceId;
|
|
}
|
|
|
|
async function getAllTasks() {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('tasks', 'readonly');
|
|
return promisifyRequest(tx.objectStore('tasks').getAll());
|
|
}
|
|
|
|
async function putLocalTask(task) {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('tasks', 'readwrite');
|
|
await promisifyRequest(tx.objectStore('tasks').put(task));
|
|
}
|
|
|
|
async function getDirtyTasks() {
|
|
const tasks = await getAllTasks();
|
|
return tasks.filter((t) => t._dirty === true);
|
|
}
|
|
|
|
async function getAllTags() {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('tags', 'readonly');
|
|
return promisifyRequest(tx.objectStore('tags').getAll());
|
|
}
|
|
|
|
async function putLocalTag(tag) {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('tags', 'readwrite');
|
|
await promisifyRequest(tx.objectStore('tags').put(tag));
|
|
}
|
|
|
|
async function getDirtyTags() {
|
|
const tags = await getAllTags();
|
|
return tags.filter((t) => t._dirty === true);
|
|
}
|
|
|
|
async function getAllTimeEntries() {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('time_entries', 'readonly');
|
|
return promisifyRequest(tx.objectStore('time_entries').getAll());
|
|
}
|
|
|
|
async function putLocalTimeEntry(entry) {
|
|
const db = await openOfflineDB();
|
|
const tx = db.transaction('time_entries', 'readwrite');
|
|
await promisifyRequest(tx.objectStore('time_entries').put(entry));
|
|
}
|
|
|
|
async function getDirtyTimeEntries() {
|
|
const entries = await getAllTimeEntries();
|
|
return entries.filter((e) => e._dirty === true);
|
|
}
|
|
|
|
async function getRunningTimeEntry() {
|
|
const entries = await getAllTimeEntries();
|
|
return entries.find((e) => !e.is_deleted && !e.ended_at) || null;
|
|
}
|