1190 lines
58 KiB
JavaScript
1190 lines
58 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
console.log("OrgMyLife frontend initialized.");
|
|
|
|
// Fetch Dashboard Data
|
|
fetch("/api/dashboard")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log("Dashboard Data:", data);
|
|
|
|
// Populate Digest
|
|
document.getElementById("digest-content").textContent = data.digest;
|
|
|
|
// Populate Suggestions
|
|
const suggestionsContainer = document.getElementById("suggestions-container");
|
|
if (data.suggestions && data.suggestions.length > 0) {
|
|
suggestionsContainer.innerHTML = data.suggestions.map(s => {
|
|
const severityClass = s.severity === 'high' ? 'suggestion-high' :
|
|
s.severity === 'medium' ? 'suggestion-medium' : 'suggestion-low';
|
|
return `<div class="suggestion-box ${severityClass}">${s.content}</div>`;
|
|
}).join('');
|
|
} else {
|
|
suggestionsContainer.innerHTML = '<div class="suggestion-box suggestion-low">✅ All good! No urgent suggestions.</div>';
|
|
}
|
|
|
|
// Populate Priorities
|
|
const prioritiesList = document.getElementById("priorities-list");
|
|
prioritiesList.innerHTML = data.priorities.map(p =>
|
|
`<li>
|
|
<span class="${p.priority === 'Very High' || p.priority === 'High' ? 'priority-high' : ''}">${p.title}</span>
|
|
<small>(${p.priority})</small>
|
|
</li>`
|
|
).join('');
|
|
|
|
// Populate Calendar
|
|
const calendarList = document.getElementById("calendar-list");
|
|
calendarList.innerHTML = data.events.map(e =>
|
|
`<li>
|
|
<strong>${e.time}</strong><br>
|
|
${e.title}
|
|
</li>`
|
|
).join('');
|
|
})
|
|
.catch(err => {
|
|
console.error("Error fetching dashboard data:", err);
|
|
document.getElementById("digest-content").textContent = "Error loading data.";
|
|
});
|
|
|
|
// --- Sync Buttons ---
|
|
const syncBacklogBtn = document.getElementById("sync-backlog-btn");
|
|
if (syncBacklogBtn) {
|
|
syncBacklogBtn.addEventListener("click", () => {
|
|
syncBacklogBtn.textContent = "Syncing...";
|
|
syncBacklogBtn.disabled = true;
|
|
fetch("/api/sync/backlog", { method: "POST" })
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
alert(data.message);
|
|
window.location.reload();
|
|
})
|
|
.catch(err => {
|
|
console.error("Error syncing backlog:", err);
|
|
alert("Failed to sync backlog.");
|
|
syncBacklogBtn.textContent = "Sync Backlog";
|
|
syncBacklogBtn.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
function makeSyncHandler(btnId, endpoint, label) {
|
|
const btn = document.getElementById(btnId);
|
|
if (!btn) return;
|
|
btn.addEventListener("click", () => {
|
|
btn.textContent = "Syncing...";
|
|
btn.disabled = true;
|
|
fetch(endpoint, { method: "POST" })
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
alert(data.message);
|
|
window.location.reload();
|
|
})
|
|
.catch(err => {
|
|
console.error(`Error syncing ${label}:`, err);
|
|
alert(`Failed to sync ${label}.`);
|
|
btn.textContent = label;
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
makeSyncHandler("sync-todo-btn", "/api/sync/nextcloud-todo", "Sync ToDo");
|
|
makeSyncHandler("sync-email-btn", "/api/sync/email", "Sync Email");
|
|
makeSyncHandler("sync-gmail-btn", "/api/sync/gmail", "Sync Gmail");
|
|
|
|
// --- Sync All Button ---
|
|
const syncAllBtn = document.getElementById("sync-all-btn");
|
|
if (syncAllBtn) {
|
|
syncAllBtn.addEventListener("click", async () => {
|
|
syncAllBtn.textContent = "Syncing...";
|
|
syncAllBtn.disabled = true;
|
|
const endpoints = [
|
|
"/api/sync/nextcloud-todo",
|
|
"/api/sync/email",
|
|
"/api/sync/gmail",
|
|
"/api/sync/backlog"
|
|
];
|
|
try {
|
|
for (const ep of endpoints) {
|
|
await fetch(ep, { method: "POST" });
|
|
}
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error("Error in Sync All:", err);
|
|
alert("Sync All failed. Check console.");
|
|
syncAllBtn.textContent = "⟳ Sync All";
|
|
syncAllBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Navigation Tabs ---
|
|
const navDashboard = document.getElementById("nav-dashboard");
|
|
const navTasks = document.getElementById("nav-tasks");
|
|
const navDigest = document.getElementById("nav-digest");
|
|
const navRetro = document.getElementById("nav-retro");
|
|
const dashboardView = document.getElementById("dashboard");
|
|
const allTasksView = document.getElementById("all-tasks-view");
|
|
const digestView = document.getElementById("digest-view");
|
|
const retroView = document.getElementById("retro-view");
|
|
|
|
function switchTab(tab) {
|
|
// Hide all views
|
|
dashboardView.style.display = "none";
|
|
allTasksView.style.display = "none";
|
|
digestView.style.display = "none";
|
|
retroView.style.display = "none";
|
|
if (projectsView) projectsView.style.display = "none";
|
|
if (callsView) callsView.style.display = "none";
|
|
// Deactivate all nav buttons
|
|
document.querySelectorAll(".nav-btn").forEach(b => b.classList.remove("active"));
|
|
|
|
if (tab === "tasks") {
|
|
navTasks.classList.add("active");
|
|
allTasksView.style.display = "flex";
|
|
loadAllTasks();
|
|
} else if (tab === "digest") {
|
|
navDigest.classList.add("active");
|
|
digestView.style.display = "flex";
|
|
loadDigest();
|
|
} else if (tab === "retro") {
|
|
navRetro.classList.add("active");
|
|
retroView.style.display = "flex";
|
|
loadRetro();
|
|
} else if (tab === "projects") {
|
|
navProjects.classList.add("active");
|
|
projectsView.style.display = "flex";
|
|
loadProjects();
|
|
} else if (tab === "calls") {
|
|
navCalls.classList.add("active");
|
|
callsView.style.display = "flex";
|
|
loadCalls();
|
|
} else {
|
|
navDashboard.classList.add("active");
|
|
dashboardView.style.display = "flex";
|
|
}
|
|
}
|
|
|
|
navDashboard?.addEventListener("click", () => switchTab("dashboard"));
|
|
navTasks?.addEventListener("click", () => switchTab("tasks"));
|
|
navDigest?.addEventListener("click", () => switchTab("digest"));
|
|
navRetro?.addEventListener("click", () => switchTab("retro"));
|
|
|
|
const navProjects = document.getElementById("nav-projects");
|
|
const projectsView = document.getElementById("projects-view");
|
|
navProjects?.addEventListener("click", () => switchTab("projects"));
|
|
|
|
const navCalls = document.getElementById("nav-calls");
|
|
const callsView = document.getElementById("calls-view");
|
|
navCalls?.addEventListener("click", () => switchTab("calls"));
|
|
|
|
// --- Daily Digest ---
|
|
function loadDigest() {
|
|
fetch("/api/digest")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
document.getElementById("digest-greeting").textContent = `${data.greeting}! You have ${data.total_open} open tasks.`;
|
|
document.getElementById("digest-date").textContent = data.date;
|
|
|
|
const overdueList = document.getElementById("digest-overdue");
|
|
if (data.overdue.length > 0) {
|
|
overdueList.innerHTML = data.overdue.map(t =>
|
|
`<li><strong>${t.title}</strong> <small class="due-badge overdue">${t.days_overdue}d overdue</small></li>`
|
|
).join('');
|
|
} else {
|
|
overdueList.innerHTML = '<li style="color: #34a853;">✅ Nothing overdue!</li>';
|
|
}
|
|
|
|
const dueTodayList = document.getElementById("digest-due-today");
|
|
if (data.due_today.length > 0) {
|
|
dueTodayList.innerHTML = data.due_today.map(t =>
|
|
`<li>${t.title}</li>`
|
|
).join('');
|
|
} else {
|
|
dueTodayList.innerHTML = '<li style="color: #666;">No deadlines today.</li>';
|
|
}
|
|
|
|
const topList = document.getElementById("digest-top-tasks");
|
|
topList.innerHTML = data.top_tasks.map(t =>
|
|
`<li><span class="${t.priority === 'Very High' || t.priority === 'High' ? 'priority-high' : ''}">${t.title}</span> <small>(${t.priority})</small></li>`
|
|
).join('');
|
|
|
|
const eventsList = document.getElementById("digest-events");
|
|
if (data.events_today.length > 0) {
|
|
eventsList.innerHTML = data.events_today.map(e =>
|
|
`<li><strong>${e.time}</strong> ${e.title}</li>`
|
|
).join('');
|
|
} else {
|
|
eventsList.innerHTML = '<li style="color: #666;">No events today.</li>';
|
|
}
|
|
|
|
// Render pending calls section
|
|
const callsSection = document.getElementById("digest-calls-section");
|
|
const callsList = document.getElementById("digest-calls");
|
|
if (data.pending_calls && data.pending_calls.length > 0) {
|
|
callsSection.style.display = "";
|
|
callsList.innerHTML = data.pending_calls.map(c =>
|
|
`<li><strong>${c.person}</strong> <span style="color: #666;">— ${c.reason}</span></li>`
|
|
).join('');
|
|
} else {
|
|
callsSection.style.display = "none";
|
|
callsList.innerHTML = '';
|
|
}
|
|
})
|
|
.catch(err => console.error("Error loading digest:", err));
|
|
}
|
|
|
|
// --- Weekly Retro ---
|
|
function loadRetro() {
|
|
fetch("/api/retro")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
document.getElementById("retro-period").textContent = data.period;
|
|
|
|
document.getElementById("retro-summary").innerHTML = `
|
|
<div class="retro-stats">
|
|
<div class="retro-stat"><span class="retro-num">${data.completed}</span><br>Completed</div>
|
|
<div class="retro-stat"><span class="retro-num">${data.created}</span><br>Created</div>
|
|
<div class="retro-stat"><span class="retro-num">${data.total_open}</span><br>Open</div>
|
|
<div class="retro-stat"><span class="retro-num">${data.in_review}</span><br>In Review</div>
|
|
</div>
|
|
`;
|
|
|
|
const healthColor = data.health === 'growing' ? '#d93025' : data.health === 'shrinking' ? '#34a853' : '#ea8600';
|
|
document.getElementById("retro-health").innerHTML = `
|
|
<p style="font-size: 1.2rem; color: ${healthColor}; font-weight: 600;">
|
|
${data.health === 'growing' ? '📈 Backlog growing' : data.health === 'shrinking' ? '📉 Backlog shrinking' : '➡️ Backlog stable'}
|
|
</p>
|
|
<p>Net change: ${data.net_change > 0 ? '+' : ''}${data.net_change} tasks</p>
|
|
<p>${data.events_attended} calendar events this week</p>
|
|
`;
|
|
|
|
document.getElementById("retro-priorities").innerHTML = Object.entries(data.priority_breakdown)
|
|
.map(([label, count]) => `<div style="margin: 4px 0;"><strong>${label}:</strong> ${count}</div>`)
|
|
.join('');
|
|
|
|
const overdueList = document.getElementById("retro-overdue");
|
|
if (data.overdue.length > 0) {
|
|
overdueList.innerHTML = data.overdue.map(t =>
|
|
`<li><strong>${t.title}</strong> <small class="due-badge overdue">${t.days_overdue}d</small></li>`
|
|
).join('');
|
|
} else {
|
|
overdueList.innerHTML = '<li style="color: #34a853;">✅ Nothing overdue!</li>';
|
|
}
|
|
})
|
|
.catch(err => console.error("Error loading retro:", err));
|
|
}
|
|
|
|
// --- Calls Tab ---
|
|
function loadCalls() {
|
|
const callsList = document.getElementById("calls-list");
|
|
callsList.innerHTML = "<p>Loading calls...</p>";
|
|
fetch("/api/calls")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (!data.calls || data.calls.length === 0) {
|
|
callsList.innerHTML = "<p style=\"color: #666;\">No pending calls.</p>";
|
|
return;
|
|
}
|
|
callsList.innerHTML = data.calls.map(c => {
|
|
const phoneDisplay = c.phone_number
|
|
? `<a href="tel:${c.phone_number}">${c.phone_number}</a>`
|
|
: '<span style="color: #999;">No number</span>';
|
|
const priorityLabels = { 1: 'Very High', 2: 'High', 3: 'Medium', 4: 'Low' };
|
|
return `
|
|
<div class="call-item" data-id="${c.id}" style="padding: 12px 0; border-bottom: 1px solid #eee;">
|
|
<div class="call-display" style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<strong>${c.person}</strong>
|
|
<div style="font-size: 0.85rem; color: #666;">${c.reason}</div>
|
|
<div style="font-size: 0.85rem; margin-top: 4px;">📞 ${phoneDisplay}</div>
|
|
</div>
|
|
<div style="display: flex; gap: 6px; align-items: center;">
|
|
<small class="priority-badge priority-${c.priority}">${priorityLabels[c.priority] || 'Medium'}</small>
|
|
<button class="btn-call-edit btn" data-id="${c.id}" title="Edit" style="padding: 4px 10px; cursor: pointer;">✎ Edit</button>
|
|
<button class="btn-call-done btn" data-id="${c.id}" data-task-id="${c.original_task_id || ''}" title="Mark done" style="padding: 4px 10px; cursor: pointer;">✓ Done</button>
|
|
<button class="btn-call-delete btn" data-id="${c.id}" title="Delete" style="padding: 4px 10px; cursor: pointer;">✕</button>
|
|
</div>
|
|
</div>
|
|
<div class="call-edit-form" style="display: none; margin-top: 8px;">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
|
<input type="text" class="edit-person" value="${c.person.replace(/"/g, '"')}" placeholder="Person" style="padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px;">
|
|
<input type="text" class="edit-reason" value="${c.reason.replace(/"/g, '"')}" placeholder="Reason" style="padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px;">
|
|
<input type="text" class="edit-phone" value="${(c.phone_number || '').replace(/"/g, '"')}" placeholder="Phone number" style="padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px;">
|
|
<select class="edit-priority" style="padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px;">
|
|
<option value="1" ${c.priority === 1 ? 'selected' : ''}>Very High</option>
|
|
<option value="2" ${c.priority === 2 ? 'selected' : ''}>High</option>
|
|
<option value="3" ${c.priority === 3 ? 'selected' : ''}>Medium</option>
|
|
<option value="4" ${c.priority === 4 ? 'selected' : ''}>Low</option>
|
|
</select>
|
|
</div>
|
|
<div style="display: flex; gap: 6px;">
|
|
<button class="btn-call-save btn" data-id="${c.id}" style="padding: 4px 12px; cursor: pointer; background: #1a73e8; color: #fff; border: none; border-radius: 4px;">Save</button>
|
|
<button class="btn-call-cancel btn" data-id="${c.id}" style="padding: 4px 12px; cursor: pointer; border: 1px solid #ddd; border-radius: 4px;">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Edit handlers
|
|
callsList.querySelectorAll(".btn-call-edit").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const item = btn.closest(".call-item");
|
|
item.querySelector(".call-display").style.display = "none";
|
|
item.querySelector(".call-edit-form").style.display = "block";
|
|
});
|
|
});
|
|
|
|
// Cancel handlers
|
|
callsList.querySelectorAll(".btn-call-cancel").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const item = btn.closest(".call-item");
|
|
item.querySelector(".call-display").style.display = "flex";
|
|
item.querySelector(".call-edit-form").style.display = "none";
|
|
});
|
|
});
|
|
|
|
// Save handlers
|
|
callsList.querySelectorAll(".btn-call-save").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const item = btn.closest(".call-item");
|
|
const callId = btn.dataset.id;
|
|
const person = item.querySelector(".edit-person").value.trim();
|
|
const reason = item.querySelector(".edit-reason").value.trim();
|
|
const phone_number = item.querySelector(".edit-phone").value.trim();
|
|
const priority = parseInt(item.querySelector(".edit-priority").value);
|
|
|
|
if (!person) { alert("Person is required."); return; }
|
|
if (!reason) { alert("Reason is required."); return; }
|
|
|
|
fetch(`/api/calls/${callId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ person, reason, phone_number, priority })
|
|
})
|
|
.then(res => { if (!res.ok) throw new Error("Update failed"); return res.json(); })
|
|
.then(() => loadCalls())
|
|
.catch(err => { console.error("Error updating call:", err); alert("Failed to update call."); });
|
|
});
|
|
});
|
|
|
|
// Done handlers
|
|
callsList.querySelectorAll(".btn-call-done").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const callId = btn.dataset.id;
|
|
const taskId = btn.dataset.taskId;
|
|
let resolveTask = false;
|
|
if (taskId) {
|
|
resolveTask = confirm("Also mark the original task as completed?");
|
|
}
|
|
fetch(`/api/calls/${callId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "done", resolve_task: resolveTask })
|
|
})
|
|
.then(res => res.json())
|
|
.then(() => loadCalls())
|
|
.catch(err => console.error("Error completing call:", err));
|
|
});
|
|
});
|
|
|
|
// Delete handlers
|
|
callsList.querySelectorAll(".btn-call-delete").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
if (!confirm("Delete this call item?")) return;
|
|
fetch(`/api/calls/${btn.dataset.id}`, { method: "DELETE" })
|
|
.then(res => res.json())
|
|
.then(() => loadCalls())
|
|
.catch(err => console.error("Error deleting call:", err));
|
|
});
|
|
});
|
|
})
|
|
.catch(err => {
|
|
console.error("Error loading calls:", err);
|
|
callsList.innerHTML = "<p style=\"color: #d93025;\">Error loading calls.</p>";
|
|
});
|
|
}
|
|
|
|
// --- Sort Buttons ---
|
|
document.querySelectorAll(".sort-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
document.querySelectorAll(".sort-btn").forEach(b => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
currentSort = btn.dataset.sort;
|
|
loadAllTasks();
|
|
});
|
|
});
|
|
|
|
// --- Filter Dropdowns ---
|
|
const filterOrigin = document.getElementById("filter-origin");
|
|
const filterAgent = document.getElementById("filter-agent");
|
|
const filterLabel = document.getElementById("filter-label");
|
|
filterOrigin?.addEventListener("change", () => loadAllTasks());
|
|
filterAgent?.addEventListener("change", () => loadAllTasks());
|
|
filterLabel?.addEventListener("change", () => loadAllTasks());
|
|
|
|
// --- Edit Form State ---
|
|
const addTaskForm = document.getElementById("add-task-form");
|
|
const formHeading = document.getElementById("form-heading");
|
|
const formSubmitBtn = document.getElementById("form-submit-btn");
|
|
const formCancelBtn = document.getElementById("form-cancel-btn");
|
|
const taskEditId = document.getElementById("task-edit-id");
|
|
const taskTitleInput = document.getElementById("task-title");
|
|
const taskDescInput = document.getElementById("task-desc");
|
|
const taskPrioritySelect = document.getElementById("task-priority");
|
|
const taskDueInput = document.getElementById("task-due");
|
|
const taskLabelsInput = document.getElementById("task-labels");
|
|
|
|
function enterEditMode(task) {
|
|
taskEditId.value = task.id;
|
|
taskTitleInput.value = task.title;
|
|
taskDescInput.value = task.description || "";
|
|
taskPrioritySelect.value = task.priority_value;
|
|
taskDueInput.value = task.due_date || "";
|
|
if (taskLabelsInput) taskLabelsInput.value = task.labels || "";
|
|
formHeading.textContent = "Edit Task";
|
|
formSubmitBtn.textContent = "Save";
|
|
formCancelBtn.style.display = "inline-block";
|
|
taskTitleInput.focus();
|
|
}
|
|
|
|
function exitEditMode() {
|
|
taskEditId.value = "";
|
|
addTaskForm.reset();
|
|
formHeading.textContent = "Add New Task";
|
|
formSubmitBtn.textContent = "Add";
|
|
formCancelBtn.style.display = "none";
|
|
}
|
|
|
|
formCancelBtn?.addEventListener("click", exitEditMode);
|
|
|
|
// --- All Tasks Logic ---
|
|
let currentSort = "";
|
|
|
|
function loadAllTasks() {
|
|
const params = new URLSearchParams();
|
|
if (currentSort) params.set("sort", currentSort);
|
|
const originVal = document.getElementById("filter-origin")?.value;
|
|
const agentVal = document.getElementById("filter-agent")?.value;
|
|
const labelVal = document.getElementById("filter-label")?.value;
|
|
if (originVal) params.set("filter_origin", originVal);
|
|
if (agentVal) params.set("filter_agent", agentVal);
|
|
if (labelVal) params.set("filter_label", labelVal);
|
|
const url = `/api/tasks${params.toString() ? '?' + params.toString() : ''}`;
|
|
fetch(url)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const allTasksList = document.getElementById("all-tasks-list");
|
|
if (data.tasks.length === 0) {
|
|
allTasksList.innerHTML = "<li>No open tasks.</li>";
|
|
return;
|
|
}
|
|
|
|
allTasksList.innerHTML = data.tasks.map(t => {
|
|
// Build origin badge with link
|
|
let originBadge = '';
|
|
if (t.origin) {
|
|
let originLabel = t.origin;
|
|
if (t.origin.startsWith('nextcloud_todo')) {
|
|
originLabel = '☁️ ' + (t.origin.split(':')[1] || 'Nextcloud');
|
|
} else if (t.origin === 'dhive_email') {
|
|
originLabel = '✉️ dHive Mail';
|
|
} else if (t.origin === 'gmail') {
|
|
originLabel = '✉️ Gmail';
|
|
} else if (t.origin === 'manual') {
|
|
originLabel = '✏️ Manual';
|
|
} else {
|
|
originLabel = '📋 ' + t.origin;
|
|
}
|
|
if (t.source_url) {
|
|
originBadge = `<a href="${t.source_url}" target="_blank" class="origin-badge origin-link" title="Open in source app">${originLabel} ↗</a>`;
|
|
} else {
|
|
originBadge = `<small class="origin-badge">${originLabel}</small>`;
|
|
}
|
|
}
|
|
|
|
const labelBadges = t.labels ? t.labels.split(',').map(l => l.trim()).filter(Boolean).map(l =>
|
|
`<span class="label-badge">${l}</span>`
|
|
).join('') : '';
|
|
|
|
return `
|
|
<li class="task-item ${t.agent_ready ? 'agent-ready' : ''} ${t.has_conflict ? 'has-conflict' : ''}" draggable="true" data-id="${t.id}" data-priority-value="${t.priority_value}">
|
|
<span class="drag-handle" title="Drag to reorder">⠿</span>
|
|
<span class="move-arrows">
|
|
<button class="btn-move-top" data-id="${t.id}" title="Move to top">⤒</button>
|
|
<button class="btn-move-bottom" data-id="${t.id}" title="Move to bottom">⤓</button>
|
|
</span>
|
|
<div class="task-content">
|
|
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
|
<div>
|
|
<strong class="${t.priority === 'Very High' || t.priority === 'High' ? 'priority-high' : ''}"><span style="color: #999; font-weight: normal; font-size: 0.75rem;">#${t.id}</span> ${t.title}</strong>
|
|
${t.has_conflict ? `<span class="conflict-badge" data-id="${t.id}" title="Sync conflict — click to resolve">⚠️</span>` : ''}
|
|
${t.sender ? `<div class="task-sender">From: ${t.sender}</div>` : ''}
|
|
${labelBadges ? `<div style="margin-top: 3px;">${labelBadges}</div>` : ''}
|
|
</div>
|
|
<div class="task-actions">
|
|
${originBadge}
|
|
${t.due_date ? `<small class="due-badge ${new Date(t.due_date) < new Date().toISOString().split('T')[0] ? 'overdue' : ''}">${t.due_date}</small>` : ''}
|
|
<small class="priority-badge priority-${t.priority_value}">${t.priority}</small>
|
|
<button class="btn-agent ${t.agent_ready ? 'active' : ''}" data-id="${t.id}" data-ready="${t.agent_ready}" title="${t.agent_ready ? 'Remove from AI queue' : 'Send to AI Orchestrator'}">🤖</button>
|
|
<button class="btn-snooze" data-id="${t.id}" title="Remind me later">⏰</button>
|
|
<button class="btn-call" data-id="${t.id}" title="Add to Call List">📞</button>
|
|
<button class="btn-note" data-id="${t.id}" title="Send to Notes">📝</button>
|
|
<button class="btn-edit" data-task='${JSON.stringify(t).replace(/'/g, "'")}' title="Edit">✎</button>
|
|
<button class="btn-done" data-id="${t.id}" title="Mark done">✓</button>
|
|
<button class="btn-delete" data-id="${t.id}" title="Won't do (archive/dismiss)">✕</button>
|
|
<button class="btn-purge" data-id="${t.id}" title="Delete permanently (remove from server)">🗑</button>
|
|
</div>
|
|
</div>
|
|
${t.description && !t.sender ? `<div style="font-size: 0.85rem; color: #666; margin-top: 4px; white-space: pre-wrap;">${t.description}</div>` : ''}
|
|
${t.created_at ? `<div style="font-size: 0.75rem; color: #999; margin-top: 3px;">Created: ${t.created_at}</div>` : ''}
|
|
</div>
|
|
</li>
|
|
`}).join('');
|
|
|
|
// Attach edit handlers
|
|
allTasksList.querySelectorAll(".btn-edit").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const task = JSON.parse(btn.dataset.task);
|
|
enterEditMode(task);
|
|
});
|
|
});
|
|
|
|
// Attach done handlers
|
|
allTasksList.querySelectorAll(".btn-done").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
fetch(`/api/tasks/${taskId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "completed" })
|
|
})
|
|
.then(res => res.json())
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error completing task:", err));
|
|
});
|
|
});
|
|
|
|
// Attach delete handlers (won't do / dismiss)
|
|
allTasksList.querySelectorAll(".btn-delete").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
if (!confirm("Won't do this task? (archives email, dismisses from board)")) return;
|
|
fetch(`/api/tasks/${taskId}`, {
|
|
method: "DELETE"
|
|
})
|
|
.then(res => res.json())
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error dismissing task:", err));
|
|
});
|
|
});
|
|
|
|
// Attach purge handlers (delete from server permanently)
|
|
allTasksList.querySelectorAll(".btn-purge").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
if (!confirm("Delete permanently? This removes the email/task from the server.")) return;
|
|
fetch(`/api/tasks/${taskId}/purge`, {
|
|
method: "POST"
|
|
})
|
|
.then(res => res.json())
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error purging task:", err));
|
|
});
|
|
});
|
|
|
|
// Attach agent-ready toggle handlers
|
|
allTasksList.querySelectorAll(".btn-agent").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
const currentlyReady = btn.dataset.ready === "true" || btn.dataset.ready === "True";
|
|
fetch(`/api/tasks/${taskId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ agent_ready: !currentlyReady })
|
|
})
|
|
.then(res => res.json())
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error toggling agent_ready:", err));
|
|
});
|
|
});
|
|
|
|
// Attach call list handlers
|
|
allTasksList.querySelectorAll(".btn-call").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
fetch("/api/calls", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ task_id: parseInt(taskId) })
|
|
})
|
|
.then(res => {
|
|
if (!res.ok) throw new Error("Failed to add to call list");
|
|
return res.json();
|
|
})
|
|
.then(() => {
|
|
btn.textContent = "✓";
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}, 1000);
|
|
})
|
|
.catch(err => {
|
|
console.error("Error adding to call list:", err);
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
});
|
|
|
|
// Attach send-to-notes handlers
|
|
allTasksList.querySelectorAll(".btn-note").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = "⏳";
|
|
fetch(`/api/tasks/${taskId}/to-note`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" }
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.status === "success") {
|
|
btn.textContent = "✓";
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}, 1500);
|
|
} else {
|
|
alert("Failed to send to notes: " + (data.message || "Unknown error"));
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Error sending to notes:", err);
|
|
alert("Error sending to notes");
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
});
|
|
|
|
// Attach move-to-top handlers
|
|
allTasksList.querySelectorAll(".btn-move-top").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
fetch(`/api/tasks/${btn.dataset.id}/move?direction=top`, { method: "PUT" })
|
|
.then(res => res.json())
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error moving task:", err));
|
|
});
|
|
});
|
|
|
|
// Attach move-to-bottom handlers
|
|
allTasksList.querySelectorAll(".btn-move-bottom").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
fetch(`/api/tasks/${btn.dataset.id}/move?direction=bottom`, { method: "PUT" })
|
|
.then(res => res.json())
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error moving task:", err));
|
|
});
|
|
});
|
|
|
|
// Attach conflict badge handlers
|
|
allTasksList.querySelectorAll(".conflict-badge").forEach(badge => {
|
|
badge.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const taskId = badge.dataset.id;
|
|
showConflictModal(taskId);
|
|
});
|
|
});
|
|
|
|
// Attach snooze (remind me later) handlers
|
|
allTasksList.querySelectorAll(".btn-snooze").forEach(btn => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
// Remove any existing snooze popup
|
|
document.querySelectorAll(".snooze-popup").forEach(p => p.remove());
|
|
|
|
const taskId = btn.dataset.id;
|
|
const popup = document.createElement("div");
|
|
popup.className = "snooze-popup";
|
|
popup.style.cssText = "position: absolute; background: white; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 8px; z-index: 1000; font-size: 0.85rem;";
|
|
|
|
// Calculate dates
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
const nextMonday = new Date();
|
|
nextMonday.setDate(nextMonday.getDate() + ((8 - nextMonday.getDay()) % 7 || 7));
|
|
const nextMonth = new Date();
|
|
nextMonth.setMonth(nextMonth.getMonth() + 1, 1);
|
|
|
|
const fmt = d => d.toISOString().split("T")[0];
|
|
|
|
popup.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; gap: 4px; min-width: 150px;">
|
|
<button class="snooze-opt" data-date="${fmt(tomorrow)}" style="text-align: left; padding: 6px 8px; border: none; background: none; cursor: pointer; border-radius: 4px;">Tomorrow</button>
|
|
<button class="snooze-opt" data-date="${fmt(nextMonday)}" style="text-align: left; padding: 6px 8px; border: none; background: none; cursor: pointer; border-radius: 4px;">Next Monday</button>
|
|
<button class="snooze-opt" data-date="${fmt(nextMonth)}" style="text-align: left; padding: 6px 8px; border: none; background: none; cursor: pointer; border-radius: 4px;">Next Month</button>
|
|
<hr style="margin: 4px 0; border: none; border-top: 1px solid #eee;">
|
|
<label style="padding: 4px 8px; font-size: 0.8rem; color: #666;">Pick a date:</label>
|
|
<input type="date" class="snooze-date-pick" style="padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px;">
|
|
</div>
|
|
`;
|
|
|
|
btn.style.position = "relative";
|
|
btn.parentElement.style.position = "relative";
|
|
btn.parentElement.appendChild(popup);
|
|
|
|
// Option click handlers
|
|
popup.querySelectorAll(".snooze-opt").forEach(opt => {
|
|
opt.addEventListener("mouseenter", () => { opt.style.background = "#e8f0fe"; });
|
|
opt.addEventListener("mouseleave", () => { opt.style.background = "none"; });
|
|
opt.addEventListener("click", () => {
|
|
snoozeTask(taskId, opt.dataset.date);
|
|
popup.remove();
|
|
});
|
|
});
|
|
|
|
// Date picker
|
|
popup.querySelector(".snooze-date-pick").addEventListener("change", (ev) => {
|
|
if (ev.target.value) {
|
|
snoozeTask(taskId, ev.target.value);
|
|
popup.remove();
|
|
}
|
|
});
|
|
|
|
// Close on outside click
|
|
setTimeout(() => {
|
|
document.addEventListener("click", function closePopup(ev) {
|
|
if (!popup.contains(ev.target) && ev.target !== btn) {
|
|
popup.remove();
|
|
document.removeEventListener("click", closePopup);
|
|
}
|
|
});
|
|
}, 10);
|
|
});
|
|
});
|
|
|
|
function snoozeTask(taskId, dateStr) {
|
|
fetch(`/api/tasks/${taskId}/snooze`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ until: dateStr })
|
|
})
|
|
.then(res => {
|
|
if (!res.ok) throw new Error("Snooze failed");
|
|
return res.json();
|
|
})
|
|
.then(() => loadAllTasks())
|
|
.catch(err => console.error("Error snoozing task:", err));
|
|
}
|
|
|
|
// Attach drag-and-drop handlers
|
|
initDragAndDrop(allTasksList);
|
|
})
|
|
.catch(err => {
|
|
console.error("Error loading all tasks:", err);
|
|
document.getElementById("all-tasks-list").innerHTML = "<li>Error loading tasks.</li>";
|
|
});
|
|
}
|
|
|
|
// --- Drag and Drop ---
|
|
let dragSrcEl = null;
|
|
|
|
function initDragAndDrop(list) {
|
|
const items = list.querySelectorAll(".task-item");
|
|
|
|
items.forEach(item => {
|
|
item.addEventListener("dragstart", handleDragStart);
|
|
item.addEventListener("dragover", handleDragOver);
|
|
item.addEventListener("dragenter", handleDragEnter);
|
|
item.addEventListener("dragleave", handleDragLeave);
|
|
item.addEventListener("drop", handleDrop);
|
|
item.addEventListener("dragend", handleDragEnd);
|
|
});
|
|
}
|
|
|
|
function handleDragStart(e) {
|
|
dragSrcEl = this;
|
|
this.classList.add("dragging");
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", this.dataset.id);
|
|
}
|
|
|
|
function handleDragOver(e) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
}
|
|
|
|
function handleDragEnter(e) {
|
|
e.preventDefault();
|
|
this.classList.add("drag-over");
|
|
}
|
|
|
|
function handleDragLeave() {
|
|
this.classList.remove("drag-over");
|
|
}
|
|
|
|
function handleDrop(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.classList.remove("drag-over");
|
|
|
|
if (dragSrcEl !== this) {
|
|
const list = this.parentNode;
|
|
const allItems = [...list.querySelectorAll(".task-item")];
|
|
const fromIndex = allItems.indexOf(dragSrcEl);
|
|
const toIndex = allItems.indexOf(this);
|
|
|
|
if (fromIndex < toIndex) {
|
|
list.insertBefore(dragSrcEl, this.nextSibling);
|
|
} else {
|
|
list.insertBefore(dragSrcEl, this);
|
|
}
|
|
|
|
// Send new order to backend
|
|
const reordered = [...list.querySelectorAll(".task-item")].map((item, idx) => ({
|
|
id: parseInt(item.dataset.id),
|
|
position: idx
|
|
}));
|
|
|
|
fetch("/api/tasks/reorder", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ order: reordered })
|
|
})
|
|
.catch(err => console.error("Error saving reorder:", err));
|
|
}
|
|
}
|
|
|
|
function handleDragEnd() {
|
|
this.classList.remove("dragging");
|
|
document.querySelectorAll(".task-item").forEach(item => {
|
|
item.classList.remove("drag-over");
|
|
});
|
|
}
|
|
|
|
// --- Form Submit (Create or Update) ---
|
|
addTaskForm?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
|
|
const editId = taskEditId.value;
|
|
const title = taskTitleInput.value;
|
|
const desc = taskDescInput.value;
|
|
const priority = parseInt(taskPrioritySelect.value, 10);
|
|
const dueDate = taskDueInput.value || null;
|
|
const labels = taskLabelsInput ? taskLabelsInput.value : "";
|
|
|
|
if (editId) {
|
|
// Update existing task
|
|
fetch(`/api/tasks/${editId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, description: desc, priority, due_date: dueDate || "", labels })
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.status === "success") {
|
|
exitEditMode();
|
|
loadAllTasks();
|
|
loadLabelFilter();
|
|
} else {
|
|
alert("Failed to update task.");
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Error updating task:", err);
|
|
alert("Error updating task.");
|
|
});
|
|
} else {
|
|
// Create new task
|
|
fetch("/api/tasks", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, description: desc, priority, due_date: dueDate, labels: labels || null })
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.status === "success") {
|
|
addTaskForm.reset();
|
|
loadAllTasks();
|
|
loadLabelFilter();
|
|
} else {
|
|
alert("Failed to add task.");
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Error adding task:", err);
|
|
alert("Error adding task.");
|
|
});
|
|
}
|
|
});
|
|
|
|
// --- Label Filter Population ---
|
|
function loadLabelFilter() {
|
|
fetch("/api/tasks/labels")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const select = document.getElementById("filter-label");
|
|
if (!select) return;
|
|
const currentVal = select.value;
|
|
select.innerHTML = '<option value="">All labels</option>';
|
|
(data.labels || []).forEach(label => {
|
|
const opt = document.createElement("option");
|
|
opt.value = label;
|
|
opt.textContent = label;
|
|
select.appendChild(opt);
|
|
});
|
|
// Restore previous selection if still valid
|
|
if (currentVal && data.labels && data.labels.includes(currentVal)) {
|
|
select.value = currentVal;
|
|
}
|
|
})
|
|
.catch(err => console.error("Error loading labels:", err));
|
|
}
|
|
|
|
// Load labels on init
|
|
loadLabelFilter();
|
|
|
|
// --- Projects Kanban Board ---
|
|
function loadProjects() {
|
|
fetch("/api/projects")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const statuses = ["backlog", "ready", "in_progress", "blocked", "review", "done"];
|
|
statuses.forEach(status => {
|
|
const container = document.getElementById(`kanban-${status}`);
|
|
const tasks = data.tasks.filter(t => t.status === status);
|
|
container.innerHTML = tasks.map(t => `
|
|
<div class="kanban-card" data-id="${t.id}">
|
|
<div class="kanban-card-header">
|
|
<strong>${t.title}</strong>
|
|
<button class="btn-delete-project" data-id="${t.id}" title="Delete">✕</button>
|
|
</div>
|
|
${t.repo ? `<small class="kanban-repo">${t.repo}</small>` : ''}
|
|
${t.description ? `<p class="kanban-desc">${t.description}</p>` : ''}
|
|
<div class="kanban-actions">
|
|
${status !== 'backlog' ? `<button class="kanban-move" data-id="${t.id}" data-dir="left" title="Move left">←</button>` : ''}
|
|
${status !== 'done' ? `<button class="kanban-move" data-id="${t.id}" data-dir="right" title="Move right">→</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('') || '<p class="kanban-empty">No tasks</p>';
|
|
});
|
|
|
|
// Move handlers
|
|
document.querySelectorAll(".kanban-move").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const taskId = btn.dataset.id;
|
|
const dir = btn.dataset.dir;
|
|
const card = btn.closest(".kanban-card");
|
|
const column = card.closest(".kanban-column");
|
|
const currentStatus = column.dataset.status;
|
|
const order = ["backlog", "ready", "in_progress", "blocked", "review", "done"];
|
|
const idx = order.indexOf(currentStatus);
|
|
const newStatus = dir === "right" ? order[idx + 1] : order[idx - 1];
|
|
if (!newStatus) return;
|
|
|
|
fetch(`/api/projects/${taskId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: newStatus })
|
|
})
|
|
.then(res => {
|
|
if (!res.ok) {
|
|
console.error("Failed to move project task:", res.status, res.statusText);
|
|
return res.json().then(d => { throw new Error(d.detail || "Move failed"); });
|
|
}
|
|
return res.json();
|
|
})
|
|
.then(() => loadProjects())
|
|
.catch(err => {
|
|
console.error("Error moving project task:", err);
|
|
alert("Failed to move task: " + err.message);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Delete handlers
|
|
document.querySelectorAll(".btn-delete-project").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
if (!confirm("Delete this project task?")) return;
|
|
fetch(`/api/projects/${btn.dataset.id}`, { method: "DELETE" })
|
|
.then(res => res.json())
|
|
.then(() => loadProjects())
|
|
.catch(err => console.error("Error deleting project task:", err));
|
|
});
|
|
});
|
|
})
|
|
.catch(err => console.error("Error loading projects:", err));
|
|
}
|
|
|
|
// Add project task form
|
|
const addProjectForm = document.getElementById("add-project-task-form");
|
|
addProjectForm?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const title = document.getElementById("pt-title").value;
|
|
const repo = document.getElementById("pt-repo").value;
|
|
fetch("/api/projects/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, repo })
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.status === "success") {
|
|
addProjectForm.reset();
|
|
loadProjects();
|
|
}
|
|
})
|
|
.catch(err => console.error("Error adding project task:", err));
|
|
});
|
|
|
|
// --- Add Call Form ---
|
|
const addCallForm = document.getElementById("add-call-form");
|
|
addCallForm?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const person = document.getElementById("call-person").value;
|
|
const reason = document.getElementById("call-reason").value;
|
|
const phone_number = document.getElementById("call-phone").value || null;
|
|
const priority = parseInt(document.getElementById("call-priority").value, 10);
|
|
fetch("/api/calls", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ person, reason, phone_number, priority })
|
|
})
|
|
.then(res => {
|
|
if (!res.ok) throw new Error("Failed to add call");
|
|
return res.json();
|
|
})
|
|
.then(() => {
|
|
addCallForm.reset();
|
|
loadCalls();
|
|
})
|
|
.catch(err => {
|
|
console.error("Error adding call:", err);
|
|
alert("Failed to add call.");
|
|
});
|
|
});
|
|
|
|
// --- Conflict Resolution Modal ---
|
|
function showConflictModal(taskId) {
|
|
// Remove any existing modal
|
|
document.querySelectorAll(".conflict-modal-overlay").forEach(m => m.remove());
|
|
|
|
fetch("/api/tasks/conflicts")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const conflict = data.conflicts.find(c => c.id == taskId);
|
|
if (!conflict) {
|
|
alert("No conflict data found for this task.");
|
|
return;
|
|
}
|
|
|
|
const remote = conflict.conflict_data || {};
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "conflict-modal-overlay";
|
|
overlay.innerHTML = `
|
|
<div class="conflict-modal">
|
|
<h3>⚠️ Sync Conflict</h3>
|
|
<p style="color: #666; margin-bottom: 1rem;">This task was modified both locally and remotely. Choose which version to keep.</p>
|
|
<div class="conflict-comparison">
|
|
<div class="conflict-version conflict-local">
|
|
<h4>📱 Local Version</h4>
|
|
<div class="conflict-field"><strong>Title:</strong> ${conflict.title}</div>
|
|
<div class="conflict-field"><strong>Status:</strong> ${conflict.status}</div>
|
|
<div class="conflict-field"><strong>Description:</strong> ${conflict.description || '<em>empty</em>'}</div>
|
|
</div>
|
|
<div class="conflict-version conflict-remote">
|
|
<h4>☁️ Remote Version</h4>
|
|
<div class="conflict-field"><strong>Title:</strong> ${remote.title || ''}</div>
|
|
<div class="conflict-field"><strong>Status:</strong> ${remote.status || ''}</div>
|
|
<div class="conflict-field"><strong>Description:</strong> ${remote.description || '<em>empty</em>'}</div>
|
|
</div>
|
|
</div>
|
|
<div class="conflict-actions">
|
|
<button class="btn conflict-btn-local">Keep Local</button>
|
|
<button class="btn conflict-btn-remote">Use Remote</button>
|
|
<button class="btn conflict-btn-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
// Close on overlay click
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) overlay.remove();
|
|
});
|
|
|
|
// Cancel
|
|
overlay.querySelector(".conflict-btn-cancel").addEventListener("click", () => overlay.remove());
|
|
|
|
// Keep Local
|
|
overlay.querySelector(".conflict-btn-local").addEventListener("click", () => {
|
|
resolveConflict(taskId, "local", overlay);
|
|
});
|
|
|
|
// Use Remote
|
|
overlay.querySelector(".conflict-btn-remote").addEventListener("click", () => {
|
|
resolveConflict(taskId, "remote", overlay);
|
|
});
|
|
})
|
|
.catch(err => console.error("Error loading conflict data:", err));
|
|
}
|
|
|
|
function resolveConflict(taskId, resolution, overlay) {
|
|
fetch(`/api/tasks/${taskId}/resolve-conflict`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ resolution })
|
|
})
|
|
.then(res => {
|
|
if (!res.ok) throw new Error("Failed to resolve conflict");
|
|
return res.json();
|
|
})
|
|
.then(() => {
|
|
overlay.remove();
|
|
loadAllTasks();
|
|
checkConflicts();
|
|
})
|
|
.catch(err => {
|
|
console.error("Error resolving conflict:", err);
|
|
alert("Failed to resolve conflict.");
|
|
});
|
|
}
|
|
|
|
// --- Dashboard Conflict Notification ---
|
|
function checkConflicts() {
|
|
fetch("/api/tasks/conflicts")
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
let notice = document.getElementById("conflict-notice");
|
|
if (data.total > 0) {
|
|
if (!notice) {
|
|
notice = document.createElement("div");
|
|
notice.id = "conflict-notice";
|
|
notice.className = "conflict-notice";
|
|
const digestSection = document.getElementById("digest-section");
|
|
if (digestSection) {
|
|
digestSection.parentNode.insertBefore(notice, digestSection);
|
|
}
|
|
}
|
|
notice.innerHTML = `⚠️ ${data.total} task${data.total > 1 ? 's have' : ' has'} sync conflicts — <a href="#" id="conflict-notice-link">resolve now</a>`;
|
|
notice.querySelector("#conflict-notice-link")?.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
switchTab("tasks");
|
|
});
|
|
} else if (notice) {
|
|
notice.remove();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// Check for conflicts on load
|
|
checkConflicts();
|
|
});
|