// dashboard.js (Server-side assignments, polling-mode) // - No more localStorage // - Polls /api/assignments every 5s to keep all clients in sync // - POST /api/assign_member on drop, POST /api/remove_member_assignment when returned to list // - Prevents duplicates, preserves card objects and status updates // --------------------------- // In-memory maps // --------------------------- const friendlyMembers = new Map(); // id -> { id, name, level, estimate, status, domElement } const enemyMembers = new Map(); // DOM references const friendlyContainer = document.getElementById("friendly-container"); const enemyContainer = document.getElementById("enemy-container"); // polling interval (ms) for assignments sync const ASSIGNMENTS_POLL_MS = 5000; // utility function toInt(v) { const n = Number(v); return Number.isNaN(n) ? null : n; } // --------------------------- // Status CSS helpers // --------------------------- function statusClass(status) { if (!status) return ""; const s = String(status).toLowerCase(); if (s.includes("okay")) return "status-ok"; if (s.includes("travel") || s.includes("abroad")) return "status-traveling"; if (s.includes("hospital")) return "status-hospitalized"; return ""; } function applyStatusClass(member) { const span = member.domElement?.querySelector(".status-text"); if (!span) return; span.className = "status-text " + statusClass(member.status); span.textContent = member.status || "Unknown"; } // --------------------------- // Card creation & update // --------------------------- function createMemberCard(member, kind) { // if domElement already exists, reuse it if (member.domElement) return member.domElement; const card = document.createElement("div"); card.classList.add("member-card"); card.setAttribute("draggable", "true"); card.dataset.id = member.id; card.dataset.kind = kind; const nameDiv = document.createElement("div"); nameDiv.className = "name"; nameDiv.textContent = member.name; nameDiv.style.color = kind === "friendly" ? "#8fd38f" : "#ff6b6b"; const statsDiv = document.createElement("div"); statsDiv.className = "stats"; statsDiv.innerHTML = ` Lv: ${member.level}
Est: ${member.estimate}
Status: ${member.status || "Unknown"} `; card.appendChild(nameDiv); card.appendChild(statsDiv); // store reference member.domElement = card; // drag handlers: payload is JSON string card.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/plain", JSON.stringify({ kind, id: member.id })); card.style.opacity = "0.5"; }); card.addEventListener("dragend", () => { card.style.opacity = "1"; }); // apply initial status class applyStatusClass(member); return card; } function updateMemberCard(member) { if (!member.domElement) return; // name might have changed (rare) const nameEl = member.domElement.querySelector(".name"); if (nameEl && nameEl.textContent !== member.name) nameEl.textContent = member.name; // stats/status const statsEl = member.domElement.querySelector(".stats"); if (statsEl) { const statusSpan = statsEl.querySelector(".status-text"); if (statusSpan) { statusSpan.textContent = member.status || "Unknown"; statusSpan.className = "status-text " + statusClass(member.status); } } } // --------------------------- // Load members from server // --------------------------- async function loadMembers(kind) { const url = kind === "friendly" ? "/api/friendly_members" : "/api/enemy_members"; const container = kind === "friendly" ? friendlyContainer : enemyContainer; const map = kind === "friendly" ? friendlyMembers : enemyMembers; try { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) { console.error("Failed to load members:", res.status); return; } const list = await res.json(); // build set of received ids const receivedIds = new Set(list.map(m => m.id)); // update or create for (const m of list) { let existing = map.get(m.id); if (existing) { // update fields (preserve dom & placement) existing.name = m.name; existing.level = m.level; existing.estimate = m.estimate; // keep existing.status if already set (status refreshes separately) if (!existing.status) existing.status = "Unknown"; updateMemberCard(existing); } else { const newMember = { id: m.id, name: m.name, level: m.level, estimate: m.estimate, status: m.status || "Unknown", domElement: null }; map.set(m.id, newMember); // create card but DO NOT automatically append here; // placement will be handled by loadAssignmentsFromServer() createMemberCard(newMember, kind); } } // remove obsolete members for (const existingId of Array.from(map.keys())) { if (!receivedIds.has(existingId)) { const member = map.get(existingId); if (member?.domElement?.parentElement) member.domElement.parentElement.removeChild(member.domElement); map.delete(existingId); } } } catch (err) { console.error("loadMembers error", err); } } // --------------------------- // Server-side assignments API interactions // --------------------------- async function fetchAssignments() { try { const res = await fetch("/api/assignments", { cache: "no-store" }); if (!res.ok) { console.warn("Failed to fetch assignments:", res.status); return null; } return await res.json(); // expected { "1": { friendly: [...], enemy: [...] }, ... } } catch (err) { console.error("fetchAssignments error", err); return null; } } async function saveAssignmentToServer(groupId, kind, memberId) { try { const res = await fetch("/api/assign_member", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ group_id: groupId, kind, member_id: memberId }) }); if (!res.ok) { console.warn("Failed to save assignment:", res.status); } } catch (err) { console.error("saveAssignmentToServer error", err); } } async function removeAssignmentFromServer(memberId) { try { const res = await fetch("/api/remove_member_assignment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ member_id: memberId }) }); if (!res.ok) { console.warn("Failed to remove assignment:", res.status); } } catch (err) { console.error("removeAssignmentFromServer error", err); } } async function clearAssignmentsOnServer() { try { const res = await fetch("/api/clear_assignments", { method: "POST" }); if (!res.ok) console.warn("clear_assignments failed:", res.status); } catch (err) { console.error("clearAssignmentsOnServer error", err); } } // --------------------------- // Apply server assignments to DOM // --------------------------- function ensureMainListContains(member, kind) { const container = kind === "friendly" ? friendlyContainer : enemyContainer; const parent = member.domElement?.parentElement; // Detect group zone: ids like "group-1-friendly" or "group-2-enemy" const isInGroupZone = parent && typeof parent.id === "string" && /^group-\d+-/.test(parent.id); // If member has no parent yet, or is in a group zone, ensure it ends up in the main list if (!parent || isInGroupZone) { // If it's already in the right container, nothing to do if (member.domElement && member.domElement.parentElement !== container) { // remove from previous parent (if any) and append to main list const prev = member.domElement.parentElement; if (prev) prev.removeChild(member.domElement); container.appendChild(member.domElement); } } } function applyAssignmentsToDOM(assignments) { if (!assignments) return; // First, move all assigned members into their group zones Object.keys(assignments).forEach(groupKey => { const group = assignments[groupKey]; // friendly list (group.friendly || []).forEach(id => { const member = friendlyMembers.get(id); if (!member) return; // not loaded yet const zoneId = `group-${groupKey}-friendly`; const zone = document.getElementById(zoneId); if (!zone) return; if (member.domElement && member.domElement.parentElement !== zone) { // remove from previous parent const prev = member.domElement.parentElement; if (prev) prev.removeChild(member.domElement); zone.appendChild(member.domElement); } }); // enemy list (group.enemy || []).forEach(id => { const member = enemyMembers.get(id); if (!member) return; const zoneId = `group-${groupKey}-enemy`; const zone = document.getElementById(zoneId); if (!zone) return; if (member.domElement && member.domElement.parentElement !== zone) { const prev = member.domElement.parentElement; if (prev) prev.removeChild(member.domElement); zone.appendChild(member.domElement); } }); }); // Second, ensure all unassigned members are in their main lists friendlyMembers.forEach(member => { // check if member is referenced in assignments anywhere const assigned = Object.values(assignments).some(g => (g.friendly || []).includes(member.id)); if (!assigned) ensureMainListContains(member, "friendly"); }); enemyMembers.forEach(member => { const assigned = Object.values(assignments).some(g => (g.enemy || []).includes(member.id)); if (!assigned) ensureMainListContains(member, "enemy"); }); } // --------------------------- // Polling assignments loop // --------------------------- let assignmentsPollHandle = null; let lastAssignmentsSnapshot = null; async function pollAssignments() { const assignments = await fetchAssignments(); if (!assignments) return; // quick shallow compare to avoid full DOM updates if nothing changed const snapshot = JSON.stringify(assignments); if (snapshot !== lastAssignmentsSnapshot) { lastAssignmentsSnapshot = snapshot; applyAssignmentsToDOM(assignments); } } function startAssignmentsPolling() { if (assignmentsPollHandle) clearInterval(assignmentsPollHandle); assignmentsPollHandle = setInterval(pollAssignments, ASSIGNMENTS_POLL_MS); // run immediately pollAssignments(); } function stopAssignmentsPolling() { if (assignmentsPollHandle) { clearInterval(assignmentsPollHandle); assignmentsPollHandle = null; } } // --------------------------- // Dropzone wiring (sends assignment to server) // --------------------------- function setupDropZones() { // attach handlers once const zones = document.querySelectorAll(".drop-zone, .member-list"); zones.forEach(zone => { // avoid double-binding by using a marker if (zone.dataset._drag_listeners_attached) return; zone.addEventListener("dragover", (e) => { e.preventDefault(); // peek at payload to check validity let raw = null; try { raw = e.dataTransfer.getData("text/plain"); } catch {} let valid = false; if (raw) { try { const p = JSON.parse(raw); const kind = p.kind; const zoneType = zone.classList.contains("friendly-zone") ? "friendly" : zone.classList.contains("enemy-zone") ? "enemy" : zone.classList.contains("member-list") || zone.id === "friendly-container" || zone.id === "enemy-container" ? "main-list" : null; // main-list accepts only same-kind cards if (zoneType === "main-list") valid = true; else if (zoneType && kind === zoneType) valid = true; } catch {} } zone.classList.toggle("dragover-valid", valid); zone.classList.toggle("dragover-invalid", !valid); }); zone.addEventListener("dragleave", () => { zone.classList.remove("dragover-valid", "dragover-invalid"); }); zone.addEventListener("drop", async (e) => { e.preventDefault(); zone.classList.remove("dragover-valid", "dragover-invalid"); let payload; try { payload = JSON.parse(e.dataTransfer.getData("text/plain")); } catch { return; } if (!payload || !payload.kind || !payload.id) return; const kind = payload.kind; const id = payload.id; const zoneIsFriendly = zone.classList.contains("friendly-zone"); const zoneIsEnemy = zone.classList.contains("enemy-zone"); const zoneIsMainList = zone.classList.contains("member-list") || zone.id === "friendly-container" || zone.id === "enemy-container"; // Validate mix of kinds if (zoneIsFriendly && kind !== "friendly") { // invalid drop alert("Cannot drop enemy into friendly zone."); return; } if (zoneIsEnemy && kind !== "enemy") { alert("Cannot drop friendly into enemy zone."); return; } // If dropping into a group zone -> save assignment to server if (zoneIsFriendly || zoneIsEnemy) { // zone id format expected: group--friendly or group--enemy const parts = zone.id.split("-"); // expected ["group", "", "friendly"] if (parts.length >= 3) { const groupKey = parts[1]; await saveAssignmentToServer(groupKey, kind, id); // Move in DOM (we still move locally so UX is instant) const map = kind === "friendly" ? friendlyMembers : enemyMembers; const member = map.get(id); if (member && member.domElement && member.domElement.parentElement !== zone) { const prev = member.domElement.parentElement; if (prev) prev.removeChild(member.domElement); zone.appendChild(member.domElement); } } else { console.warn("Unexpected zone id format", zone.id); } } else if (zoneIsMainList) { // Dropped back into main list - remove assignment on server await removeAssignmentFromServer(id); // Move card back to main list container const map = kind === "friendly" ? friendlyMembers : enemyMembers; const member = map.get(id); const container = kind === "friendly" ? friendlyContainer : enemyContainer; if (member && member.domElement && member.domElement.parentElement !== container) { const prev = member.domElement.parentElement; if (prev) prev.removeChild(member.domElement); container.appendChild(member.domElement); } } }); zone.dataset._drag_listeners_attached = "1"; }); } // --------------------------- // Populate & Status functions // --------------------------- async function populateFriendly() { const id = toInt(document.getElementById("friendly-id").value); if (!id) return alert("Enter Friendly Faction ID"); try { const res = await fetch("/api/populate_friendly", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ faction_id: id, interval: 0 }) }); const data = await res.json(); // Update in-memory map & DOM if (data.members) { for (const m of data.members) { let existing = friendlyMembers.get(m.id); if (existing) { existing.name = m.name; existing.level = m.level; existing.estimate = m.estimate; existing.status = m.status || "Unknown"; updateMemberCard(existing); } else { const newMember = { id: m.id, name: m.name, level: m.level, estimate: m.estimate, status: m.status || "Unknown", domElement: null }; friendlyMembers.set(m.id, newMember); const card = createMemberCard(newMember, "friendly"); friendlyContainer.appendChild(card); } } } // Refresh assignments & status UI await loadMembers("enemy"); // in case population changed cross lists await pollAssignments(); } catch (err) { console.error("populateFriendly error:", err); } } async function populateEnemy() { const id = toInt(document.getElementById("enemy-id").value); if (!id) return alert("Enter Enemy Faction ID"); try { const res = await fetch("/api/populate_enemy", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ faction_id: id, interval: 0 }) }); const data = await res.json(); if (data.members) { for (const m of data.members) { let existing = enemyMembers.get(m.id); if (existing) { existing.name = m.name; existing.level = m.level; existing.estimate = m.estimate; existing.status = m.status || "Unknown"; updateMemberCard(existing); } else { const newMember = { id: m.id, name: m.name, level: m.level, estimate: m.estimate, status: m.status || "Unknown", domElement: null }; enemyMembers.set(m.id, newMember); const card = createMemberCard(newMember, "enemy"); enemyContainer.appendChild(card); } } } // Refresh assignments & status UI await loadMembers("friendly"); await pollAssignments(); } catch (err) { console.error("populateEnemy error:", err); } } // status refresh (pulls from server status json) async function refreshStatus(kind) { const url = kind === "friendly" ? "/api/friendly_status" : "/api/enemy_status"; const map = kind === "friendly" ? friendlyMembers : enemyMembers; try { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) return; const statusData = await res.json(); for (const idStr of Object.keys(statusData)) { const id = parseInt(idStr); const member = map.get(id); if (!member) continue; member.status = statusData[idStr].status; updateMemberCard(member); } } catch (err) { console.error("refreshStatus error", err); } } // Start/Stop status loops (buttons toggle) let friendlyStatusIntervalHandle = null; let enemyStatusIntervalHandle = null; async function toggleFriendlyStatus() { const btn = document.getElementById("friendly-status-btn"); if (friendlyStatusIntervalHandle) { clearInterval(friendlyStatusIntervalHandle); friendlyStatusIntervalHandle = null; btn.textContent = "Start Refresh"; return; } const id = toInt(document.getElementById("friendly-id").value); const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10); await fetch("/api/start_friendly_status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ faction_id: id, interval }) }); friendlyStatusIntervalHandle = setInterval(() => refreshStatus("friendly"), interval * 1000); refreshStatus("friendly"); btn.textContent = "Stop Refresh"; } async function toggleEnemyStatus() { const btn = document.getElementById("enemy-status-btn"); if (enemyStatusIntervalHandle) { clearInterval(enemyStatusIntervalHandle); enemyStatusIntervalHandle = null; btn.textContent = "Start Refresh"; return; } const id = toInt(document.getElementById("enemy-id").value); const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10); await fetch("/api/start_enemy_status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ faction_id: id, interval }) }); enemyStatusIntervalHandle = setInterval(() => refreshStatus("enemy"), interval * 1000); refreshStatus("enemy"); btn.textContent = "Stop Refresh"; } // --------------------------- // Reset groups (server-side) // --------------------------- async function resetGroups() { if (!confirm("Reset all group assignments on the server?")) return; await clearAssignmentsOnServer(); // reload assignments & UI await pollAssignments(); } // --------------------------- // Wire up buttons & init // --------------------------- function wireUp() { document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly); document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy); document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus); document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus); const resetBtn = document.getElementById("reset-groups-btn"); if (resetBtn) resetBtn.addEventListener("click", resetGroups); setupDropZones(); } // --------------------------- // Initial load // --------------------------- document.addEventListener("DOMContentLoaded", async () => { wireUp(); // load members first await loadMembers("friendly"); await loadMembers("enemy"); // load assignments and apply them await pollAssignments(); startAssignmentsPolling(); // kick off status polling for initial UI (but status loops are triggered by Start Refresh buttons) // immediate status pull so cards show current status await refreshStatus("friendly"); await refreshStatus("enemy"); });