The project has lived only as a folder on the Mac Studio and, briefly, as a transfer tarball. This is the first commit: the Express server and its web front end, the Capacitor iOS and Android wrappers, and the release and backup scripts. The repo is the source, not a backup of the machine. Left out on purpose, by the .gitignore files that were already in the tree: node_modules, build output, mobile/www (regenerated from server/public on every build), and server/data, which holds the live checklists and belongs on the NAS rather than in git. Every credential is excluded too — server/.env, the Play upload keystore and its properties file, the App Store Connect key and ios-signing.config. The one secret that was not in an ignored file was the NAS SSH password, hard-coded in build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read it from the login keychain (service checklist-nas, account Christian), falling back to NAS_PASS in the environment, so rotating it is one keychain entry rather than a history rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1094 lines
35 KiB
JavaScript
1094 lines
35 KiB
JavaScript
const { ipcRenderer } = require('electron');
|
|
const Store = require('electron-store');
|
|
const store = new Store();
|
|
|
|
const DEFAULT_LISTS = [
|
|
{ id: 'general', name: 'General' }
|
|
];
|
|
|
|
// Initialize data structure with migration support
|
|
let checklistData = store.get('checklistData', {
|
|
lists: [],
|
|
selectedListId: DEFAULT_LISTS[0].id
|
|
});
|
|
|
|
// Migration from single-list structure
|
|
if (checklistData.items) {
|
|
const migratedList = {
|
|
id: 'startup',
|
|
name: 'Startup',
|
|
items: checklistData.items,
|
|
lastCheckDate: checklistData.lastCheckDate || new Date().toDateString()
|
|
};
|
|
checklistData = {
|
|
lists: [migratedList],
|
|
selectedListId: migratedList.id
|
|
};
|
|
store.set('checklistData', checklistData);
|
|
}
|
|
|
|
function ensureDefaultLists() {
|
|
if (!Array.isArray(checklistData.lists)) {
|
|
checklistData.lists = [];
|
|
}
|
|
if (checklistData.lists.length === 0) {
|
|
checklistData.lists.push({
|
|
id: DEFAULT_LISTS[0].id,
|
|
name: DEFAULT_LISTS[0].name,
|
|
items: [],
|
|
lastCheckDate: new Date().toDateString()
|
|
});
|
|
checklistData.selectedListId = DEFAULT_LISTS[0].id;
|
|
}
|
|
}
|
|
|
|
ensureDefaultLists();
|
|
|
|
// DOM elements
|
|
const itemTitleInput = document.getElementById('itemTitle');
|
|
const itemDescriptionInput = document.getElementById('itemDescription');
|
|
const isRecurringCheckbox = document.getElementById('isRecurring');
|
|
const recurrenceOptions = document.getElementById('recurrenceOptions');
|
|
const recurrenceTypeSelect = document.getElementById('recurrenceType');
|
|
const weeklyOptions = document.getElementById('weeklyOptions');
|
|
const monthlyOptions = document.getElementById('monthlyOptions');
|
|
const dayOfWeekSelect = document.getElementById('dayOfWeek');
|
|
const dayOfMonthInput = document.getElementById('dayOfMonth');
|
|
const addItemBtn = document.getElementById('addItemBtn');
|
|
const todoList = document.getElementById('todoList');
|
|
const doneList = document.getElementById('doneList');
|
|
const listTabs = document.getElementById('listTabs');
|
|
const addListBtn = document.getElementById('addListBtn');
|
|
const addListSelect = document.getElementById('addListSelect');
|
|
const renameListBtn = document.getElementById('renameListBtn');
|
|
const deleteListBtn = document.getElementById('deleteListBtn');
|
|
const exportJsonBtn = document.getElementById('exportJsonBtn');
|
|
const importJsonBtn = document.getElementById('importJsonBtn');
|
|
const listMenu = document.getElementById('listMenu');
|
|
const listMenuHandle = document.getElementById('listMenuHandle');
|
|
const tasksTabBtn = document.getElementById('tasksTabBtn');
|
|
const addTabBtn = document.getElementById('addTabBtn');
|
|
const tasksView = document.getElementById('tasksView');
|
|
const addView = document.getElementById('addView');
|
|
const moveItemModal = document.getElementById('moveItemModal');
|
|
const moveItemSelect = document.getElementById('moveItemSelect');
|
|
const moveItemSave = document.getElementById('moveItemSave');
|
|
const moveItemCancel = document.getElementById('moveItemCancel');
|
|
const moveItemOverlay = moveItemModal;
|
|
|
|
const listModal = document.getElementById('listModal');
|
|
const listModalTitle = document.getElementById('listModalTitle');
|
|
const listModalMessage = document.getElementById('listModalMessage');
|
|
const listModalInput = document.getElementById('listModalInput');
|
|
const listModalSave = document.getElementById('listModalSave');
|
|
const listModalCancel = document.getElementById('listModalCancel');
|
|
const listModalOverlay = listModal; // alias for clarity
|
|
|
|
// Edit task modal elements
|
|
const editTaskModal = document.getElementById('editTaskModal');
|
|
const editTitle = document.getElementById('editTitle');
|
|
const editDescription = document.getElementById('editDescription');
|
|
const editIsRecurring = document.getElementById('editIsRecurring');
|
|
const editRecurrenceOptions = document.getElementById('editRecurrenceOptions');
|
|
const editRecurrenceType = document.getElementById('editRecurrenceType');
|
|
const editWeeklyOptions = document.getElementById('editWeeklyOptions');
|
|
const editMonthlyOptions = document.getElementById('editMonthlyOptions');
|
|
const editDayOfWeek = document.getElementById('editDayOfWeek');
|
|
const editDayOfMonth = document.getElementById('editDayOfMonth');
|
|
const editModalSave = document.getElementById('editModalSave');
|
|
const editModalCancel = document.getElementById('editModalCancel');
|
|
const editModalOverlay = editTaskModal;
|
|
|
|
let addTargetListId = checklistData.selectedListId;
|
|
let modalMode = null;
|
|
let modalTargetId = null;
|
|
let dragSourceListId = null;
|
|
const FALLBACK_LIST = { id: 'new-list', name: 'New List' };
|
|
let touchDragSourceId = null;
|
|
let editingTaskId = null;
|
|
let moveModalItemId = null;
|
|
let itemDragId = null;
|
|
let itemDragDone = false;
|
|
|
|
// Event listeners
|
|
isRecurringCheckbox.addEventListener('change', () => {
|
|
recurrenceOptions.style.display = isRecurringCheckbox.checked ? 'block' : 'none';
|
|
updateRecurrenceOptions();
|
|
});
|
|
|
|
recurrenceTypeSelect.addEventListener('change', updateRecurrenceOptions);
|
|
|
|
addItemBtn.addEventListener('click', addItem);
|
|
|
|
itemTitleInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
addItem();
|
|
}
|
|
});
|
|
|
|
addListBtn.addEventListener('click', addList);
|
|
renameListBtn.addEventListener('click', renameList);
|
|
deleteListBtn.addEventListener('click', deleteList);
|
|
exportJsonBtn.addEventListener('click', exportChecklists);
|
|
importJsonBtn.addEventListener('click', importChecklists);
|
|
tasksTabBtn.addEventListener('click', () => switchTab('tasks'));
|
|
addTabBtn.addEventListener('click', () => switchTab('add'));
|
|
addListSelect.addEventListener('change', (e) => {
|
|
addTargetListId = e.target.value;
|
|
});
|
|
moveItemSave.addEventListener('click', handleMoveItemSave);
|
|
moveItemCancel.addEventListener('click', closeMoveItemModal);
|
|
moveItemOverlay.addEventListener('click', (e) => {
|
|
if (e.target === moveItemOverlay) {
|
|
closeMoveItemModal();
|
|
}
|
|
});
|
|
todoList.addEventListener('dragover', (e) => onListContainerDragOver(e, false));
|
|
doneList.addEventListener('dragover', (e) => onListContainerDragOver(e, true));
|
|
todoList.addEventListener('drop', (e) => onListContainerDrop(e, false));
|
|
doneList.addEventListener('drop', (e) => onListContainerDrop(e, true));
|
|
listModalSave.addEventListener('click', handleModalSave);
|
|
listModalCancel.addEventListener('click', closeListModal);
|
|
listModalInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleModalSave();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeListModal();
|
|
}
|
|
});
|
|
listModalOverlay.addEventListener('click', (e) => {
|
|
if (e.target === listModalOverlay) {
|
|
closeListModal();
|
|
}
|
|
});
|
|
|
|
// List menu (collapsible drawer for list actions)
|
|
let listMenuPointerStartY = null;
|
|
let listMenuPointerStartX = null;
|
|
let listMenuPointerMoved = false;
|
|
let listMenuSwipeHandled = false;
|
|
const LIST_MENU_SWIPE_THRESHOLD = 18;
|
|
const LIST_MENU_MOVE_THRESHOLD = 10;
|
|
|
|
function expandListMenu() {
|
|
listMenu.classList.add('expanded');
|
|
listMenuHandle.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
function collapseListMenu() {
|
|
listMenu.classList.remove('expanded');
|
|
listMenuHandle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
function toggleListMenu() {
|
|
if (listMenu.classList.contains('expanded')) {
|
|
collapseListMenu();
|
|
} else {
|
|
expandListMenu();
|
|
}
|
|
}
|
|
|
|
listMenuHandle.addEventListener('click', () => {
|
|
if (listMenuSwipeHandled) {
|
|
listMenuSwipeHandled = false;
|
|
return;
|
|
}
|
|
toggleListMenu();
|
|
});
|
|
|
|
listMenuHandle.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggleListMenu();
|
|
}
|
|
});
|
|
|
|
listMenuHandle.addEventListener('pointerdown', (e) => {
|
|
listMenuPointerStartY = e.clientY;
|
|
listMenuPointerStartX = e.clientX;
|
|
listMenuPointerMoved = false;
|
|
try { listMenuHandle.setPointerCapture(e.pointerId); } catch (_) {}
|
|
});
|
|
|
|
listMenuHandle.addEventListener('pointermove', (e) => {
|
|
if (listMenuPointerStartY === null) return;
|
|
const dy = e.clientY - listMenuPointerStartY;
|
|
const dx = e.clientX - listMenuPointerStartX;
|
|
if (Math.abs(dy) > LIST_MENU_MOVE_THRESHOLD || Math.abs(dx) > LIST_MENU_MOVE_THRESHOLD) {
|
|
listMenuPointerMoved = true;
|
|
}
|
|
});
|
|
|
|
listMenuHandle.addEventListener('pointerup', (e) => {
|
|
try { listMenuHandle.releasePointerCapture(e.pointerId); } catch (_) {}
|
|
if (listMenuPointerStartY === null) {
|
|
return;
|
|
}
|
|
const dy = e.clientY - listMenuPointerStartY;
|
|
const dx = e.clientX - listMenuPointerStartX;
|
|
const isSwipe = listMenuPointerMoved && Math.abs(dy) > LIST_MENU_SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx);
|
|
if (isSwipe) {
|
|
if (dy > 0) {
|
|
expandListMenu();
|
|
} else {
|
|
collapseListMenu();
|
|
}
|
|
listMenuSwipeHandled = true;
|
|
}
|
|
listMenuPointerStartY = null;
|
|
listMenuPointerStartX = null;
|
|
listMenuPointerMoved = false;
|
|
});
|
|
|
|
listMenuHandle.addEventListener('pointercancel', () => {
|
|
listMenuPointerStartY = null;
|
|
listMenuPointerStartX = null;
|
|
listMenuPointerMoved = false;
|
|
});
|
|
|
|
editIsRecurring.addEventListener('change', () => {
|
|
editRecurrenceOptions.style.display = editIsRecurring.checked ? 'block' : 'none';
|
|
updateEditRecurrenceOptions();
|
|
});
|
|
editRecurrenceType.addEventListener('change', updateEditRecurrenceOptions);
|
|
editModalSave.addEventListener('click', saveEditTask);
|
|
editModalCancel.addEventListener('click', closeEditModal);
|
|
// Keep edit modal open when clicking the backdrop so text selections outside the modal don't close it.
|
|
editModalOverlay.addEventListener('click', (e) => {
|
|
if (e.target !== editModalOverlay) return;
|
|
});
|
|
|
|
// Functions
|
|
function switchTab(tab) {
|
|
if (tab === 'tasks') {
|
|
tasksView.classList.add('active');
|
|
addView.classList.remove('active');
|
|
tasksTabBtn.classList.add('active');
|
|
addTabBtn.classList.remove('active');
|
|
} else {
|
|
tasksView.classList.remove('active');
|
|
addView.classList.add('active');
|
|
tasksTabBtn.classList.remove('active');
|
|
addTabBtn.classList.add('active');
|
|
}
|
|
}
|
|
|
|
function addList() {
|
|
openListModal('add');
|
|
}
|
|
|
|
function renameList() {
|
|
openListModal('rename');
|
|
}
|
|
|
|
function setSelectedList(id) {
|
|
checklistData.selectedListId = id;
|
|
addTargetListId = addTargetListId || id;
|
|
saveData();
|
|
renderLists();
|
|
}
|
|
|
|
function getCurrentList() {
|
|
let list = checklistData.lists.find(l => l.id === checklistData.selectedListId);
|
|
if (!list) {
|
|
ensureDefaultLists();
|
|
list = checklistData.lists[0];
|
|
checklistData.selectedListId = list.id;
|
|
saveData();
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function updateRecurrenceOptions() {
|
|
const type = recurrenceTypeSelect.value;
|
|
weeklyOptions.style.display = type === 'weekly' ? 'block' : 'none';
|
|
monthlyOptions.style.display = type === 'monthly' ? 'block' : 'none';
|
|
}
|
|
|
|
function addItem() {
|
|
const title = itemTitleInput.value.trim();
|
|
if (!title) {
|
|
alert('Please enter a task title');
|
|
return;
|
|
}
|
|
|
|
const description = itemDescriptionInput.value.trim();
|
|
const isRecurring = isRecurringCheckbox.checked;
|
|
|
|
let recurrence = null;
|
|
if (isRecurring) {
|
|
const type = recurrenceTypeSelect.value;
|
|
recurrence = {
|
|
type: type,
|
|
dayOfWeek: type === 'weekly' ? parseInt(dayOfWeekSelect.value) : null,
|
|
dayOfMonth: type === 'monthly' ? parseInt(dayOfMonthInput.value) : null
|
|
};
|
|
}
|
|
|
|
const newItem = {
|
|
id: Date.now().toString(),
|
|
title: title,
|
|
description: description,
|
|
completed: false,
|
|
completedDate: null,
|
|
recurrence: recurrence,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
const targetList = checklistData.lists.find(l => l.id === addTargetListId) || getCurrentList();
|
|
targetList.items.push(newItem);
|
|
saveData();
|
|
renderLists();
|
|
|
|
// Reset form
|
|
itemTitleInput.value = '';
|
|
itemDescriptionInput.value = '';
|
|
isRecurringCheckbox.checked = false;
|
|
recurrenceOptions.style.display = 'none';
|
|
itemTitleInput.focus();
|
|
}
|
|
|
|
function openEditModal(itemId) {
|
|
const list = getCurrentList();
|
|
const item = list.items.find(i => i.id === itemId);
|
|
if (!item) return;
|
|
editingTaskId = itemId;
|
|
editTitle.value = item.title;
|
|
editDescription.value = item.description || '';
|
|
editIsRecurring.checked = !!item.recurrence;
|
|
editRecurrenceOptions.style.display = editIsRecurring.checked ? 'block' : 'none';
|
|
if (item.recurrence) {
|
|
editRecurrenceType.value = item.recurrence.type || 'daily';
|
|
editDayOfWeek.value = (item.recurrence.dayOfWeek ?? 0).toString();
|
|
editDayOfMonth.value = item.recurrence.dayOfMonth ?? 1;
|
|
} else {
|
|
editRecurrenceType.value = 'daily';
|
|
editDayOfWeek.value = '0';
|
|
editDayOfMonth.value = 1;
|
|
}
|
|
updateEditRecurrenceOptions();
|
|
editTaskModal.style.display = 'flex';
|
|
requestAnimationFrame(() => {
|
|
editTitle.focus({ preventScroll: true });
|
|
editTitle.select();
|
|
});
|
|
}
|
|
|
|
function closeEditModal() {
|
|
editingTaskId = null;
|
|
editTaskModal.style.display = 'none';
|
|
}
|
|
|
|
function saveEditTask() {
|
|
if (!editingTaskId) {
|
|
closeEditModal();
|
|
return;
|
|
}
|
|
const list = getCurrentList();
|
|
const item = list.items.find(i => i.id === editingTaskId);
|
|
if (!item) {
|
|
closeEditModal();
|
|
return;
|
|
}
|
|
const title = editTitle.value.trim();
|
|
if (!title) {
|
|
alert('Please enter a task title');
|
|
return;
|
|
}
|
|
const description = editDescription.value.trim();
|
|
let recurrence = null;
|
|
if (editIsRecurring.checked) {
|
|
const type = editRecurrenceType.value;
|
|
recurrence = {
|
|
type,
|
|
dayOfWeek: type === 'weekly' ? parseInt(editDayOfWeek.value) : null,
|
|
dayOfMonth: type === 'monthly' ? parseInt(editDayOfMonth.value) : null
|
|
};
|
|
}
|
|
item.title = title;
|
|
item.description = description;
|
|
item.recurrence = recurrence;
|
|
saveData();
|
|
renderLists();
|
|
closeEditModal();
|
|
}
|
|
|
|
function toggleItem(itemId) {
|
|
const list = getCurrentList();
|
|
const item = list.items.find(i => i.id === itemId);
|
|
if (!item) return;
|
|
|
|
item.completed = !item.completed;
|
|
item.completedDate = item.completed ? new Date().toISOString() : null;
|
|
|
|
saveData();
|
|
renderLists();
|
|
}
|
|
|
|
function deleteItem(itemId) {
|
|
const list = getCurrentList();
|
|
if (confirm('Are you sure you want to delete this task?')) {
|
|
list.items = list.items.filter(i => i.id !== itemId);
|
|
saveData();
|
|
renderLists();
|
|
}
|
|
}
|
|
|
|
function shouldRecur(item) {
|
|
if (!item.recurrence || !item.completed) return false;
|
|
|
|
const now = new Date();
|
|
const completedDate = new Date(item.completedDate);
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const completedDay = new Date(completedDate.getFullYear(), completedDate.getMonth(), completedDate.getDate());
|
|
|
|
switch (item.recurrence.type) {
|
|
case 'daily':
|
|
return completedDay.getTime() < today.getTime();
|
|
|
|
case 'weekly':
|
|
const dayOfWeek = now.getDay();
|
|
return dayOfWeek === item.recurrence.dayOfWeek &&
|
|
completedDay.getTime() < today.getTime();
|
|
|
|
case 'monthly':
|
|
const dayOfMonth = now.getDate();
|
|
return dayOfMonth === item.recurrence.dayOfMonth &&
|
|
completedDay.getTime() < today.getTime();
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getNextOccurrenceDate(recurrence) {
|
|
if (!recurrence) return null;
|
|
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
switch (recurrence.type) {
|
|
case 'daily':
|
|
return new Date(today.getTime() + 24 * 60 * 60 * 1000);
|
|
|
|
case 'weekly':
|
|
const dayOfWeek = now.getDay();
|
|
const targetDay = recurrence.dayOfWeek;
|
|
let daysUntil = targetDay - dayOfWeek;
|
|
if (daysUntil <= 0) daysUntil += 7;
|
|
return new Date(today.getTime() + daysUntil * 24 * 60 * 60 * 1000);
|
|
|
|
case 'monthly':
|
|
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, recurrence.dayOfMonth);
|
|
return nextMonth;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatDate(date) {
|
|
if (!date) return '';
|
|
const d = new Date(date);
|
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
const months = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
return `${days[d.getDay()]}, ${months[d.getMonth()]} ${d.getDate()}`;
|
|
}
|
|
|
|
function checkRecurringItems() {
|
|
const today = new Date().toDateString();
|
|
checklistData.lists.forEach(list => {
|
|
if (list.lastCheckDate === today) return;
|
|
list.items.forEach(item => {
|
|
if (shouldRecur(item)) {
|
|
item.completed = false;
|
|
item.completedDate = null;
|
|
}
|
|
});
|
|
list.lastCheckDate = today;
|
|
});
|
|
saveData();
|
|
}
|
|
|
|
function renderLists() {
|
|
checkRecurringItems();
|
|
renderListTabs();
|
|
const list = getCurrentList();
|
|
const todoItems = list.items.filter(item => !item.completed);
|
|
const doneItems = list.items.filter(item => item.completed);
|
|
|
|
renderList(todoList, todoItems, false);
|
|
renderList(doneList, doneItems, true);
|
|
renderListSelect();
|
|
}
|
|
|
|
function renderList(container, items, isDone) {
|
|
if (items.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">No tasks yet</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = items.map(item => {
|
|
const recurrenceText = item.recurrence ?
|
|
getRecurrenceText(item.recurrence, item.completed) : '';
|
|
|
|
return `
|
|
<div class="checklist-item ${isDone ? 'done' : ''}" draggable="true"
|
|
data-item-id="${item.id}"
|
|
ondragstart="onItemDragStart('${item.id}', ${isDone})"
|
|
ondragover="onItemDragOver(event, '${item.id}', ${isDone})"
|
|
ondragleave="onItemDragLeave('${item.id}')"
|
|
ondrop="onItemDrop(event, '${item.id}', ${isDone})">
|
|
<div class="checklist-item-header">
|
|
<input type="checkbox" ${item.completed ? 'checked' : ''}
|
|
onchange="toggleItem('${item.id}')" />
|
|
<div class="checklist-item-title">${escapeHtml(item.title)}</div>
|
|
</div>
|
|
${item.description ? `
|
|
<div class="checklist-item-description">${escapeHtml(item.description)}</div>
|
|
` : ''}
|
|
${recurrenceText ? `
|
|
<div class="checklist-item-recurrence">
|
|
<span class="recurrence-icon">🔄</span>
|
|
<span>${recurrenceText}</span>
|
|
</div>
|
|
` : ''}
|
|
<div class="checklist-item-actions">
|
|
<button class="btn btn-secondary" onclick="editItem('${item.id}')">Edit</button>
|
|
<button class="btn btn-secondary" onclick="openMoveItemModal('${item.id}')">Move</button>
|
|
<button class="btn btn-danger" onclick="deleteItem('${item.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderListTabs() {
|
|
const current = getCurrentList().id;
|
|
listTabs.innerHTML = checklistData.lists.map(list => `
|
|
<button
|
|
class="list-tab ${list.id === current ? 'active' : ''}"
|
|
onclick="changeList('${list.id}')"
|
|
draggable="true"
|
|
ondragstart="onListDragStart('${list.id}')"
|
|
ondragover="onListDragOver(event, '${list.id}')"
|
|
ondragleave="onListDragLeave('${list.id}')"
|
|
ondrop="onListDrop(event, '${list.id}')"
|
|
onpointerdown="onListPointerDown(event, '${list.id}')"
|
|
onpointerup="onListPointerUp(event, '${list.id}')"
|
|
onpointercancel="onListPointerCancel()"
|
|
>
|
|
${escapeHtml(list.name)}
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
function renderListSelect() {
|
|
const current = addTargetListId || checklistData.selectedListId;
|
|
addListSelect.innerHTML = checklistData.lists.map(list => `
|
|
<option value="${list.id}" ${list.id === current ? 'selected' : ''}>${escapeHtml(list.name)}</option>
|
|
`).join('');
|
|
if (!addTargetListId) {
|
|
addTargetListId = checklistData.selectedListId;
|
|
}
|
|
}
|
|
|
|
function openListModal(mode) {
|
|
modalMode = mode;
|
|
modalTargetId = getCurrentList().id;
|
|
listModalTitle.textContent = mode === 'add' ? 'Add List' : 'Rename List';
|
|
listModalMessage.style.display = 'none';
|
|
listModalMessage.classList.remove('danger');
|
|
listModalInput.style.display = 'block';
|
|
listModalSave.textContent = 'Save';
|
|
listModalSave.classList.remove('btn-danger');
|
|
|
|
if (mode === 'rename') {
|
|
listModalInput.value = getCurrentList().name;
|
|
} else {
|
|
listModalInput.value = '';
|
|
}
|
|
|
|
listModal.style.display = 'flex';
|
|
requestAnimationFrame(() => {
|
|
listModalInput.focus({ preventScroll: true });
|
|
listModalInput.select();
|
|
});
|
|
}
|
|
|
|
function closeListModal() {
|
|
modalMode = null;
|
|
modalTargetId = null;
|
|
listModal.style.display = 'none';
|
|
listModalInput.value = '';
|
|
}
|
|
|
|
function handleModalSave() {
|
|
const name = listModalInput.value.trim();
|
|
if (modalMode === 'add') {
|
|
if (!name) {
|
|
closeListModal();
|
|
return;
|
|
}
|
|
const id = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '') || Date.now().toString();
|
|
if (checklistData.lists.find(l => l.id === id)) {
|
|
alert('A list with that name already exists.');
|
|
return;
|
|
}
|
|
checklistData.lists.push({
|
|
id,
|
|
name,
|
|
items: [],
|
|
lastCheckDate: new Date().toDateString()
|
|
});
|
|
checklistData.selectedListId = id;
|
|
addTargetListId = id;
|
|
} else if (modalMode === 'rename') {
|
|
if (!name) {
|
|
closeListModal();
|
|
return;
|
|
}
|
|
const list = checklistData.lists.find(l => l.id === modalTargetId);
|
|
if (list) {
|
|
list.name = name;
|
|
}
|
|
} else if (modalMode === 'delete') {
|
|
const current = checklistData.lists.find(l => l.id === modalTargetId);
|
|
if (current) {
|
|
checklistData.lists = checklistData.lists.filter(l => l.id !== current.id);
|
|
if (checklistData.lists.length === 0) {
|
|
const newId = `${FALLBACK_LIST.id}-${Date.now()}`;
|
|
const newName = `${FALLBACK_LIST.name}`;
|
|
checklistData.lists.push({
|
|
id: newId,
|
|
name: newName,
|
|
items: [],
|
|
lastCheckDate: new Date().toDateString()
|
|
});
|
|
checklistData.selectedListId = newId;
|
|
addTargetListId = newId;
|
|
} else {
|
|
if (!checklistData.lists.find(l => l.id === addTargetListId)) {
|
|
addTargetListId = checklistData.lists[0].id;
|
|
}
|
|
checklistData.selectedListId = checklistData.lists[0].id;
|
|
}
|
|
}
|
|
}
|
|
saveData();
|
|
renderLists();
|
|
closeListModal();
|
|
}
|
|
|
|
function deleteList() {
|
|
modalMode = 'delete';
|
|
modalTargetId = getCurrentList().id;
|
|
listModalTitle.textContent = 'Delete List';
|
|
listModalMessage.textContent = `Delete "${getCurrentList().name}"? Tasks inside will be removed. If it was the last list, a blank "New List" will be created.`;
|
|
listModalMessage.style.display = 'block';
|
|
listModalMessage.classList.add('danger');
|
|
listModalInput.style.display = 'none';
|
|
listModalSave.textContent = 'Delete';
|
|
listModalSave.classList.add('btn-danger');
|
|
listModal.style.display = 'flex';
|
|
listModalInput.blur();
|
|
}
|
|
|
|
function onListDragStart(id) {
|
|
dragSourceListId = id;
|
|
}
|
|
|
|
function onListDragOver(event, id) {
|
|
event.preventDefault();
|
|
const tab = findTabEl(id);
|
|
if (tab) tab.classList.add('drag-over');
|
|
}
|
|
|
|
function onListDragLeave(id) {
|
|
const tab = findTabEl(id);
|
|
if (tab) tab.classList.remove('drag-over');
|
|
}
|
|
|
|
function onListDrop(event, targetId) {
|
|
event.preventDefault();
|
|
const tab = findTabEl(targetId);
|
|
if (tab) tab.classList.remove('drag-over');
|
|
if (!dragSourceListId || dragSourceListId === targetId) return;
|
|
const lists = checklistData.lists;
|
|
const fromIndex = lists.findIndex(l => l.id === dragSourceListId);
|
|
const toIndex = lists.findIndex(l => l.id === targetId);
|
|
if (fromIndex === -1 || toIndex === -1) return;
|
|
const [moved] = lists.splice(fromIndex, 1);
|
|
lists.splice(toIndex, 0, moved);
|
|
saveData();
|
|
renderLists();
|
|
}
|
|
|
|
function findTabEl(id) {
|
|
return listTabs.querySelector(`.list-tab[onclick="changeList('${id}')"]`);
|
|
}
|
|
|
|
function onListPointerDown(event, id) {
|
|
if (event.pointerType === 'touch') {
|
|
touchDragSourceId = id;
|
|
}
|
|
}
|
|
|
|
function onListPointerUp(event, targetId) {
|
|
if (event.pointerType !== 'touch') return;
|
|
if (!touchDragSourceId || touchDragSourceId === targetId) {
|
|
touchDragSourceId = null;
|
|
return;
|
|
}
|
|
const lists = checklistData.lists;
|
|
const fromIndex = lists.findIndex(l => l.id === touchDragSourceId);
|
|
const toIndex = lists.findIndex(l => l.id === targetId);
|
|
if (fromIndex === -1 || toIndex === -1) {
|
|
touchDragSourceId = null;
|
|
return;
|
|
}
|
|
const [moved] = lists.splice(fromIndex, 1);
|
|
lists.splice(toIndex, 0, moved);
|
|
touchDragSourceId = null;
|
|
saveData();
|
|
renderLists();
|
|
}
|
|
|
|
function onListPointerCancel() {
|
|
touchDragSourceId = null;
|
|
}
|
|
|
|
function updateEditRecurrenceOptions() {
|
|
const type = editRecurrenceType.value;
|
|
editWeeklyOptions.style.display = type === 'weekly' ? 'block' : 'none';
|
|
editMonthlyOptions.style.display = type === 'monthly' ? 'block' : 'none';
|
|
}
|
|
|
|
function getRecurrenceText(recurrence, isCompleted) {
|
|
if (isCompleted) {
|
|
// Show next occurrence date for completed recurring items
|
|
const nextDate = getNextOccurrenceDate(recurrence);
|
|
return nextDate ? formatDate(nextDate) : '';
|
|
} else {
|
|
// Show recurrence pattern for active items
|
|
switch (recurrence.type) {
|
|
case 'daily':
|
|
return 'Repeats daily';
|
|
case 'weekly':
|
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
return `Repeats every week on ${days[recurrence.dayOfWeek]}`;
|
|
case 'monthly':
|
|
return `Repeats monthly on day ${recurrence.dayOfMonth}`;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function saveData() {
|
|
store.set('checklistData', checklistData);
|
|
}
|
|
|
|
function openMoveItemModal(itemId) {
|
|
moveModalItemId = itemId;
|
|
populateMoveSelect();
|
|
moveItemModal.style.display = 'flex';
|
|
requestAnimationFrame(() => {
|
|
moveItemSelect.focus({ preventScroll: true });
|
|
});
|
|
}
|
|
|
|
function closeMoveItemModal() {
|
|
moveModalItemId = null;
|
|
moveItemModal.style.display = 'none';
|
|
}
|
|
|
|
function populateMoveSelect() {
|
|
const options = checklistData.lists.map(list => `
|
|
<option value="${list.id}" ${list.id === checklistData.selectedListId ? 'selected' : ''}>
|
|
${escapeHtml(list.name)}
|
|
</option>
|
|
`).join('');
|
|
moveItemSelect.innerHTML = options;
|
|
}
|
|
|
|
function handleMoveItemSave() {
|
|
if (!moveModalItemId) {
|
|
closeMoveItemModal();
|
|
return;
|
|
}
|
|
const targetListId = moveItemSelect.value;
|
|
const sourceList = checklistData.lists.find(l => l.items.some(i => i.id === moveModalItemId));
|
|
const targetList = checklistData.lists.find(l => l.id === targetListId);
|
|
if (!sourceList || !targetList) {
|
|
closeMoveItemModal();
|
|
return;
|
|
}
|
|
if (sourceList.id === targetList.id) {
|
|
closeMoveItemModal();
|
|
return;
|
|
}
|
|
|
|
const fromIndex = sourceList.items.findIndex(i => i.id === moveModalItemId);
|
|
const [item] = sourceList.items.splice(fromIndex, 1);
|
|
targetList.items.push(item);
|
|
moveModalItemId = null;
|
|
saveData();
|
|
renderLists();
|
|
closeMoveItemModal();
|
|
}
|
|
|
|
function onItemDragStart(id, isDone) {
|
|
itemDragId = id;
|
|
itemDragDone = isDone;
|
|
}
|
|
|
|
function onItemDragOver(event, targetId, isDone) {
|
|
event.preventDefault();
|
|
if (!itemDragId || itemDragDone !== isDone) return;
|
|
const el = findItemElement(targetId);
|
|
if (el) {
|
|
el.classList.add('drag-over');
|
|
}
|
|
}
|
|
|
|
function onItemDragLeave(targetId) {
|
|
const el = findItemElement(targetId);
|
|
if (el) {
|
|
el.classList.remove('drag-over');
|
|
}
|
|
}
|
|
|
|
function onItemDrop(event, targetId, isDone) {
|
|
event.preventDefault();
|
|
if (!itemDragId || itemDragDone !== isDone) {
|
|
clearDragState(targetId);
|
|
return;
|
|
}
|
|
reorderItems(itemDragId, targetId);
|
|
clearDragState(targetId);
|
|
}
|
|
|
|
function onListContainerDragOver(event, isDone) {
|
|
event.preventDefault();
|
|
if (!itemDragId || itemDragDone !== isDone) return;
|
|
}
|
|
|
|
function onListContainerDrop(event, isDone) {
|
|
event.preventDefault();
|
|
if (!itemDragId || itemDragDone !== isDone) {
|
|
itemDragId = null;
|
|
return;
|
|
}
|
|
reorderItems(itemDragId, null);
|
|
itemDragId = null;
|
|
itemDragDone = false;
|
|
}
|
|
|
|
function reorderItems(sourceId, targetId) {
|
|
const list = getCurrentList();
|
|
const items = list.items;
|
|
const fromIndex = items.findIndex(i => i.id === sourceId);
|
|
if (fromIndex === -1) return;
|
|
|
|
let toIndex = targetId ? items.findIndex(i => i.id === targetId) : items.length - 1;
|
|
if (toIndex === -1) return;
|
|
|
|
if (fromIndex === toIndex) return;
|
|
|
|
const [moved] = items.splice(fromIndex, 1);
|
|
if (fromIndex < toIndex) {
|
|
toIndex -= 1;
|
|
}
|
|
items.splice(toIndex, 0, moved);
|
|
saveData();
|
|
renderLists();
|
|
}
|
|
|
|
function clearDragState(targetId) {
|
|
const el = targetId ? findItemElement(targetId) : null;
|
|
if (el) el.classList.remove('drag-over');
|
|
itemDragId = null;
|
|
itemDragDone = false;
|
|
}
|
|
|
|
function findItemElement(id) {
|
|
return document.querySelector(`[data-item-id="${id}"]`);
|
|
}
|
|
|
|
async function exportChecklists() {
|
|
try {
|
|
const payload = JSON.stringify(checklistData, null, 2);
|
|
const result = await ipcRenderer.invoke('export-checklists', payload);
|
|
if (result?.canceled) return;
|
|
if (result?.error) {
|
|
alert(`Failed to export checklists: ${result.error}`);
|
|
return;
|
|
}
|
|
alert(`Checklists saved to ${result.filePath}`);
|
|
} catch (error) {
|
|
alert(`Failed to export checklists: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function importChecklists() {
|
|
try {
|
|
const result = await ipcRenderer.invoke('import-checklists');
|
|
if (result?.canceled) return;
|
|
if (result?.error) {
|
|
alert(`Failed to import checklists: ${result.error}`);
|
|
return;
|
|
}
|
|
|
|
const parsed = parseImportedData(result?.data);
|
|
if (!parsed) {
|
|
alert('The selected file is not a valid checklist export.');
|
|
return;
|
|
}
|
|
|
|
checklistData = parsed;
|
|
addTargetListId = checklistData.selectedListId;
|
|
ensureDefaultLists();
|
|
saveData();
|
|
renderLists();
|
|
alert('Checklists imported successfully.');
|
|
} catch (error) {
|
|
alert(`Failed to import checklists: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function parseImportedData(rawData) {
|
|
if (!rawData) return null;
|
|
let parsed;
|
|
try {
|
|
parsed = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const lists = Array.isArray(parsed.lists)
|
|
? parsed.lists.map((list, index) => normalizeListData(list, index)).filter(Boolean)
|
|
: [];
|
|
|
|
if (lists.length === 0) return null;
|
|
|
|
const selectedListId = lists.find(l => l.id === parsed.selectedListId)?.id || lists[0].id;
|
|
return { lists, selectedListId };
|
|
}
|
|
|
|
function normalizeListData(list, index) {
|
|
if (!list || typeof list !== 'object') return null;
|
|
|
|
const id = typeof list.id === 'string' && list.id.trim()
|
|
? list.id.trim()
|
|
: `list-${Date.now()}-${index}`;
|
|
const name = typeof list.name === 'string' && list.name.trim()
|
|
? list.name.trim()
|
|
: 'Imported List';
|
|
|
|
const items = Array.isArray(list.items)
|
|
? list.items.map(normalizeItemData).filter(Boolean)
|
|
: [];
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
items,
|
|
lastCheckDate: typeof list.lastCheckDate === 'string'
|
|
? list.lastCheckDate
|
|
: new Date().toDateString()
|
|
};
|
|
}
|
|
|
|
function normalizeItemData(item) {
|
|
if (!item || typeof item !== 'object') return null;
|
|
|
|
const title = typeof item.title === 'string' ? item.title.trim() : '';
|
|
if (!title) return null;
|
|
|
|
return {
|
|
id: typeof item.id === 'string' && item.id.trim()
|
|
? item.id
|
|
: `task-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
|
title,
|
|
description: typeof item.description === 'string' ? item.description : '',
|
|
completed: !!item.completed,
|
|
completedDate: item.completed ? (item.completedDate || new Date().toISOString()) : null,
|
|
recurrence: normalizeRecurrenceData(item.recurrence),
|
|
createdAt: item.createdAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function normalizeRecurrenceData(recurrence) {
|
|
if (!recurrence || typeof recurrence !== 'object') return null;
|
|
const allowedTypes = ['daily', 'weekly', 'monthly'];
|
|
const type = allowedTypes.includes(recurrence.type) ? recurrence.type : 'daily';
|
|
const dayOfWeek = type === 'weekly' ? clampNumber(recurrence.dayOfWeek, 0, 6, 0) : null;
|
|
const dayOfMonth = type === 'monthly' ? clampNumber(recurrence.dayOfMonth, 1, 31, 1) : null;
|
|
return { type, dayOfWeek, dayOfMonth };
|
|
}
|
|
|
|
function clampNumber(value, min, max, fallback) {
|
|
const num = Number(value);
|
|
if (Number.isInteger(num) && num >= min && num <= max) {
|
|
return num;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// Make functions available globally for inline event handlers
|
|
window.toggleItem = toggleItem;
|
|
window.deleteItem = deleteItem;
|
|
window.changeList = setSelectedList;
|
|
window.onListDragStart = onListDragStart;
|
|
window.onListDragOver = onListDragOver;
|
|
window.onListDragLeave = onListDragLeave;
|
|
window.onListDrop = onListDrop;
|
|
window.onListPointerDown = onListPointerDown;
|
|
window.onListPointerUp = onListPointerUp;
|
|
window.onListPointerCancel = onListPointerCancel;
|
|
window.editItem = openEditModal;
|
|
window.openMoveItemModal = openMoveItemModal;
|
|
window.onItemDragStart = onItemDragStart;
|
|
window.onItemDragOver = onItemDragOver;
|
|
window.onItemDragLeave = onItemDragLeave;
|
|
window.onItemDrop = onItemDrop;
|
|
|
|
// Initial render
|
|
renderLists();
|
|
hideLoading();
|
|
|
|
function totalOpenItems() {
|
|
return checklistData.lists.reduce((sum, list) => {
|
|
return sum + list.items.filter(i => !i.completed).length;
|
|
}, 0);
|
|
}
|
|
|
|
// Check for recurring items every minute
|
|
setInterval(() => {
|
|
const oldTodoCount = totalOpenItems();
|
|
checkRecurringItems();
|
|
const newTodoCount = totalOpenItems();
|
|
if (oldTodoCount !== newTodoCount) {
|
|
renderLists();
|
|
}
|
|
}, 60000); // Check every minute
|
|
|
|
function hideLoading() {
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
if (overlay) {
|
|
overlay.style.display = 'none';
|
|
}
|
|
}
|
|
|