// 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 (unchanged behavior)
// ---------------------------
async function populateFriendly() {
const id = toInt(document.getElementById("friendly-id").value);
if (!id) return alert("Enter Friendly Faction ID");
await fetch("/api/populate_friendly", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ faction_id: id, interval: 0 })
});
// reload members and assignments
await loadMembers("friendly");
await loadMembers("enemy"); // in case population changed cross lists
await pollAssignments();
await refreshStatus("friendly");
}
async function populateEnemy() {
const id = toInt(document.getElementById("enemy-id").value);
if (!id) return alert("Enter Enemy Faction ID");
await fetch("/api/populate_enemy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ faction_id: id, interval: 0 })
});
await loadMembers("enemy");
await loadMembers("friendly");
await pollAssignments();
await refreshStatus("enemy");
}
// 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");
});