Bring offline mode to full parity with the online dashboard

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>
This commit is contained in:
Keith Smith
2026-09-04 23:45:43 -06:00
co-authored by Claude Sonnet 5
parent fc045a0c4b
commit a6f227e931
7 changed files with 701 additions and 122 deletions
+46 -1
View File
@@ -5,7 +5,7 @@
*/ */
const OFFLINE_DB_NAME = 'keepitgoing-offline'; const OFFLINE_DB_NAME = 'keepitgoing-offline';
const OFFLINE_DB_VERSION = 1; const OFFLINE_DB_VERSION = 2;
function openOfflineDB() { function openOfflineDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -19,6 +19,12 @@ function openOfflineDB() {
if (!db.objectStoreNames.contains('meta')) { if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' }); 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.onsuccess = () => resolve(request.result);
@@ -71,3 +77,42 @@ async function getDirtyTasks() {
const tasks = await getAllTasks(); const tasks = await getAllTasks();
return tasks.filter((t) => t._dirty === true); 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;
}
+97 -58
View File
@@ -12,74 +12,57 @@ function getCsrfToken() {
return el ? el.value : null; return el ? el.value : null;
} }
function stripLocalFields(task) { function stripLocalFields(record) {
const { _dirty, _pending_conflict_id, ...clean } = task; const { _dirty, _pending_conflict_id, ...clean } = record;
return clean; return clean;
} }
async function runBackgroundSync() { // server_changes.time_entries[] uses a field named "task" holding the task's
const csrfToken = getCsrfToken(); // sync_id, but *creating* a new entry requires "task_sync_id" instead
if (!csrfToken) { // (sync/views.py process_time_entry_changes). Normalize to task_sync_id
return; // Not on an authenticated page (e.g. login/register). // locally so the rest of the app only ever deals with one field name.
function timeEntryToWire(entry) {
return stripLocalFields(entry);
} }
const deviceId = await ensureDeviceId(); function timeEntryFromWire(serverEntry) {
const lastSyncToken = await getMeta('last_sync_token'); const { task, ...rest } = serverEntry;
const dirtyTasks = await getDirtyTasks(); // duration_seconds isn't part of the sync wire format at all (a
// pre-existing gap in TimeEntrySyncSerializer) - compute it locally so
let response; // the offline app's time totals are correct regardless of where an
try { // entry came from.
response = await fetch('/api/sync/', { const durationSeconds = rest.ended_at
method: 'POST', ? Math.round((new Date(rest.ended_at) - new Date(rest.started_at)) / 1000)
headers: { : null;
'Content-Type': 'application/json', return { ...rest, task_sync_id: task, duration_seconds: durationSeconds };
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
device_id: deviceId,
last_sync_token: lastSyncToken || null,
changes: {
tasks: dirtyTasks.map(stripLocalFields),
tags: [],
time_entries: [],
},
}),
});
} catch (err) {
return; // Offline or network error - retry next time, nothing to clean up.
} }
if (!response.ok) { // Shared merge-then-auto-resolve logic for one entity type's slice of a
return; // Includes 429 throttled - retry next time. // sync response: merge server rows (skipping anything about to be
} // re-asserted by a conflict resolution), clear dirty flags for rows that
// synced cleanly, then resolve each conflict as "local wins".
const data = await response.json(); async function mergeAndResolveEntity({ entityType, dirtyLocal, serverRows, conflicts, csrfToken, putLocal, fromWire }) {
const normalize = fromWire || ((r) => r);
const conflictedSyncIds = new Set( const relevantConflicts = conflicts.filter((c) => c.entity_type === entityType);
(data.conflicts || []) const conflictedIds = new Set(
.map((c) => c.local_data && c.local_data.sync_id) relevantConflicts.map((c) => c.local_data && c.local_data.sync_id).filter(Boolean)
.filter(Boolean)
); );
// Merge server changes first, skipping anything about to be re-asserted for (const serverRow of serverRows) {
// by a conflict resolution below (otherwise this would clobber it). const normalized = normalize(serverRow);
for (const serverTask of data.server_changes.tasks) { if (conflictedIds.has(normalized.sync_id)) {
if (conflictedSyncIds.has(serverTask.sync_id)) {
continue; continue;
} }
await putLocalTask({ ...serverTask, _dirty: false, _pending_conflict_id: null }); await putLocal({ ...normalized, _dirty: false, _pending_conflict_id: null });
} }
// Clear dirty flags for rows that synced cleanly. for (const row of dirtyLocal) {
for (const task of dirtyTasks) { if (!conflictedIds.has(row.sync_id)) {
if (!conflictedSyncIds.has(task.sync_id)) { await putLocal({ ...row, _dirty: false, _pending_conflict_id: null });
await putLocalTask({ ...task, _dirty: false, _pending_conflict_id: null });
} }
} }
// Auto-resolve conflicts as "local wins" - this is a low-frequency-offline for (const conflict of relevantConflicts) {
// client, so conflicts should be rare; a resolution UI is future work.
for (const conflict of data.conflicts || []) {
const syncId = conflict.local_data && conflict.local_data.sync_id; const syncId = conflict.local_data && conflict.local_data.sync_id;
if (!syncId) { if (!syncId) {
continue; continue;
@@ -96,17 +79,73 @@ async function runBackgroundSync() {
if (!resolveResponse.ok) { if (!resolveResponse.ok) {
throw new Error('resolve failed'); throw new Error('resolve failed');
} }
const localTask = dirtyTasks.find((t) => t.sync_id === syncId); const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
if (localTask) { if (localRow) {
await putLocalTask({ ...localTask, _dirty: false, _pending_conflict_id: null }); await putLocal({ ...localRow, _dirty: false, _pending_conflict_id: null });
} }
} catch (err) { } catch (err) {
const localTask = dirtyTasks.find((t) => t.sync_id === syncId); const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
if (localTask) { if (localRow) {
await putLocalTask({ ...localTask, _dirty: true, _pending_conflict_id: conflict.id }); await putLocal({ ...localRow, _dirty: true, _pending_conflict_id: conflict.id });
} }
} }
} }
}
async function runBackgroundSync() {
const csrfToken = getCsrfToken();
if (!csrfToken) {
return; // Not on an authenticated page (e.g. login/register).
}
const deviceId = await ensureDeviceId();
const lastSyncToken = await getMeta('last_sync_token');
const dirtyTasks = await getDirtyTasks();
const dirtyTags = await getDirtyTags();
const dirtyTimeEntries = await getDirtyTimeEntries();
let response;
try {
response = await fetch('/api/sync/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
device_id: deviceId,
last_sync_token: lastSyncToken || null,
changes: {
tasks: dirtyTasks.map(stripLocalFields),
tags: dirtyTags.map(stripLocalFields),
time_entries: dirtyTimeEntries.map(timeEntryToWire),
},
}),
});
} catch (err) {
return; // Offline or network error - retry next time, nothing to clean up.
}
if (!response.ok) {
return; // Includes 429 throttled - retry next time.
}
const data = await response.json();
const conflicts = data.conflicts || [];
await mergeAndResolveEntity({
entityType: 'task', dirtyLocal: dirtyTasks, serverRows: data.server_changes.tasks,
conflicts, csrfToken, putLocal: putLocalTask,
});
await mergeAndResolveEntity({
entityType: 'tag', dirtyLocal: dirtyTags, serverRows: data.server_changes.tags,
conflicts, csrfToken, putLocal: putLocalTag,
});
await mergeAndResolveEntity({
entityType: 'time_entry', dirtyLocal: dirtyTimeEntries, serverRows: data.server_changes.time_entries,
conflicts, csrfToken, putLocal: putLocalTimeEntry, fromWire: timeEntryFromWire,
});
await setMeta('last_sync_token', data.sync_token); await setMeta('last_sync_token', data.sync_token);
await setMeta('last_sync_at', new Date().toISOString()); await setMeta('last_sync_at', new Date().toISOString());
+419 -31
View File
@@ -1,16 +1,24 @@
/** /**
* KeepItGoing - Offline mini task app * KeepItGoing - Offline mini task app
* Renders and edits tasks straight from IndexedDB while there's no network. * Renders and edits tasks straight from IndexedDB while there's no network.
* Reuses the .task-item/.task-checkbox/.priority-badge classes from * Reuses .task-item/.task-checkbox/.priority-badge/.task-group-label/.modal-*
* templates/tasks/_task_item.html for visual consistency with the real app, * classes from the real app's templates for visual consistency, and mirrors
* and mirrors the dashboard's default filter/sort behavior (tasks/views.py * the dashboard's default filter/sort (tasks/views.py DashboardView) and the
* DashboardView) so offline mode isn't a degraded, unsorted, unfiltered view. * task-detail view's fields/actions (templates/tasks/_task_detail.html).
*/ */
const PRIORITY_ORDER = { urgent: 4, high: 3, medium: 2, low: 1 }; const PRIORITY_ORDER = { urgent: 4, high: 3, medium: 2, low: 1 };
const RECURRENCE_CHOICES = ['none', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
const TAG_COLOR_PRESETS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
let currentFilter = 'all'; let currentFilter = 'all';
let currentSort = 'due_date'; let currentSort = 'due_date';
let openTaskSyncId = null;
let timerDisplayInterval = null;
/* ============================================
Date/format helpers
============================================ */
function todayLocalStr() { function todayLocalStr() {
const d = new Date(); const d = new Date();
@@ -26,13 +34,23 @@ function formatDueDate(dateStr) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} }
function formatDuration(totalSeconds) {
const seconds = Math.max(0, totalSeconds || 0);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/* ============================================
Filter/sort (mirrors DashboardView's defaults)
============================================ */
function applyFilter(tasks, filter) { function applyFilter(tasks, filter) {
const today = todayLocalStr(); const today = todayLocalStr();
if (filter === 'completed') { if (filter === 'completed') {
return tasks.filter((t) => t.status === 'completed'); return tasks.filter((t) => t.status === 'completed');
} }
// Every other filter excludes completed by default, matching the
// online dashboard's default behavior.
const notCompleted = tasks.filter((t) => t.status !== 'completed'); const notCompleted = tasks.filter((t) => t.status !== 'completed');
switch (filter) { switch (filter) {
case 'today': case 'today':
@@ -69,7 +87,27 @@ function applySort(tasks, sort) {
return sorted; return sorted;
} }
function renderTaskItem(task) { /* ============================================
Task list rendering
============================================ */
function renderTagChips(task, tagsBySyncId) {
const tagIds = task.tag_sync_ids || [];
if (tagIds.length === 0) return '';
return tagIds
.map((id) => tagsBySyncId[id])
.filter(Boolean)
.map((tag) => `<span class="task-group-label" style="background-color: ${tag.color}; color: white; font-weight: 600;">${escapeHtml(tag.name)}</span>`)
.join('');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str == null ? '' : String(str);
return div.innerHTML;
}
function renderTaskItem(task, tagsBySyncId) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `task-item priority-${task.priority}${task.status === 'completed' ? ' completed' : ''}`; item.className = `task-item priority-${task.priority}${task.status === 'completed' ? ' completed' : ''}`;
@@ -80,7 +118,10 @@ function renderTaskItem(task) {
if (task.status === 'completed') { if (task.status === 'completed') {
toggleForm.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>'; toggleForm.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
} }
toggleForm.addEventListener('click', () => toggleTaskComplete(task.sync_id)); toggleForm.addEventListener('click', (e) => {
e.stopPropagation();
toggleTaskComplete(task.sync_id);
});
const content = document.createElement('div'); const content = document.createElement('div');
content.className = 'task-content'; content.className = 'task-content';
@@ -90,10 +131,15 @@ function renderTaskItem(task) {
title.textContent = task.title; title.textContent = task.title;
content.appendChild(title); content.appendChild(title);
const metaParts = [];
if (task.due_date) { if (task.due_date) {
metaParts.push(`📅 ${formatDueDate(task.due_date)}`);
}
const tagChips = renderTagChips(task, tagsBySyncId);
if (metaParts.length || tagChips) {
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'task-meta'; meta.className = 'task-meta';
meta.textContent = `📅 ${formatDueDate(task.due_date)}`; meta.innerHTML = metaParts.map((p) => `<span>${p}</span>`).join(' ') + tagChips;
content.appendChild(meta); content.appendChild(meta);
} }
@@ -105,6 +151,8 @@ function renderTaskItem(task) {
item.appendChild(content); item.appendChild(content);
item.appendChild(priorityBadge); item.appendChild(priorityBadge);
item.addEventListener('click', () => openTaskDetail(task.sync_id));
return item; return item;
} }
@@ -118,17 +166,20 @@ async function toggleTaskComplete(syncId) {
task._dirty = true; task._dirty = true;
await putLocalTask(task); await putLocalTask(task);
renderOfflineTasks(); renderOfflineTasks();
if (openTaskSyncId === syncId) {
openTaskDetail(syncId);
}
} }
async function addOfflineTask(title) { function blankTask(overrides) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const syncId = crypto.randomUUID(); const syncId = crypto.randomUUID();
const task = { return {
id: syncId, id: syncId,
sync_id: syncId, sync_id: syncId,
parent: null, parent: null,
parent_sync_id: null, parent_sync_id: null,
title: title, title: '',
description: '', description: '',
status: 'pending', status: 'pending',
priority: 'medium', priority: 'medium',
@@ -145,8 +196,12 @@ async function addOfflineTask(title) {
updated_at: now, updated_at: now,
_dirty: true, _dirty: true,
_pending_conflict_id: null, _pending_conflict_id: null,
...overrides,
}; };
await putLocalTask(task); }
async function addOfflineTask(title) {
await putLocalTask(blankTask({ title }));
renderOfflineTasks(); renderOfflineTasks();
} }
@@ -155,19 +210,22 @@ async function renderOfflineTasks() {
const emptyEl = document.getElementById('offline-empty-state'); const emptyEl = document.getElementById('offline-empty-state');
if (!listEl) return; if (!listEl) return;
const allTasks = await getAllTasks(); const [allTasks, allTags] = await Promise.all([getAllTasks(), getAllTags()]);
const nonDeleted = allTasks.filter((t) => !t.is_deleted); const tagsBySyncId = {};
const visibleTasks = applySort(applyFilter(nonDeleted, currentFilter), currentSort); allTags.forEach((t) => { if (!t.is_deleted) tagsBySyncId[t.sync_id] = t; });
// Only top-level tasks in the main list - subtasks appear inside their
// parent's detail modal, matching the online dashboard.
const topLevel = allTasks.filter((t) => !t.is_deleted && !t.parent_sync_id);
const visibleTasks = applySort(applyFilter(topLevel, currentFilter), currentSort);
listEl.innerHTML = ''; listEl.innerHTML = '';
if (visibleTasks.length === 0) { if (visibleTasks.length === 0) {
const lastSyncToken = await getMeta('last_sync_token'); const lastSyncToken = await getMeta('last_sync_token');
if (!lastSyncToken) { emptyEl.textContent = lastSyncToken
emptyEl.textContent = "No offline data yet — connect once while online to sync your tasks."; ? 'No tasks here.'
} else { : "No offline data yet — connect once while online to sync your tasks.";
emptyEl.textContent = 'No tasks here.';
}
emptyEl.hidden = false; emptyEl.hidden = false;
listEl.hidden = true; listEl.hidden = true;
return; return;
@@ -176,18 +234,321 @@ async function renderOfflineTasks() {
emptyEl.hidden = true; emptyEl.hidden = true;
listEl.hidden = false; listEl.hidden = false;
for (const task of visibleTasks) { for (const task of visibleTasks) {
listEl.appendChild(renderTaskItem(task)); listEl.appendChild(renderTaskItem(task, tagsBySyncId));
} }
} }
function setActiveFilterButton(filter) { const FILTER_TITLES = { all: 'All Tasks', today: 'Today', upcoming: 'Upcoming', overdue: 'Overdue', completed: 'Completed' };
document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => {
const isActive = btn.dataset.filter === filter; function setActiveFilterNav(filter) {
btn.classList.toggle('btn-primary', isActive); document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
btn.classList.toggle('btn-secondary', !isActive); item.classList.toggle('active', item.dataset.filter === filter);
}); });
const titleEl = document.getElementById('offline-page-title');
if (titleEl) titleEl.textContent = FILTER_TITLES[filter] || 'All Tasks';
} }
/* ============================================
Task detail pane
(internal element ids keep the "modal-" prefix from an earlier design,
but this now renders into the real #detail-pane, matching the online
dashboard's slide-in panel instead of a popup modal.)
============================================ */
function closeTaskDetail() {
document.getElementById('detail-pane').classList.add('hidden');
document.getElementById('app').classList.add('detail-closed');
closeOfflineMobileMenus();
stopTimerDisplayInterval();
openTaskSyncId = null;
}
async function openTaskDetail(syncId) {
openTaskSyncId = syncId;
await renderTaskDetailPane();
const detailPane = document.getElementById('detail-pane');
detailPane.classList.remove('hidden');
document.getElementById('app').classList.remove('detail-closed');
if (window.innerWidth <= 1024) {
detailPane.classList.add('open');
document.getElementById('mobile-overlay').classList.add('visible');
}
}
async function renderTaskDetailPane() {
const syncId = openTaskSyncId;
if (!syncId) return;
const [allTasks, allTags, allTimeEntries] = await Promise.all([
getAllTasks(), getAllTags(), getAllTimeEntries(),
]);
const task = allTasks.find((t) => t.sync_id === syncId);
if (!task) {
closeTaskDetail();
return;
}
const tags = allTags.filter((t) => !t.is_deleted);
const subtasks = allTasks.filter((t) => !t.is_deleted && t.parent_sync_id === syncId);
const taskTimeEntries = allTimeEntries.filter((e) => !e.is_deleted && e.task_sync_id === syncId);
const totalSeconds = taskTimeEntries.reduce((sum, e) => sum + (e.duration_seconds || 0), 0);
const runningEntry = taskTimeEntries.find((e) => !e.ended_at);
const body = document.getElementById('detail-pane');
body.innerHTML = `
<div class="detail-header">
<h2 style="font-size: var(--font-lg); font-weight: 600;">Task Details</h2>
<button class="detail-close" id="detail-close-btn" aria-label="Close detail panel">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<form id="task-edit-form">
<div class="form-group">
<label class="form-label" for="modal-title">Title</label>
<input type="text" class="form-input" id="modal-title" required value="${escapeHtml(task.title)}">
</div>
<div class="form-group">
<label class="form-label" for="modal-description">Description</label>
<textarea class="form-textarea" id="modal-description" rows="3">${escapeHtml(task.description)}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="modal-status">Status</label>
<select class="form-select" id="modal-status">
<option value="pending" ${task.status === 'pending' ? 'selected' : ''}>Pending</option>
<option value="in_progress" ${task.status === 'in_progress' ? 'selected' : ''}>In Progress</option>
<option value="completed" ${task.status === 'completed' ? 'selected' : ''}>Completed</option>
<option value="cancelled" ${task.status === 'cancelled' ? 'selected' : ''}>Cancelled</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="modal-priority">Priority</label>
<select class="form-select" id="modal-priority">
<option value="low" ${task.priority === 'low' ? 'selected' : ''}>Low</option>
<option value="medium" ${task.priority === 'medium' ? 'selected' : ''}>Medium</option>
<option value="high" ${task.priority === 'high' ? 'selected' : ''}>High</option>
<option value="urgent" ${task.priority === 'urgent' ? 'selected' : ''}>Urgent</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="modal-due-date">Due Date</label>
<input type="date" class="form-input" id="modal-due-date" value="${task.due_date || ''}">
</div>
<div class="form-group">
<label class="form-label" for="modal-due-time">Due Time</label>
<input type="time" class="form-input" id="modal-due-time" value="${task.due_time || ''}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="modal-recurrence">Recurrence</label>
<select class="form-select" id="modal-recurrence">
${RECURRENCE_CHOICES.map((r) => `<option value="${r}" ${task.recurrence === r ? 'selected' : ''}>${r === 'none' ? 'None' : r.charAt(0).toUpperCase() + r.slice(1)}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label" for="modal-recurrence-end">Ends</label>
<input type="date" class="form-input" id="modal-recurrence-end" value="${task.recurrence_end_date || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<div id="modal-tag-checkboxes" style="display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-sm);">
${tags.map((tag) => `
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="modal-tag-checkbox" value="${tag.sync_id}" ${(task.tag_sync_ids || []).includes(tag.sync_id) ? 'checked' : ''}>
<span style="color: ${tag.color}">${escapeHtml(tag.name)}</span>
</label>
`).join('') || '<span class="text-muted">No tags yet.</span>'}
</div>
<div style="display: flex; gap: var(--space-sm);">
<input type="text" id="modal-new-tag-name" class="form-input" placeholder="New tag name" style="flex: 1;">
<input type="color" id="modal-new-tag-color" value="${TAG_COLOR_PRESETS[0]}" style="width: 40px; padding: 2px;">
<button type="button" class="btn btn-secondary btn-sm" id="modal-add-tag-btn">+ Tag</button>
</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Save</button>
</form>
<div style="margin-top: var(--space-lg); background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
<div style="font-size: var(--font-sm); font-weight: 500; color: var(--text-secondary); margin-bottom: var(--space-sm);">Subtasks</div>
<form id="modal-subtask-form" style="display: flex; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<input type="text" id="modal-subtask-title" class="form-input" placeholder="Add a subtask..." style="flex: 1; padding: var(--space-xs) var(--space-sm); font-size: var(--font-sm);" required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<div id="modal-subtask-list" style="display: flex; flex-direction: column; gap: var(--space-xs);">
${subtasks.length === 0 ? '<div style="font-size: var(--font-sm); color: var(--text-muted);">No subtasks</div>' : subtasks.map((st) => `
<div style="display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-xs); border-radius: var(--radius-xs); background: var(--surface);">
<button type="button" class="task-checkbox modal-subtask-toggle ${st.status === 'completed' ? 'checked' : ''}" data-sync-id="${st.sync_id}" style="width: 16px; height: 16px; font-size: 10px;">${st.status === 'completed' ? '&#10003;' : ''}</button>
<span style="font-size: var(--font-sm); ${st.status === 'completed' ? 'text-decoration: line-through; color: var(--text-muted);' : ''}">${escapeHtml(st.title)}</span>
</div>
`).join('')}
</div>
</div>
<div class="time-tracker" style="margin-top: var(--space-lg);">
<div class="time-tracker-header">
<span class="time-tracker-label">Time Spent</span>
<span class="time-tracker-total" id="modal-time-total">${formatDuration(totalSeconds)}</span>
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
${runningEntry
? `<button type="button" class="btn btn-danger btn-full btn-sm" id="modal-timer-btn" data-running="true" data-entry-sync-id="${runningEntry.sync_id}" data-started="${runningEntry.started_at}">Stop Timer</button>`
: `<button type="button" class="btn btn-secondary btn-full btn-sm" id="modal-timer-btn" data-running="false">Start Timer</button>`
}
</div>
</div>
`;
wireModalEvents(task, totalSeconds);
}
function wireModalEvents(task, baseTotalSeconds) {
document.getElementById('detail-close-btn').addEventListener('click', closeTaskDetail);
document.getElementById('task-edit-form').addEventListener('submit', (e) => {
e.preventDefault();
saveTaskModalEdits(task.sync_id);
});
document.getElementById('modal-add-tag-btn').addEventListener('click', () => {
addNewTagInline(task.sync_id);
});
document.getElementById('modal-subtask-form').addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('modal-subtask-title');
const title = input.value.trim();
if (title) {
addOfflineSubtask(task.sync_id, title);
}
});
document.querySelectorAll('.modal-subtask-toggle').forEach((btn) => {
btn.addEventListener('click', () => toggleTaskComplete(btn.dataset.syncId));
});
const timerBtn = document.getElementById('modal-timer-btn');
timerBtn.addEventListener('click', () => {
if (timerBtn.dataset.running === 'true') {
stopTimer(timerBtn.dataset.entrySyncId, task.sync_id);
} else {
startTimer(task.sync_id);
}
});
stopTimerDisplayInterval();
if (timerBtn.dataset.running === 'true') {
const startedAt = new Date(timerBtn.dataset.started).getTime();
timerDisplayInterval = setInterval(() => {
const liveSeconds = baseTotalSeconds + Math.floor((Date.now() - startedAt) / 1000);
const totalEl = document.getElementById('modal-time-total');
if (totalEl) totalEl.textContent = formatDuration(liveSeconds);
}, 1000);
}
}
function stopTimerDisplayInterval() {
if (timerDisplayInterval) {
clearInterval(timerDisplayInterval);
timerDisplayInterval = null;
}
}
async function saveTaskModalEdits(syncId) {
const tasks = await getAllTasks();
const task = tasks.find((t) => t.sync_id === syncId);
if (!task) return;
task.title = document.getElementById('modal-title').value.trim() || task.title;
task.description = document.getElementById('modal-description').value;
task.status = document.getElementById('modal-status').value;
task.priority = document.getElementById('modal-priority').value;
task.due_date = document.getElementById('modal-due-date').value || null;
task.due_time = document.getElementById('modal-due-time').value || null;
task.recurrence = document.getElementById('modal-recurrence').value;
task.recurrence_end_date = document.getElementById('modal-recurrence-end').value || null;
task.tag_sync_ids = Array.from(document.querySelectorAll('.modal-tag-checkbox:checked')).map((cb) => cb.value);
task.updated_at = new Date().toISOString();
task._dirty = true;
await putLocalTask(task);
closeTaskDetail();
renderOfflineTasks();
}
async function addNewTagInline(taskSyncId) {
const nameInput = document.getElementById('modal-new-tag-name');
const colorInput = document.getElementById('modal-new-tag-color');
const name = nameInput.value.trim();
if (!name) return;
const now = new Date().toISOString();
const syncId = crypto.randomUUID();
await putLocalTag({
id: syncId, sync_id: syncId, name, description: '', color: colorInput.value,
icon: '', sort_order: 0, is_archived: false, is_deleted: false,
created_at: now, updated_at: now, _dirty: true, _pending_conflict_id: null,
});
nameInput.value = '';
await renderTaskDetailPane();
}
async function addOfflineSubtask(parentSyncId, title) {
const tasks = await getAllTasks();
const parent = tasks.find((t) => t.sync_id === parentSyncId);
const subtask = blankTask({
title,
parent: parentSyncId,
parent_sync_id: parentSyncId,
// Subtasks inherit the parent's tags, matching tasks/views.py subtask_create.
tag_sync_ids: parent ? [...(parent.tag_sync_ids || [])] : [],
});
await putLocalTask(subtask);
await renderTaskDetailPane();
}
async function startTimer(taskSyncId) {
const running = await getRunningTimeEntry();
if (running) {
// Only one timer can run per user at a time, matching web_timer_start.
running.ended_at = new Date().toISOString();
running.duration_seconds = Math.round((new Date(running.ended_at) - new Date(running.started_at)) / 1000);
running._dirty = true;
await putLocalTimeEntry(running);
}
const now = new Date().toISOString();
const syncId = crypto.randomUUID();
await putLocalTimeEntry({
id: syncId, sync_id: syncId, task_sync_id: taskSyncId, started_at: now, ended_at: null,
duration_seconds: null, notes: '', is_deleted: false, created_at: now, updated_at: now,
_dirty: true, _pending_conflict_id: null,
});
await renderTaskDetailPane();
}
async function stopTimer(entrySyncId) {
const entries = await getAllTimeEntries();
const entry = entries.find((e) => e.sync_id === entrySyncId);
if (!entry) return;
entry.ended_at = new Date().toISOString();
entry.duration_seconds = Math.round((new Date(entry.ended_at) - new Date(entry.started_at)) / 1000);
entry.updated_at = entry.ended_at;
entry._dirty = true;
await putLocalTimeEntry(entry);
await renderTaskDetailPane();
}
/* ============================================
Init
============================================ */
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
renderOfflineTasks(); renderOfflineTasks();
@@ -204,10 +565,11 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => { document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
btn.addEventListener('click', () => { item.addEventListener('click', (e) => {
currentFilter = btn.dataset.filter; e.preventDefault();
setActiveFilterButton(currentFilter); currentFilter = item.dataset.filter;
setActiveFilterNav(currentFilter);
renderOfflineTasks(); renderOfflineTasks();
}); });
}); });
@@ -221,3 +583,29 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
}); });
/* ============================================
Sidebar / mobile chrome
(app.js isn't loaded on this self-contained offline page, so these
mirror its toggleSidebar()/closeMobileMenus()/toggleTheme() directly.)
============================================ */
function toggleOfflineSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('mobile-overlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('visible', sidebar.classList.contains('open'));
}
function closeOfflineMobileMenus() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('detail-pane').classList.remove('open');
document.getElementById('mobile-overlay').classList.remove('visible');
}
function toggleOfflineTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'light';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
+25 -1
View File
@@ -6,7 +6,7 @@ from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from tasks.models import Task from tasks.models import Task, TimeEntry
from sync.models import SyncLog, SyncConflict from sync.models import SyncLog, SyncConflict
User = get_user_model() User = get_user_model()
@@ -147,3 +147,27 @@ class SyncEndpointTests(TestCase):
self.assertEqual(task.title, 'Server title') self.assertEqual(task.title, 'Server title')
conflict.refresh_from_db() conflict.refresh_from_db()
self.assertEqual(conflict.status, 'resolved_server') self.assertEqual(conflict.status, 'resolved_server')
def test_resolve_conflict_local_applies_time_entry_data(self):
task = Task.objects.create(user=self.user, title='Timed task')
start = timezone.now() - timedelta(hours=1)
entry = TimeEntry.objects.create(user=self.user, task=task, started_at=start, notes='server notes')
conflict = SyncConflict.objects.create(
user=self.user,
entity_type='time_entry',
entity_id=entry.id,
local_data={'sync_id': str(entry.sync_id), 'notes': 'local notes'},
server_data={'notes': 'server notes'},
)
response = self.client.post(
f'/api/sync/conflicts/{conflict.id}/resolve/',
{'resolution': 'local'},
format='json',
)
self.assertEqual(response.status_code, 200)
entry.refresh_from_db()
self.assertEqual(entry.notes, 'local notes')
conflict.refresh_from_db()
self.assertEqual(conflict.status, 'resolved_local')
+18
View File
@@ -474,3 +474,21 @@ def apply_conflict_data(entity_type, entity_id, data):
tag.save() tag.save()
except Tag.DoesNotExist: except Tag.DoesNotExist:
pass pass
elif entity_type == 'time_entry':
try:
entry = TimeEntry.objects.get(id=entity_id)
started_at = data.get('started_at', entry.started_at)
if isinstance(started_at, str):
started_at = parse_datetime(started_at)
ended_at = data.get('ended_at', entry.ended_at)
if ended_at and isinstance(ended_at, str):
ended_at = parse_datetime(ended_at)
entry.started_at = started_at
entry.ended_at = ended_at
entry.notes = data.get('notes', entry.notes)
entry.save()
except TimeEntry.DoesNotExist:
pass
+79 -14
View File
@@ -17,9 +17,75 @@
</script> </script>
</head> </head>
<body> <body>
<div style="max-width: 640px; margin: 0 auto; padding: var(--space-lg);"> <div class="app-layout detail-closed" id="app">
<!-- Header (offline-tinted so it's unmistakable which mode you're in) -->
<header class="app-header" style="background-color: #d97706; color: #fff; border-bottom-color: #b45309;">
<div style="display: flex; align-items: center; gap: var(--space-md);">
<button class="sidebar-toggle" onclick="toggleOfflineSidebar()" aria-label="Toggle sidebar" style="color: #fff;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
<span class="app-brand" style="color: #fff;">KeepItGoing</span>
<span style="font-size: var(--font-sm); font-weight: 600;">⚠ Offline — changes sync automatically once you're back online</span>
</div>
<div class="app-header-actions">
<button class="theme-toggle" onclick="toggleOfflineTheme()" aria-label="Toggle theme" title="Toggle dark/light mode" style="color: #fff;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
</header>
<!-- Sidebar -->
<aside class="app-sidebar" id="sidebar">
<div class="sidebar-section">
<div class="sidebar-section-title">Filters</div>
<nav class="sidebar-nav" id="offline-filter-nav">
<a href="#" class="sidebar-item active" data-filter="all">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
All Tasks
</a>
<a href="#" class="sidebar-item" data-filter="today">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
Today
</a>
<a href="#" class="sidebar-item" data-filter="upcoming">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
Upcoming
</a>
<a href="#" class="sidebar-item" data-filter="overdue">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
</svg>
Overdue
</a>
<a href="#" class="sidebar-item" data-filter="completed">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<path d="M22 4L12 14.01l-3-3"/>
</svg>
Completed
</a>
</nav>
</div>
</aside>
<!-- Main Task Pane -->
<main class="task-pane">
<div class="task-pane-header"> <div class="task-pane-header">
<h1 class="task-pane-title">Tasks (Offline)</h1> <h1 class="task-pane-title" id="offline-page-title">All Tasks</h1>
<select id="offline-sort-select" class="form-select" style="width: auto;"> <select id="offline-sort-select" class="form-select" style="width: auto;">
<option value="due_date">Due Date (Earliest)</option> <option value="due_date">Due Date (Earliest)</option>
<option value="due_date_desc">Due Date (Latest)</option> <option value="due_date_desc">Due Date (Latest)</option>
@@ -28,18 +94,6 @@
</select> </select>
</div> </div>
<p class="text-muted" style="margin-bottom: var(--space-lg);">
You're offline. Changes you make here will sync automatically once you're back online.
</p>
<div id="offline-filter-tabs" style="display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-lg);">
<button type="button" class="btn btn-sm btn-primary" data-filter="all">All Tasks</button>
<button type="button" class="btn btn-sm btn-secondary" data-filter="today">Today</button>
<button type="button" class="btn btn-sm btn-secondary" data-filter="upcoming">Upcoming</button>
<button type="button" class="btn btn-sm btn-secondary" data-filter="overdue">Overdue</button>
<button type="button" class="btn btn-sm btn-secondary" data-filter="completed">Completed</button>
</div>
<form id="offline-add-task-form" class="quick-add"> <form id="offline-add-task-form" class="quick-add">
<input type="text" id="offline-task-title" class="quick-add-input" placeholder="Add a new task..." required> <input type="text" id="offline-task-title" class="quick-add-input" placeholder="Add a new task..." required>
<button type="submit" class="btn btn-primary">Add</button> <button type="submit" class="btn btn-primary">Add</button>
@@ -49,6 +103,17 @@
<p>Loading...</p> <p>Loading...</p>
</div> </div>
<div class="task-list" id="offline-task-list"></div> <div class="task-list" id="offline-task-list"></div>
</main>
<!-- Detail Panel -->
<aside class="detail-pane hidden" id="detail-pane">
<div class="empty-state">
<p>Select a task to view details</p>
</div>
</aside>
<!-- Mobile overlay -->
<div class="mobile-overlay" id="mobile-overlay" onclick="closeOfflineMobileMenus()"></div>
</div> </div>
<script src="{% static 'js/offline-db.js' %}"></script> <script src="{% static 'js/offline-db.js' %}"></script>
+1 -1
View File
@@ -1,4 +1,4 @@
{% load static %}const CACHE_NAME = 'keepitgoing-shell-v3'; {% load static %}const CACHE_NAME = 'keepitgoing-shell-v5';
const OFFLINE_URL = '{% url "offline" %}'; const OFFLINE_URL = '{% url "offline" %}';
const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}'; const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}';