Internal
Public Access
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:
co-authored by
Claude Sonnet 5
parent
fc045a0c4b
commit
a6f227e931
+46
-1
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
const OFFLINE_DB_NAME = 'keepitgoing-offline';
|
||||
const OFFLINE_DB_VERSION = 1;
|
||||
const OFFLINE_DB_VERSION = 2;
|
||||
|
||||
function openOfflineDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -19,6 +19,12 @@ function openOfflineDB() {
|
||||
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);
|
||||
@@ -71,3 +77,42 @@ 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;
|
||||
}
|
||||
|
||||
+99
-60
@@ -12,74 +12,57 @@ function getCsrfToken() {
|
||||
return el ? el.value : null;
|
||||
}
|
||||
|
||||
function stripLocalFields(task) {
|
||||
const { _dirty, _pending_conflict_id, ...clean } = task;
|
||||
function stripLocalFields(record) {
|
||||
const { _dirty, _pending_conflict_id, ...clean } = record;
|
||||
return clean;
|
||||
}
|
||||
|
||||
async function runBackgroundSync() {
|
||||
const csrfToken = getCsrfToken();
|
||||
if (!csrfToken) {
|
||||
return; // Not on an authenticated page (e.g. login/register).
|
||||
}
|
||||
// server_changes.time_entries[] uses a field named "task" holding the task's
|
||||
// sync_id, but *creating* a new entry requires "task_sync_id" instead
|
||||
// (sync/views.py process_time_entry_changes). Normalize to task_sync_id
|
||||
// locally so the rest of the app only ever deals with one field name.
|
||||
function timeEntryToWire(entry) {
|
||||
return stripLocalFields(entry);
|
||||
}
|
||||
|
||||
const deviceId = await ensureDeviceId();
|
||||
const lastSyncToken = await getMeta('last_sync_token');
|
||||
const dirtyTasks = await getDirtyTasks();
|
||||
function timeEntryFromWire(serverEntry) {
|
||||
const { task, ...rest } = serverEntry;
|
||||
// duration_seconds isn't part of the sync wire format at all (a
|
||||
// pre-existing gap in TimeEntrySyncSerializer) - compute it locally so
|
||||
// the offline app's time totals are correct regardless of where an
|
||||
// entry came from.
|
||||
const durationSeconds = rest.ended_at
|
||||
? Math.round((new Date(rest.ended_at) - new Date(rest.started_at)) / 1000)
|
||||
: null;
|
||||
return { ...rest, task_sync_id: task, duration_seconds: durationSeconds };
|
||||
}
|
||||
|
||||
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: [],
|
||||
time_entries: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
} 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 conflictedSyncIds = new Set(
|
||||
(data.conflicts || [])
|
||||
.map((c) => c.local_data && c.local_data.sync_id)
|
||||
.filter(Boolean)
|
||||
// Shared merge-then-auto-resolve logic for one entity type's slice of a
|
||||
// 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".
|
||||
async function mergeAndResolveEntity({ entityType, dirtyLocal, serverRows, conflicts, csrfToken, putLocal, fromWire }) {
|
||||
const normalize = fromWire || ((r) => r);
|
||||
const relevantConflicts = conflicts.filter((c) => c.entity_type === entityType);
|
||||
const conflictedIds = new Set(
|
||||
relevantConflicts.map((c) => c.local_data && c.local_data.sync_id).filter(Boolean)
|
||||
);
|
||||
|
||||
// Merge server changes first, skipping anything about to be re-asserted
|
||||
// by a conflict resolution below (otherwise this would clobber it).
|
||||
for (const serverTask of data.server_changes.tasks) {
|
||||
if (conflictedSyncIds.has(serverTask.sync_id)) {
|
||||
for (const serverRow of serverRows) {
|
||||
const normalized = normalize(serverRow);
|
||||
if (conflictedIds.has(normalized.sync_id)) {
|
||||
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 task of dirtyTasks) {
|
||||
if (!conflictedSyncIds.has(task.sync_id)) {
|
||||
await putLocalTask({ ...task, _dirty: false, _pending_conflict_id: null });
|
||||
for (const row of dirtyLocal) {
|
||||
if (!conflictedIds.has(row.sync_id)) {
|
||||
await putLocal({ ...row, _dirty: false, _pending_conflict_id: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resolve conflicts as "local wins" - this is a low-frequency-offline
|
||||
// client, so conflicts should be rare; a resolution UI is future work.
|
||||
for (const conflict of data.conflicts || []) {
|
||||
for (const conflict of relevantConflicts) {
|
||||
const syncId = conflict.local_data && conflict.local_data.sync_id;
|
||||
if (!syncId) {
|
||||
continue;
|
||||
@@ -96,17 +79,73 @@ async function runBackgroundSync() {
|
||||
if (!resolveResponse.ok) {
|
||||
throw new Error('resolve failed');
|
||||
}
|
||||
const localTask = dirtyTasks.find((t) => t.sync_id === syncId);
|
||||
if (localTask) {
|
||||
await putLocalTask({ ...localTask, _dirty: false, _pending_conflict_id: null });
|
||||
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
|
||||
if (localRow) {
|
||||
await putLocal({ ...localRow, _dirty: false, _pending_conflict_id: null });
|
||||
}
|
||||
} catch (err) {
|
||||
const localTask = dirtyTasks.find((t) => t.sync_id === syncId);
|
||||
if (localTask) {
|
||||
await putLocalTask({ ...localTask, _dirty: true, _pending_conflict_id: conflict.id });
|
||||
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
|
||||
if (localRow) {
|
||||
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_at', new Date().toISOString());
|
||||
|
||||
+419
-31
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* KeepItGoing - Offline mini task app
|
||||
* Renders and edits tasks straight from IndexedDB while there's no network.
|
||||
* Reuses the .task-item/.task-checkbox/.priority-badge classes from
|
||||
* templates/tasks/_task_item.html for visual consistency with the real app,
|
||||
* and mirrors the dashboard's default filter/sort behavior (tasks/views.py
|
||||
* DashboardView) so offline mode isn't a degraded, unsorted, unfiltered view.
|
||||
* Reuses .task-item/.task-checkbox/.priority-badge/.task-group-label/.modal-*
|
||||
* classes from the real app's templates for visual consistency, and mirrors
|
||||
* the dashboard's default filter/sort (tasks/views.py DashboardView) and the
|
||||
* task-detail view's fields/actions (templates/tasks/_task_detail.html).
|
||||
*/
|
||||
|
||||
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 currentSort = 'due_date';
|
||||
let openTaskSyncId = null;
|
||||
let timerDisplayInterval = null;
|
||||
|
||||
/* ============================================
|
||||
Date/format helpers
|
||||
============================================ */
|
||||
|
||||
function todayLocalStr() {
|
||||
const d = new Date();
|
||||
@@ -26,13 +34,23 @@ function formatDueDate(dateStr) {
|
||||
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) {
|
||||
const today = todayLocalStr();
|
||||
if (filter === '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');
|
||||
switch (filter) {
|
||||
case 'today':
|
||||
@@ -69,7 +87,27 @@ function applySort(tasks, sort) {
|
||||
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');
|
||||
item.className = `task-item priority-${task.priority}${task.status === 'completed' ? ' completed' : ''}`;
|
||||
|
||||
@@ -80,7 +118,10 @@ function renderTaskItem(task) {
|
||||
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.addEventListener('click', () => toggleTaskComplete(task.sync_id));
|
||||
toggleForm.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleTaskComplete(task.sync_id);
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'task-content';
|
||||
@@ -90,10 +131,15 @@ function renderTaskItem(task) {
|
||||
title.textContent = task.title;
|
||||
content.appendChild(title);
|
||||
|
||||
const metaParts = [];
|
||||
if (task.due_date) {
|
||||
metaParts.push(`📅 ${formatDueDate(task.due_date)}`);
|
||||
}
|
||||
const tagChips = renderTagChips(task, tagsBySyncId);
|
||||
if (metaParts.length || tagChips) {
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'task-meta';
|
||||
meta.textContent = `📅 ${formatDueDate(task.due_date)}`;
|
||||
meta.innerHTML = metaParts.map((p) => `<span>${p}</span>`).join(' ') + tagChips;
|
||||
content.appendChild(meta);
|
||||
}
|
||||
|
||||
@@ -105,6 +151,8 @@ function renderTaskItem(task) {
|
||||
item.appendChild(content);
|
||||
item.appendChild(priorityBadge);
|
||||
|
||||
item.addEventListener('click', () => openTaskDetail(task.sync_id));
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -118,17 +166,20 @@ async function toggleTaskComplete(syncId) {
|
||||
task._dirty = true;
|
||||
await putLocalTask(task);
|
||||
renderOfflineTasks();
|
||||
if (openTaskSyncId === syncId) {
|
||||
openTaskDetail(syncId);
|
||||
}
|
||||
}
|
||||
|
||||
async function addOfflineTask(title) {
|
||||
function blankTask(overrides) {
|
||||
const now = new Date().toISOString();
|
||||
const syncId = crypto.randomUUID();
|
||||
const task = {
|
||||
return {
|
||||
id: syncId,
|
||||
sync_id: syncId,
|
||||
parent: null,
|
||||
parent_sync_id: null,
|
||||
title: title,
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
@@ -145,8 +196,12 @@ async function addOfflineTask(title) {
|
||||
updated_at: now,
|
||||
_dirty: true,
|
||||
_pending_conflict_id: null,
|
||||
...overrides,
|
||||
};
|
||||
await putLocalTask(task);
|
||||
}
|
||||
|
||||
async function addOfflineTask(title) {
|
||||
await putLocalTask(blankTask({ title }));
|
||||
renderOfflineTasks();
|
||||
}
|
||||
|
||||
@@ -155,19 +210,22 @@ async function renderOfflineTasks() {
|
||||
const emptyEl = document.getElementById('offline-empty-state');
|
||||
if (!listEl) return;
|
||||
|
||||
const allTasks = await getAllTasks();
|
||||
const nonDeleted = allTasks.filter((t) => !t.is_deleted);
|
||||
const visibleTasks = applySort(applyFilter(nonDeleted, currentFilter), currentSort);
|
||||
const [allTasks, allTags] = await Promise.all([getAllTasks(), getAllTags()]);
|
||||
const tagsBySyncId = {};
|
||||
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 = '';
|
||||
|
||||
if (visibleTasks.length === 0) {
|
||||
const lastSyncToken = await getMeta('last_sync_token');
|
||||
if (!lastSyncToken) {
|
||||
emptyEl.textContent = "No offline data yet — connect once while online to sync your tasks.";
|
||||
} else {
|
||||
emptyEl.textContent = 'No tasks here.';
|
||||
}
|
||||
emptyEl.textContent = lastSyncToken
|
||||
? 'No tasks here.'
|
||||
: "No offline data yet — connect once while online to sync your tasks.";
|
||||
emptyEl.hidden = false;
|
||||
listEl.hidden = true;
|
||||
return;
|
||||
@@ -176,18 +234,321 @@ async function renderOfflineTasks() {
|
||||
emptyEl.hidden = true;
|
||||
listEl.hidden = false;
|
||||
for (const task of visibleTasks) {
|
||||
listEl.appendChild(renderTaskItem(task));
|
||||
listEl.appendChild(renderTaskItem(task, tagsBySyncId));
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveFilterButton(filter) {
|
||||
document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => {
|
||||
const isActive = btn.dataset.filter === filter;
|
||||
btn.classList.toggle('btn-primary', isActive);
|
||||
btn.classList.toggle('btn-secondary', !isActive);
|
||||
const FILTER_TITLES = { all: 'All Tasks', today: 'Today', upcoming: 'Upcoming', overdue: 'Overdue', completed: 'Completed' };
|
||||
|
||||
function setActiveFilterNav(filter) {
|
||||
document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
|
||||
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' ? '✓' : ''}</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', () => {
|
||||
renderOfflineTasks();
|
||||
|
||||
@@ -204,10 +565,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#offline-filter-tabs button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentFilter = btn.dataset.filter;
|
||||
setActiveFilterButton(currentFilter);
|
||||
document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
currentFilter = item.dataset.filter;
|
||||
setActiveFilterNav(currentFilter);
|
||||
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
@@ -6,7 +6,7 @@ from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from tasks.models import Task
|
||||
from tasks.models import Task, TimeEntry
|
||||
from sync.models import SyncLog, SyncConflict
|
||||
|
||||
User = get_user_model()
|
||||
@@ -147,3 +147,27 @@ class SyncEndpointTests(TestCase):
|
||||
self.assertEqual(task.title, 'Server title')
|
||||
conflict.refresh_from_db()
|
||||
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')
|
||||
|
||||
@@ -474,3 +474,21 @@ def apply_conflict_data(entity_type, entity_id, data):
|
||||
tag.save()
|
||||
except Tag.DoesNotExist:
|
||||
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
|
||||
|
||||
@@ -17,9 +17,75 @@
|
||||
</script>
|
||||
</head>
|
||||
<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">
|
||||
<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;">
|
||||
<option value="due_date">Due Date (Earliest)</option>
|
||||
<option value="due_date_desc">Due Date (Latest)</option>
|
||||
@@ -28,18 +94,6 @@
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
@@ -49,6 +103,17 @@
|
||||
<p>Loading...</p>
|
||||
</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>
|
||||
|
||||
<script src="{% static 'js/offline-db.js' %}"></script>
|
||||
|
||||
+1
-1
@@ -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_TASKS_URL = '{% url "offline-tasks" %}';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user