Files
KeepItGoingServer/static/js/app.js
T
Keith SmithandClaude Sonnet 5 7e905a0566 Add Web Push notifications (PWA)
Wires up the previously-scaffolded VAPID/DeviceToken infrastructure end
to end: browser subscription flow on the Profile page's existing "Push
Notifications" toggle, a service worker push/notificationclick handler,
and server-side sending from the daily task digest. Also broadens that
digest's eligibility query so push-only users (email notifications off)
aren't silently skipped, and adds `generate_vapid_keys` since the pinned
py-vapid's own key generator is broken against current cryptography.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:30:56 -06:00

874 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();
});
/* ============================================
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.');
});
};