Files
recurring-checklist/app.js
T
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
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>
2026-09-24 13:00:57 -04:00

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';
}
}