Files
KeepItGoingServer/static/js/app.js
T
Keith SmithandClaude Sonnet 5 ae9ab596bb Refresh the page automatically after a pending offline sync completes
The real dashboard is server-rendered before any client JS runs, so
the first page load after reconnecting always reflects the pre-sync
database state -- the background sync then completes silently, but
nothing told that already-rendered page to update. A task created
offline would appear to vanish until the next navigation happened to
load fresh data. runBackgroundSync() now reports whether it pushed
pending changes, and both places that trigger it automatically reload
the page when it did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:05:27 -06:00

882 lines
27 KiB
JavaScript

/**
* KeepItGoing - Main Application JavaScript
* Handles theme toggle, task selection, mobile menus, and AJAX operations
*/
document.addEventListener('DOMContentLoaded', function() {
initTheme();
initTaskSelection();
initTimerDisplays();
registerServiceWorker();
maybeBackgroundSync();
});
window.addEventListener('online', async function() {
const pushedChanges = await runBackgroundSync();
if (pushedChanges) {
window.location.reload();
}
});
/* ============================================
Service Worker
============================================ */
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
}
/* ============================================
Push Notifications
============================================ */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function subscribeToPush(vapidPublicKey) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications are not supported in this browser.');
}
if (!vapidPublicKey) {
throw new Error('Push notifications are not configured on this server.');
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission was not granted.');
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const response = await fetch('/api/users/devices/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
platform: 'web',
token: JSON.stringify(subscription),
device_name: navigator.userAgent.slice(0, 255),
}),
});
if (!response.ok) {
throw new Error('Failed to register push subscription with the server.');
}
}
function handleProfileFormSubmit(event, form) {
const pushCheckbox = form.querySelector('[name=push_notifications]');
if (!pushCheckbox || !pushCheckbox.checked) {
return;
}
// preventDefault() must happen synchronously (before any await) or the
// browser will have already submitted the form by the time we get to it.
event.preventDefault();
enablePushThenSubmitProfile(form, pushCheckbox);
}
async function enablePushThenSubmitProfile(form, pushCheckbox) {
try {
if ('serviceWorker' in navigator && 'PushManager' in window) {
const registration = await navigator.serviceWorker.getRegistration();
const existing = registration && await registration.pushManager.getSubscription();
if (!existing) {
await subscribeToPush(pushCheckbox.dataset.vapidPublicKey);
}
}
} catch (err) {
alert('Could not enable push notifications: ' + err.message);
pushCheckbox.checked = false;
}
form.submit();
}
/* ============================================
Theme Toggle
============================================ */
function initTheme() {
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else if (systemDark) {
setTheme('dark');
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
// Update theme toggle icon
const lightIcon = document.getElementById('theme-icon-light');
const darkIcon = document.getElementById('theme-icon-dark');
if (lightIcon && darkIcon) {
if (theme === 'dark') {
lightIcon.style.display = 'none';
darkIcon.style.display = 'block';
} else {
lightIcon.style.display = 'block';
darkIcon.style.display = 'none';
}
}
}
/* ============================================
Task Selection
============================================ */
function initTaskSelection() {
// Add click handlers to task items
const taskItems = document.querySelectorAll('.task-item');
taskItems.forEach(item => {
item.addEventListener('click', function(e) {
// Don't select if clicking checkbox form
if (e.target.closest('.task-toggle')) {
return;
}
const taskId = this.dataset.taskId;
if (taskId) {
selectTask(taskId);
}
});
});
}
function selectTask(taskId) {
// Update URL without full page reload
const url = new URL(window.location);
url.searchParams.set('selected', taskId);
window.history.pushState({}, '', url);
// Update visual selection
document.querySelectorAll('.task-item').forEach(item => {
item.classList.remove('selected');
});
const selectedItem = document.querySelector(`.task-item[data-task-id="${taskId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
}
// Load task detail via AJAX
loadTaskDetail(taskId);
}
function loadTaskDetail(taskId) {
const detailPane = document.getElementById('detail-pane');
const appLayout = document.getElementById('app');
if (!detailPane) return;
// Show loading state
detailPane.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
detailPane.classList.remove('hidden');
if (appLayout) {
appLayout.classList.remove('detail-closed');
}
// On mobile, show detail pane
if (window.innerWidth <= 1024) {
detailPane.classList.add('open');
document.getElementById('mobile-overlay').classList.add('visible');
}
// Fetch task detail HTML
fetch(`/tasks/${taskId}/detail-partial/`)
.then(response => {
if (!response.ok) throw new Error('Failed to load task');
return response.text();
})
.then(html => {
detailPane.innerHTML = html;
})
.catch(error => {
console.error('Error loading task detail:', error);
detailPane.innerHTML = `
<div class="empty-state">
<p class="text-danger">Failed to load task details</p>
<p class="text-muted">Please refresh the page</p>
</div>
`;
});
}
function closeDetail() {
const detailPane = document.getElementById('detail-pane');
const appLayout = document.getElementById('app');
if (detailPane) {
detailPane.classList.add('hidden');
detailPane.classList.remove('open');
}
if (appLayout) {
appLayout.classList.add('detail-closed');
}
// Clear selection
document.querySelectorAll('.task-item').forEach(item => {
item.classList.remove('selected');
});
// Update URL
const url = new URL(window.location);
url.searchParams.delete('selected');
window.history.pushState({}, '', url);
// Close mobile overlay
closeMobileMenus();
}
/* ============================================
Custom Recurrence Builder
============================================ */
function toggleCustomRecurrenceBuilder(selectEl) {
const form = selectEl.closest('form');
const builder = form.querySelector('.custom-recurrence-builder');
if (builder) {
builder.classList.toggle('hidden', selectEl.value !== 'custom');
}
}
function toggleCustomFreqPanel(radioEl) {
const builder = radioEl.closest('.custom-recurrence-builder');
const weeklyPanel = builder.querySelector('.custom-weekly-panel');
const monthlyPanel = builder.querySelector('.custom-monthly-panel');
weeklyPanel.classList.toggle('hidden', radioEl.value !== 'weekly');
monthlyPanel.classList.toggle('hidden', radioEl.value !== 'monthly');
}
function assembleCustomRecurrenceRule(formEl) {
const recurrenceSelect = formEl.querySelector('[name="recurrence"]');
const ruleInput = formEl.querySelector('[name="recurrence_rule"]');
if (!recurrenceSelect || !ruleInput) {
return true;
}
if (recurrenceSelect.value !== 'custom') {
ruleInput.value = '';
return true;
}
const builder = formEl.querySelector('.custom-recurrence-builder');
if (!builder) {
return true;
}
const freqMode = builder.querySelector('.custom-freq-mode:checked');
const freq = freqMode ? freqMode.value : 'weekly';
if (freq === 'weekly') {
const panel = builder.querySelector('.custom-weekly-panel');
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
const days = Array.from(panel.querySelectorAll('.custom-weekday:checked')).map(cb => cb.value);
if (days.length === 0) {
ruleInput.value = '';
return true;
}
ruleInput.value = `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${days.join(',')}`;
} else {
const panel = builder.querySelector('.custom-monthly-panel');
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
const monthlyMode = panel.querySelector('.custom-monthly-mode:checked');
const mode = monthlyMode ? monthlyMode.value : 'day';
if (mode === 'day') {
const day = parseInt(panel.querySelector('.custom-bymonthday').value, 10) || 1;
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${day}`;
} else {
const ordinal = panel.querySelector('.custom-nth-ordinal').value;
const weekday = panel.querySelector('.custom-nth-weekday').value;
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${ordinal}${weekday}`;
}
}
return true;
}
/* ============================================
Mobile Navigation
============================================ */
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('mobile-overlay');
if (sidebar) {
sidebar.classList.toggle('open');
}
if (overlay) {
overlay.classList.toggle('visible', sidebar.classList.contains('open'));
}
}
function closeMobileMenus() {
const sidebar = document.getElementById('sidebar');
const detailPane = document.getElementById('detail-pane');
const overlay = document.getElementById('mobile-overlay');
if (sidebar) {
sidebar.classList.remove('open');
}
if (detailPane) {
detailPane.classList.remove('open');
}
if (overlay) {
overlay.classList.remove('visible');
}
}
/* ============================================
Timer Displays
============================================ */
function initTimerDisplays() {
// Handle running timer in header/dashboard
const runningTimer = document.getElementById('running-timer');
if (runningTimer) {
const startedAt = new Date(runningTimer.dataset.started);
updateTimerDisplay(runningTimer, startedAt);
setInterval(() => updateTimerDisplay(runningTimer, startedAt), 1000);
}
}
function updateTimerDisplay(element, startedAt) {
const now = new Date();
const diff = Math.floor((now - startedAt) / 1000);
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
const seconds = (diff % 60).toString().padStart(2, '0');
element.textContent = `${hours}:${minutes}:${seconds}`;
}
/* ============================================
Utility Functions
============================================ */
function formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
}
function formatRelativeDate(dateString) {
const date = new Date(dateString);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dateOnly = new Date(date);
dateOnly.setHours(0, 0, 0, 0);
if (dateOnly.getTime() === today.getTime()) {
return 'Today';
} else if (dateOnly.getTime() === tomorrow.getTime()) {
return 'Tomorrow';
} else if (dateOnly.getTime() === yesterday.getTime()) {
return 'Yesterday';
} else {
return date.toLocaleDateString();
}
}
/* ============================================
Handle Browser Back/Forward
============================================ */
window.addEventListener('popstate', function(e) {
const url = new URL(window.location);
const selectedTaskId = url.searchParams.get('selected');
if (selectedTaskId) {
// Show the selected task
document.querySelectorAll('.task-item').forEach(item => {
item.classList.remove('selected');
});
const selectedItem = document.querySelector(`.task-item[data-task-id="${selectedTaskId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
}
loadTaskDetail(selectedTaskId);
} else {
// Close detail panel
closeDetail();
}
});
/* ============================================
Group Modal
============================================ */
let currentGroupId = null;
let selectedColor = '#3b82f6';
document.addEventListener('DOMContentLoaded', function() {
initGroupModal();
});
function initGroupModal() {
// Setup backdrop click to close
const backdrop = document.getElementById('group-modal-backdrop');
if (backdrop) {
backdrop.addEventListener('click', function(e) {
if (e.target === backdrop) {
closeGroupModal();
}
});
}
// Setup color preset buttons
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', function() {
selectColor(this.dataset.color);
});
});
// Setup custom color picker
const colorPicker = document.getElementById('group-color');
if (colorPicker) {
colorPicker.addEventListener('input', function() {
selectColor(this.value, true);
});
}
// Setup name field for preview
const nameField = document.getElementById('group-name');
if (nameField) {
nameField.addEventListener('input', updateGroupPreview);
}
// Setup form submission
const form = document.getElementById('group-form');
if (form) {
form.addEventListener('submit', handleGroupSubmit);
}
}
// Make openGroupModal globally available for onclick handlers
window.openGroupModal = function(groupId = null, name = '', color = '#3b82f6') {
console.log('openGroupModal called:', { groupId, name, color });
const backdrop = document.getElementById('group-modal-backdrop');
const title = document.getElementById('group-modal-title');
const nameField = document.getElementById('group-name');
const deleteBtn = document.getElementById('group-delete-btn');
const submitBtn = document.getElementById('group-submit-btn');
const groupIdField = document.getElementById('group-id');
if (!backdrop) {
console.error('Modal backdrop not found!');
return;
}
currentGroupId = groupId;
if (groupId) {
// Edit mode
if (title) title.textContent = 'Edit Group';
if (nameField) nameField.value = name;
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
if (submitBtn) submitBtn.textContent = 'Save';
if (groupIdField) groupIdField.value = groupId;
} else {
// Create mode
if (title) title.textContent = 'New Group';
if (nameField) nameField.value = '';
if (deleteBtn) deleteBtn.style.display = 'none';
if (submitBtn) submitBtn.textContent = 'Create';
if (groupIdField) groupIdField.value = '';
color = '#3b82f6';
}
selectColor(color);
updateGroupPreview();
backdrop.classList.add('open');
console.log('Modal should now be open');
if (nameField) nameField.focus();
};
// Make closeGroupModal globally available for onclick handlers
window.closeGroupModal = function() {
const backdrop = document.getElementById('group-modal-backdrop');
if (backdrop) {
backdrop.classList.remove('open');
}
currentGroupId = null;
};
function selectColor(color, isCustom = false) {
selectedColor = color;
// Update preset button selection
document.querySelectorAll('.color-preset').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
});
// Update custom color picker if not already from custom
if (!isCustom) {
const colorPicker = document.getElementById('group-color');
if (colorPicker) {
colorPicker.value = color;
}
}
// Update hidden field
const hiddenField = document.getElementById('group-color-hidden');
if (hiddenField) {
hiddenField.value = color;
}
// Update preview
updateGroupPreview();
}
function updateGroupPreview() {
const nameField = document.getElementById('group-name');
const previewDot = document.getElementById('group-preview-dot');
const previewName = document.getElementById('group-preview-name');
if (previewDot) {
previewDot.style.backgroundColor = selectedColor;
}
if (previewName && nameField) {
previewName.textContent = nameField.value || 'Group Preview';
previewName.style.color = nameField.value ? 'var(--text-primary)' : 'var(--text-muted)';
}
}
function handleGroupSubmit(e) {
e.preventDefault();
const nameField = document.getElementById('group-name');
const name = nameField.value.trim();
if (!name) {
nameField.focus();
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('name', name);
formData.append('color', selectedColor);
formData.append('csrfmiddlewaretoken', csrfToken);
let url, method;
if (currentGroupId) {
// Update existing group
url = `/groups/${currentGroupId}/`;
formData.append('next', window.location.pathname + window.location.search);
} else {
// Create new group
url = '/groups/';
formData.append('next', window.location.pathname + window.location.search);
}
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to save group');
}
})
.catch(error => {
console.error('Error saving group:', error);
alert('Failed to save group. Please try again.');
});
}
// Make deleteGroup globally available for onclick handlers
window.deleteGroup = function() {
if (!currentGroupId) return;
if (!confirm('Are you sure you want to delete this group?\n\nTasks in this group will not be deleted.')) {
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrfToken);
formData.append('next', window.location.pathname + window.location.search);
fetch(`/groups/${currentGroupId}/delete/`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to delete group');
}
})
.catch(error => {
console.error('Error deleting group:', error);
alert('Failed to delete group. Please try again.');
});
};
/* ============================================
Tag Modal
============================================ */
let currentTagId = null;
let selectedTagColor = '#3b82f6';
document.addEventListener('DOMContentLoaded', function() {
initTagModal();
});
function initTagModal() {
// Setup backdrop click to close
const backdrop = document.getElementById('tag-modal-backdrop');
if (backdrop) {
backdrop.addEventListener('click', function(e) {
if (e.target === backdrop) {
closeTagModal();
}
});
}
// Setup color preset buttons
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', function() {
selectTagColor(this.dataset.color);
});
});
// Setup custom color picker
const colorPicker = document.getElementById('tag-color');
if (colorPicker) {
colorPicker.addEventListener('input', function() {
selectTagColor(this.value, true);
});
}
// Setup form submission
const form = document.getElementById('tag-form');
if (form) {
form.addEventListener('submit', handleTagSubmit);
}
}
function handleTagSubmit(e) {
e.preventDefault();
const nameField = document.getElementById('tag-name');
const name = nameField.value.trim();
if (!name) {
nameField.focus();
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('name', name);
formData.append('color', selectedTagColor);
formData.append('csrfmiddlewaretoken', csrfToken);
let url;
if (currentTagId) {
// Update existing tag
url = `/tags/${currentTagId}/`;
formData.append('next', window.location.pathname + window.location.search);
} else {
// Create new tag
url = '/tags/';
formData.append('next', window.location.pathname + window.location.search);
}
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to save tag');
}
})
.catch(error => {
console.error('Error saving tag:', error);
alert('Failed to save tag. Please try again.');
});
}
// Make openTagModal globally available for onclick handlers
window.openTagModal = function(tagId = null, name = '', color = '#3b82f6') {
console.log('openTagModal called:', { tagId, name, color });
const backdrop = document.getElementById('tag-modal-backdrop');
const title = document.getElementById('tag-modal-title');
const nameField = document.getElementById('tag-name');
const deleteBtn = document.getElementById('tag-delete-btn');
const submitBtn = document.getElementById('tag-submit-btn');
const tagIdField = document.getElementById('tag-id');
if (!backdrop) {
console.error('Tag modal backdrop not found!');
return;
}
currentTagId = tagId;
if (tagId) {
// Edit mode
if (title) title.textContent = 'Edit Tag';
if (nameField) nameField.value = name;
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
if (submitBtn) submitBtn.textContent = 'Save';
if (tagIdField) tagIdField.value = tagId;
} else {
// Create mode
if (title) title.textContent = 'New Tag';
if (nameField) nameField.value = '';
if (deleteBtn) deleteBtn.style.display = 'none';
if (submitBtn) submitBtn.textContent = 'Create';
if (tagIdField) tagIdField.value = '';
color = '#3b82f6';
}
selectTagColor(color);
backdrop.classList.add('open');
if (nameField) nameField.focus();
};
// Make closeTagModal globally available for onclick handlers
window.closeTagModal = function() {
const backdrop = document.getElementById('tag-modal-backdrop');
if (backdrop) {
backdrop.classList.remove('open');
}
currentTagId = null;
};
function selectTagColor(color) {
selectedTagColor = color;
// Update preset button selection
document.querySelectorAll('.color-preset').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
});
// Update custom color picker
const colorPicker = document.getElementById('tag-color');
if (colorPicker) {
colorPicker.value = color;
}
// Update hidden field
const hiddenField = document.getElementById('tag-color-hidden');
if (hiddenField) {
hiddenField.value = color;
}
}
// Make deleteTag globally available for onclick handlers
window.deleteTag = function() {
if (!currentTagId) return;
if (!confirm('Are you sure you want to delete this tag?\n\nTasks with this tag will not be deleted.')) {
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrfToken);
formData.append('next', window.location.pathname + window.location.search);
fetch(`/tags/${currentTagId}/delete/`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to delete tag');
}
})
.catch(error => {
console.error('Error deleting tag:', error);
alert('Failed to delete tag. Please try again.');
});
};