Todo/app.js

647 lines
22 KiB
JavaScript

// --- Basic Todo Model ---
const STORAGE_KEY = 'todos-v1';
const CATEGORY_KEY = 'todo-categories';
let todos = [];
let categories = [];
function loadTodos() {
todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
categories = JSON.parse(localStorage.getItem(CATEGORY_KEY) || '[]');
}
function saveTodos() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
localStorage.setItem(CATEGORY_KEY, JSON.stringify(categories));
}
function getNextId() {
return todos.length ? Math.max(...todos.map(t => t.id)) + 1 : 1;
}
function renderForm() {
const form = document.getElementById('todo-form');
form.innerHTML = `
<div class="form-section">
<label for="task">Task Description</label>
<input type="text" id="task" placeholder="Task Description" required>
</div>
<div class="form-section grid-3">
<div>
<label for="creation-date">Creation Date</label>
<input type="date" id="creation-date" title="Creation Date">
</div>
<div>
<label for="next-date">Next Planned Work Date</label>
<input type="date" id="next-date" title="Next Planned Work Date">
</div>
<div>
<label for="due-date">Due Date</label>
<input type="date" id="due-date" title="Due Date">
</div>
</div>
<div class="form-section grid-3">
<div>
<label for="status">Status</label>
<select id="status">
<option value="">(Blank)</option>
<option value="Busy">Busy</option>
<option value="Done">Done</option>
<option value="W4A">W4A</option>
</select>
</div>
<div>
<label for="urgent">Urgent (1-7)</label>
<input type="number" id="urgent" min="1" max="7" placeholder="Urgent (1-7)">
</div>
<div>
<label for="importance">Importance (1-7)</label>
<input type="number" id="importance" min="1" max="7" placeholder="Importance (1-7)">
</div>
</div>
<div class="form-section grid-2">
<div>
<label for="time-estimation">Time Estimation (min)</label>
<input type="number" id="time-estimation" min="0" placeholder="Time Est. (min)">
</div>
<div>
<label for="actual-time">Actual Time Spent (min)</label>
<input type="number" id="actual-time" min="0" placeholder="Actual Time (min)">
</div>
</div>
<div class="form-section grid-2">
<div>
<label for="category">Category</label>
<input type="text" id="category" placeholder="Category" list="category-list">
<datalist id="category-list"></datalist>
</div>
<div>
<label for="linked-tasks">Link with other tasks (IDs, comma separated)</label>
<input type="text" id="linked-tasks" placeholder="Link with other tasks (IDs, comma separated)">
</div>
</div>
<div class="form-section grid-2">
<div>
<label for="coms">Coms URL</label>
<input type="url" id="coms" placeholder="Coms URL">
</div>
<div>
<label for="link">Link URL</label>
<input type="url" id="link" placeholder="Link URL">
</div>
</div>
<div class="form-section">
<label for="comments">Comments</label>
<textarea id="comments" placeholder="Comments"></textarea>
</div>
<button type="submit" class="add-todo-btn">Add Todo</button>
`;
// Autocomplete for categories
const datalist = document.getElementById('category-list');
datalist.innerHTML = categories.map(cat => `<option value="${cat}">`).join('');
// Ensure addTodo is always bound after form render
form.removeEventListener('submit', addTodo); // prevent duplicates
form.addEventListener('submit', addTodo);
}
let sortState = { column: null, asc: true };
const sortableColumns = [
{ key: 'id', label: 'ID' },
{ key: 'task', label: 'Description' },
{ key: 'creationDate', label: 'Creation Date' },
{ key: 'nextDate', label: 'Next Date' },
{ key: 'dueDate', label: 'Due Date' },
{ key: 'urgent', label: 'Urgent' },
{ key: 'importance', label: 'Importance' },
{ key: 'prio', label: 'Prio' }
];
function sortTodos(column) {
if (sortState.column === column) {
sortState.asc = !sortState.asc;
} else {
sortState.column = column;
sortState.asc = true;
}
todos.sort((a, b) => {
let valA = a[column];
let valB = b[column];
// For date columns, compare as dates
if (["creationDate","nextDate","dueDate"].includes(column)) {
valA = valA || '';
valB = valB || '';
return sortState.asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
} else if (typeof valA === 'string') {
return sortState.asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
} else {
return sortState.asc ? valA - valB : valB - valA;
}
});
renderTodos();
}
function formatDisplayDate(dateStr) {
if (!dateStr) return '';
// Accepts YYYY-MM-DD or ISO string
let d = dateStr.length > 10 ? new Date(dateStr) : null;
let year, month, day;
if (d && !isNaN(d)) {
year = d.getFullYear().toString().slice(-2);
month = String(d.getMonth() + 1).padStart(2, '0');
day = String(d.getDate()).padStart(2, '0');
} else if (/^\d{4}-\d{2}-\d{2}/.test(dateStr)) {
year = dateStr.slice(2,4);
month = dateStr.slice(5,7);
day = dateStr.slice(8,10);
} else {
return dateStr;
}
return `${day}-${month}-${year}`;
}
function renderTodos() {
const list = document.getElementById('todos-list');
if (!todos.length) {
list.innerHTML = '<em>No todos yet.</em>';
return;
}
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'task', label: 'Description', sortable: true },
{ key: 'creationDate', label: 'Creation Date', sortable: true },
{ key: 'nextDate', label: 'Next Date', sortable: true },
{ key: 'dueDate', label: 'Due Date', sortable: true },
{ key: 'status', label: 'Status' },
{ key: 'urgent', label: 'Urgent', sortable: true },
{ key: 'importance', label: 'Importance', sortable: true },
{ key: 'prio', label: 'Prio', sortable: true },
{ key: 'timeEstimation', label: 'Time Est. (min)' },
{ key: 'actualTime', label: 'Actual Time (min)' },
{ key: 'category', label: 'Category' },
{ key: 'coms', label: 'Coms' },
{ key: 'link', label: 'Link' },
{ key: 'comments', label: 'Comments' },
{ key: 'linkedTasks', label: 'Linked Tasks' },
];
list.innerHTML = `
<div class="table-responsive">
<table class="todos-table">
<thead>
<tr>
${columns.map(col =>
col.sortable ?
`<th class="sortable" data-col="${col.key}">${col.label} ${sortState.column === col.key ? (sortState.asc ? '▲' : '▼') : ''}</th>` :
`<th>${col.label}</th>`
).join('')}
</tr>
</thead>
<tbody>
${todos.map((todo, rowIdx) => `
<tr>
${columns.map(col => renderEditableCell(todo, col.key, rowIdx)).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
// Add sorting event listeners
document.querySelectorAll('.todos-table th.sortable').forEach(th => {
th.addEventListener('click', () => sortTodos(th.dataset.col));
});
// Add modal editing listeners
document.querySelectorAll('.todos-table td[data-editable="true"]').forEach(td => {
td.addEventListener('click', function(e) {
const rowIdx = +td.dataset.row;
const key = td.dataset.key;
if (window.showEditTodoModal) {
window.showEditTodoModal(rowIdx, key);
}
});
});
}
function renderEditableCell(todo, key, rowIdx) {
// Non-editable fields: id, prio
const nonEditable = ['id', 'prio'];
// Editable fields
const editable = [
'task', 'creationDate', 'nextDate', 'dueDate', 'status', 'urgent', 'importance',
'timeEstimation', 'actualTime', 'category', 'coms', 'link', 'comments', 'linkedTasks'
];
let value = todo[key] || '';
if (["creationDate","nextDate","dueDate"].includes(key)) {
value = formatDisplayDate(todo[key]);
}
if (key === 'coms' && value) value = `<a href="${value}" target="_blank">Link</a>`;
if (key === 'link' && value) value = `<a href="${value}" target="_blank">Link</a>`;
if (key === 'status') return `<td class="status-${todo.status || 'blank'}" data-editable="true" data-row="${rowIdx}" data-key="${key}">${todo.status || ''}</td>`;
if (nonEditable.includes(key)) return `<td>${todo[key]}</td>`;
return `<td data-editable="true" data-row="${rowIdx}" data-key="${key}">${value}</td>`;
}
function handleCellEdit(e) {
const td = e.currentTarget;
const rowIdx = +td.dataset.row;
const key = td.dataset.key;
const todo = todos[rowIdx];
let input;
let oldValue = todo[key] || '';
// Helper to format date as YYYY-MM-DD
function formatDate(val) {
if (!val) return '';
if (val.length === 10 && val.match(/^\d{4}-\d{2}-\d{2}$/)) return val;
const d = new Date(val);
if (isNaN(d)) return '';
return d.toISOString().slice(0, 10);
}
if (["creationDate","nextDate","dueDate"].includes(key)) {
input = document.createElement('input');
input.type = 'date';
input.value = formatDate(oldValue);
let changed = false;
input.addEventListener('change', () => {
changed = true;
saveCellEdit(td, rowIdx, key, input.value);
});
input.addEventListener('blur', () => {
// If the user clicked the date picker, allow change to fire first
setTimeout(() => {
if (!changed) saveCellEdit(td, rowIdx, key, input.value);
}, 150);
});
} else if (["urgent","importance","timeEstimation","actualTime"].includes(key)) {
input = document.createElement('input');
input.type = 'number';
input.value = oldValue;
if (["urgent","importance"].includes(key)) {
input.min = 1; input.max = 7;
}
if (["timeEstimation","actualTime"].includes(key)) {
input.min = 0;
}
// Save on both change (for arrows) and blur (for manual input)
input.addEventListener('change', () => saveCellEdit(td, rowIdx, key, input.value));
input.addEventListener('blur', () => saveCellEdit(td, rowIdx, key, input.value));
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
input.blur();
}
});
} else if (key === 'status') {
input = document.createElement('select');
['','Busy','Done','W4A'].forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.text = opt || '(Blank)';
if (opt === oldValue) option.selected = true;
input.appendChild(option);
});
input.addEventListener('change', () => saveCellEdit(td, rowIdx, key, input.value));
} else if (key === 'category') {
input = document.createElement('input');
input.type = 'text';
input.value = oldValue;
input.setAttribute('list','category-list');
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
input.blur();
}
});
} else if (key === 'comments' || key === 'linkedTasks') {
input = document.createElement('textarea');
input.value = oldValue;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
input.blur();
}
});
} else {
input = document.createElement('input');
input.type = 'text';
input.value = oldValue;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
input.blur();
}
});
}
input.className = 'table-edit-input';
td.innerHTML = '';
td.appendChild(input);
// For date input, ensure the picker opens
if (["creationDate","nextDate","dueDate"].includes(key)) {
setTimeout(() => {
input.focus();
if (typeof input.showPicker === 'function') {
input.showPicker();
}
}, 0);
// For date fields, do NOT save or close on blur at all—only on change
} else {
input.focus();
input.addEventListener('blur', () => saveCellEdit(td, rowIdx, key, input.value));
}
}
function saveCellEdit(td, rowIdx, key, value) {
// Type conversions
if (["urgent","importance","timeEstimation","actualTime"].includes(key)) {
value = parseInt(value) || 0;
}
if (["creationDate","nextDate","dueDate"].includes(key)) {
value = value || '';
}
if (key === 'category') {
if (value && !categories.includes(value)) {
categories.push(value);
}
}
todos[rowIdx][key] = value;
// Recalculate prio if urgency or importance changed
if (key === 'urgent' || key === 'importance') {
todos[rowIdx].prio = (parseInt(todos[rowIdx].urgent) || 1) * (parseInt(todos[rowIdx].importance) || 1);
}
saveTodos();
renderTodos();
}
function addTodo(e) {
e.preventDefault();
const task = document.getElementById('task').value.trim();
const creationDate = document.getElementById('creation-date').value || new Date().toISOString().slice(0,10);
const nextDate = document.getElementById('next-date').value;
const dueDate = document.getElementById('due-date').value;
const status = document.getElementById('status').value;
const urgent = parseInt(document.getElementById('urgent').value) || 1;
const importance = parseInt(document.getElementById('importance').value) || 1;
const prio = urgent * importance;
const timeEstimation = parseInt(document.getElementById('time-estimation').value) || 0;
const actualTime = parseInt(document.getElementById('actual-time').value) || 0;
let category = document.getElementById('category').value.trim();
if (category && !categories.includes(category)) {
categories.push(category);
}
const coms = document.getElementById('coms').value.trim();
const link = document.getElementById('link').value.trim();
const comments = document.getElementById('comments').value.trim();
const linkedTasks = document.getElementById('linked-tasks').value.trim();
const todo = {
id: getNextId(),
creationDate,
nextDate,
dueDate,
status,
urgent,
importance,
prio,
timeEstimation,
actualTime,
category,
task,
coms,
link,
comments,
linkedTasks
};
todos.push(todo);
saveTodos();
renderForm();
renderTodos();
document.getElementById('todo-form').reset();
}
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
if (tab === 'list') {
document.getElementById('tab-list-btn').classList.add('active');
document.getElementById('tab-content-list').classList.add('active');
} else if (tab === 'form') {
document.getElementById('tab-form-btn').classList.add('active');
document.getElementById('tab-content-form').classList.add('active');
} else if (tab === 'admin') {
document.getElementById('tab-admin-btn').classList.add('active');
document.getElementById('tab-content-admin').classList.add('active');
}
}
function init() {
loadTodos();
renderForm();
renderTodos();
document.getElementById('tab-list-btn').addEventListener('click', () => switchTab('list'));
document.getElementById('tab-form-btn').addEventListener('click', () => switchTab('form'));
const adminBtn = document.getElementById('tab-admin-btn');
if (adminBtn) adminBtn.addEventListener('click', () => switchTab('admin'));
// --- Password Modal Helper ---
function showPasswordModal({title, confirm=false}) {
return new Promise((resolve, reject) => {
const modal = document.getElementById('password-modal');
const titleEl = document.getElementById('password-modal-title');
const input = document.getElementById('password-modal-input');
const confirmInput = document.getElementById('password-modal-confirm');
const okBtn = document.getElementById('password-modal-ok');
const cancelBtn = document.getElementById('password-modal-cancel');
const errorEl = document.getElementById('password-modal-error');
titleEl.textContent = title;
input.value = '';
confirmInput.value = '';
errorEl.style.display = 'none';
confirmInput.style.display = confirm ? '' : 'none';
modal.style.display = 'flex';
input.focus();
function cleanup() {
modal.style.display = 'none';
okBtn.onclick = null;
cancelBtn.onclick = null;
input.onkeydown = null;
confirmInput.onkeydown = null;
}
okBtn.onclick = () => {
const pw = input.value;
if (confirm) {
const pw2 = confirmInput.value;
if (!pw || !pw2) {
errorEl.textContent = 'Please enter and confirm your password.';
errorEl.style.display = 'block';
return;
}
if (pw !== pw2) {
errorEl.textContent = 'Passwords do not match.';
errorEl.style.display = 'block';
return;
}
} else {
if (!pw) {
errorEl.textContent = 'Please enter your password.';
errorEl.style.display = 'block';
return;
}
}
cleanup();
resolve(pw);
};
cancelBtn.onclick = () => { cleanup(); reject('cancel'); };
input.onkeydown = e => { if (e.key === 'Enter') okBtn.onclick(); };
confirmInput.onkeydown = e => { if (e.key === 'Enter') okBtn.onclick(); };
});
}
// --- Encryption helpers ---
async function getKeyFromPassword(password, salt) {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
'raw', enc.encode(password), {name: 'PBKDF2'}, false, ['deriveKey']);
return window.crypto.subtle.deriveKey(
{name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256'},
keyMaterial,
{name: 'AES-GCM', length: 256},
false,
['encrypt', 'decrypt']
);
}
async function encryptData(data, password) {
const enc = new TextEncoder();
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await getKeyFromPassword(password, salt);
const ciphertext = await window.crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
key,
enc.encode(data)
);
// Output as base64: salt + iv + ciphertext
function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
return toB64(salt) + ':' + toB64(iv) + ':' + toB64(ciphertext);
}
async function decryptData(b64, password) {
function fromB64(b64) { return Uint8Array.from(atob(b64), c => c.charCodeAt(0)); }
const [saltB64, ivB64, ctB64] = b64.split(':');
const salt = fromB64(saltB64);
const iv = fromB64(ivB64);
const ciphertext = fromB64(ctB64);
const key = await getKeyFromPassword(password, salt);
const dec = new TextDecoder();
const plaintext = await window.crypto.subtle.decrypt(
{name: 'AES-GCM', iv},
key,
ciphertext
);
return dec.decode(plaintext);
}
// Export JSON (password-protected)
const exportBtn = document.getElementById('export-json-btn');
if (exportBtn) exportBtn.addEventListener('click', async () => {
let password;
try {
password = await showPasswordModal({title: 'Set Export Password', confirm: true});
} catch { return; }
if (!password) return;
const data = {
todos,
categories
};
const json = JSON.stringify(data, null, 2);
try {
const encrypted = await encryptData(json, password);
const blob = new Blob([encrypted], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'todos-export.json';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
// Optionally show in <pre>
const pre = document.getElementById('export-json-output');
if (pre) {
pre.style.display = 'block';
pre.textContent = encrypted;
}
} catch (err) {
alert('Encryption failed: ' + err.message);
}
});
// Import JSON (password-protected)
const importBtn = document.getElementById('import-json-btn');
const importInput = document.getElementById('import-json-input');
if (importBtn && importInput) {
importBtn.addEventListener('click', () => importInput.click());
importInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
let password;
try {
password = await showPasswordModal({title: 'Enter Password to Import', confirm: false});
} catch { return; }
if (!password) return;
const reader = new FileReader();
reader.onload = async function(evt) {
try {
const encrypted = evt.target.result;
let json;
try {
json = await decryptData(encrypted, password);
} catch (err) {
throw new Error('Incorrect password or corrupt file.');
}
const data = JSON.parse(json);
if (!data.todos || !Array.isArray(data.todos)) throw new Error('Missing or invalid todos array.');
if (!data.categories || !Array.isArray(data.categories)) throw new Error('Missing or invalid categories array.');
todos = data.todos;
categories = data.categories;
saveTodos();
renderTodos();
renderForm();
alert('Import successful!');
switchTab('list');
} catch (err) {
alert('Import failed: ' + err.message);
}
};
reader.readAsText(file);
// Reset input so same file can be imported again
importInput.value = '';
});
}
// Delete all data
const delBtn = document.getElementById('delete-all-btn');
if (delBtn) delBtn.addEventListener('click', () => {
if (!confirm('Are you sure you want to delete ALL todos and categories? This cannot be undone.')) return;
todos = [];
categories = [];
saveTodos();
renderTodos();
renderForm();
alert('All data deleted.');
switchTab('list');
});
}
document.addEventListener('DOMContentLoaded', init);
// If you want to enable password, set a flag in localStorage and show/hide #password-section accordingly.