Files
KeepItGoingServer/static/js/app.js
T
Keith SmithandClaude Sonnet 4.5 6a0b35c39c Initial commit: KeepItGoing task management server
Features:
- Django-based REST API with web interface
- Task management with tags, priorities, and due dates
- Time tracking with start/stop timers
- Subtasks support
- Task filtering (all, today, upcoming, overdue, completed)
- Tag-based organization with color coding
- Sorting by due date and priority
- Auto-assign tags when filtering
- Responsive 3-pane layout (sidebar, task list, detail panel)
- Task sharing between users
- Mobile-responsive design with dark mode support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 17:41:29 -07:00

711 lines
21 KiB
JavaScript

/**
* KeepItGoing - Main Application JavaScript
* Handles theme toggle, task selection, mobile menus, and AJAX operations
*/
document.addEventListener('DOMContentLoaded', function() {
initTheme();
initTaskSelection();
initTimerDisplays();
});
/* ============================================
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();
}
/* ============================================
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.');
});
};