Fix for duplicate member cards when pressing populate

This commit is contained in:
2025-11-28 12:55:31 -05:00
parent 788f2fec2c
commit 022cb0eb75
3 changed files with 211 additions and 151 deletions

Binary file not shown.

View File

@@ -45,7 +45,7 @@ async def dashboard(request: Request):
class FactionRequest(BaseModel): class FactionRequest(BaseModel):
faction_id: int faction_id: int
interval: int interval: int = 30
# ----------------------------- # -----------------------------

View File

@@ -1,30 +1,39 @@
// dashboard.js // dashboard.js (Corrected Full Version)
// Full functionality: populate, start/stop status loops, load members, drag & drop between lists and groups, // Prevents duplicates on populate
// drop zones accept only friendly/enemy respectively, status color updates, localStorage persistence // Preserves group placements
// Updates or creates cards (never recreates all)
// Status refresh works correctly
// ---- In-memory maps ---- //-----------------------------------------------------
const friendlyMembers = new Map(); // id -> member object // Maps of members (id -> member object)
//-----------------------------------------------------
const friendlyMembers = new Map();
const enemyMembers = new Map(); const enemyMembers = new Map();
// ---- DOM containers ---- // DOM containers
const friendlyContainer = document.getElementById("friendly-container"); const friendlyContainer = document.getElementById("friendly-container");
const enemyContainer = document.getElementById("enemy-container"); const enemyContainer = document.getElementById("enemy-container");
// utility to safe parse ints function toInt(v) {
function toInt(v) { const n = Number(v); return Number.isNaN(n) ? null : n; } const n = Number(v);
return Number.isNaN(n) ? null : n;
}
// ---- Apply status CSS class to the .status-text inside a member card ---- //-----------------------------------------------------
// Status CSS assignment
//-----------------------------------------------------
function applyStatusClass(member) { function applyStatusClass(member) {
const span = member.domElement?.querySelector(".status-text"); const span = member.domElement?.querySelector(".status-text");
if (!span) return; if (!span) return;
span.classList.remove("status-ok", "status-traveling", "status-hospitalized"); span.classList.remove("status-ok", "status-traveling", "status-hospitalized");
const s = String(member.status || "").toLowerCase();
const s = (member.status || "").toLowerCase();
if (s === "okay") span.classList.add("status-ok"); if (s === "okay") span.classList.add("status-ok");
else if (s === "traveling" || s === "abroad") span.classList.add("status-traveling"); else if (s === "traveling" || s === "abroad") span.classList.add("status-traveling");
else if (s === "hospitalized" || s === "hospital") span.classList.add("status-hospitalized"); else if (s === "hospitalized" || s === "hospital") span.classList.add("status-hospitalized");
} }
// ---- Decide which CSS class a status should use (helper used when creating card) ---
function statusClass(status) { function statusClass(status) {
if (!status) return ""; if (!status) return "";
status = status.toLowerCase(); status = status.toLowerCase();
@@ -34,7 +43,9 @@ function statusClass(status) {
return ""; return "";
} }
// ---- Create structured card ---- //-----------------------------------------------------
// Create a new card DOM element for a member
//-----------------------------------------------------
function createMemberCard(member, kind) { function createMemberCard(member, kind) {
const card = document.createElement("div"); const card = document.createElement("div");
card.classList.add("member-card"); card.classList.add("member-card");
@@ -45,129 +56,195 @@ function createMemberCard(member, kind) {
const nameDiv = document.createElement("div"); const nameDiv = document.createElement("div");
nameDiv.className = "name"; nameDiv.className = "name";
nameDiv.textContent = member.name; nameDiv.textContent = member.name;
if (kind === "friendly") nameDiv.style.color = "#8fd38f"; // green if (kind === "friendly") nameDiv.style.color = "#8fd38f";
if (kind === "enemy") nameDiv.style.color = "#ff6b6b"; // red if (kind === "enemy") nameDiv.style.color = "#ff6b6b";
const statsDiv = document.createElement("div"); const statsDiv = document.createElement("div");
statsDiv.className = "stats"; statsDiv.className = "stats";
// Ensure initial status text and class (maybe "Unknown" until refresh) statsDiv.innerHTML = `
statsDiv.innerHTML = `Lv: ${member.level} <br> Est: ${member.estimate} <br> Status: <span class="status-text ${statusClass(member.status)}">${member.status || "Unknown"}</span>`; Lv: ${member.level} <br>
Est: ${member.estimate} <br>
Status: <span class="status-text ${statusClass(member.status)}">${member.status || "Unknown"}</span>
`;
card.appendChild(nameDiv); card.appendChild(nameDiv);
card.appendChild(statsDiv); card.appendChild(statsDiv);
// save reference
member.domElement = card; member.domElement = card;
// drag payload is JSON (kind + id) // dragging
card.addEventListener("dragstart", (e) => { card.addEventListener("dragstart", (e) => {
const payload = JSON.stringify({ kind, id: member.id }); e.dataTransfer.setData("text/plain", JSON.stringify({
e.dataTransfer.setData("text/plain", payload); kind,
id: member.id
}));
card.style.opacity = "0.5"; card.style.opacity = "0.5";
}); });
card.addEventListener("dragend", () => { card.addEventListener("dragend", () => {
card.style.opacity = "1"; card.style.opacity = "1";
}); });
// Apply initial status class (for cases where member.status was set by populate)
applyStatusClass(member);
return card; return card;
} }
// ---- Load static members from backend ---- //-----------------------------------------------------
async function loadMembers(faction) { // Update an existing card instead of replacing it
const url = faction === "friendly" ? "/api/friendly_members" : "/api/enemy_members"; //-----------------------------------------------------
function updateMemberCard(member) {
if (!member.domElement) return;
const span = member.domElement.querySelector(".status-text");
if (span) {
span.textContent = member.status || "Unknown";
}
applyStatusClass(member);
}
//-----------------------------------------------------
// Load members WITHOUT recreating card duplicates
//-----------------------------------------------------
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 { try {
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, { cache: "no-store" });
if (!res.ok) { if (!res.ok) {
console.error("Failed to fetch members:", res.status); console.error("Error loading members", res.status);
return; return;
} }
const arr = await res.json();
const container = faction === "friendly" ? friendlyContainer : enemyContainer;
const map = faction === "friendly" ? friendlyMembers : enemyMembers;
container.innerHTML = ""; const list = await res.json();
map.clear();
arr.forEach(m => { // Keep track of IDs received so we don't remove existing group placements
if (!m.status) m.status = "Unknown"; const receivedIds = new Set(list.map(m => m.id));
const card = createMemberCard(m, faction);
map.set(m.id, m); // Update existing members or create new ones
for (const m of list) {
let existing = map.get(m.id);
if (existing) {
// update info (but keep DOM placement)
existing.name = m.name;
existing.level = m.level;
existing.estimate = m.estimate;
if (!existing.status) existing.status = "Unknown";
updateMemberCard(existing);
} else {
// NEW member — create card and add to list area
const newMember = {
id: m.id,
name: m.name,
level: m.level,
estimate: m.estimate,
status: "Unknown",
domElement: null
};
const card = createMemberCard(newMember, kind);
map.set(m.id, newMember);
// Only add to container if they are NOT already assigned to a group
if (!isMemberAssigned(newMember.id)) {
container.appendChild(card); container.appendChild(card);
}); }
}
}
// Remove members that no longer exist from map (rare)
for (const existingId of map.keys()) {
if (!receivedIds.has(existingId)) {
const member = map.get(existingId);
if (member?.domElement?.parentElement) {
member.domElement.parentElement.removeChild(member.domElement);
}
map.delete(existingId);
}
}
// after we've added elements, ensure drop listeners are attached
setupDropZones(); setupDropZones();
} catch (err) { } catch (err) {
console.error("loadMembers error", err); console.error("loadMembers error", err);
} }
} }
// ---- Refresh status map and update DOM ---- //-----------------------------------------------------
async function refreshStatus(faction) { // Check if a member is placed in a group zone
const url = faction === "friendly" ? "/api/friendly_status" : "/api/enemy_status"; //-----------------------------------------------------
const map = faction === "friendly" ? friendlyMembers : enemyMembers; function isMemberAssigned(id) {
return document.querySelector(`.member-card[data-id="${id}"]`)?.parentElement?.classList.contains("drop-zone") || false;
}
//-----------------------------------------------------
// Status refresh
//-----------------------------------------------------
async function refreshStatus(kind) {
const url = kind === "friendly" ? "/api/friendly_status" : "/api/enemy_status";
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
try { try {
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return; if (!res.ok) return;
const statusData = await res.json(); const statusData = await res.json();
Object.keys(statusData).forEach(k => { for (const idStr of Object.keys(statusData)) {
const id = parseInt(k); const id = parseInt(idStr);
const member = map.get(id); const member = map.get(id);
if (!member) return; if (!member) continue;
member.status = statusData[k].status;
const span = member.domElement.querySelector(".status-text"); member.status = statusData[idStr].status;
if (span) { updateMemberCard(member);
span.textContent = member.status;
applyStatusClass(member);
} }
});
} catch (err) { } catch (err) {
console.error("refreshStatus error", err); console.error("refreshStatus error", err);
} }
} }
// ---- Populate endpoints ---- //-----------------------------------------------------
// NOW: populate also immediately fetches the latest status snapshot and applies it. // Populate endpoints (immediate build + status refresh)
//-----------------------------------------------------
async function populateFriendly() { async function populateFriendly() {
const id = toInt(document.getElementById("friendly-id").value); const id = toInt(document.getElementById("friendly-id").value);
if (!id) return alert("Enter friendly faction ID"); if (!id) return alert("Enter Friendly Faction ID");
// Send faction_id to FastAPI
await fetch("/api/populate_friendly", { await fetch("/api/populate_friendly", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ faction_id: id, interval: 0 }) body: JSON.stringify({ faction_id: id })
}); });
// Rebuild list and then immediately fetch status snapshot // Load members but do NOT recreate duplicates
await loadMembers("friendly"); await loadMembers("friendly");
// try to pull status once so the status text shows up immediately
// Immediately refresh status so cards show correct status
await refreshStatus("friendly"); await refreshStatus("friendly");
} }
async function populateEnemy() { async function populateEnemy() {
const id = toInt(document.getElementById("enemy-id").value); const id = toInt(document.getElementById("enemy-id").value);
if (!id) return alert("Enter enemy faction ID"); if (!id) return alert("Enter Enemy Faction ID");
await fetch("/api/populate_enemy", { await fetch("/api/populate_enemy", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ faction_id: id, interval: 0 }) body: JSON.stringify({ faction_id: id })
}); });
await loadMembers("enemy"); await loadMembers("enemy");
await refreshStatus("enemy"); await refreshStatus("enemy");
} }
// ---- Start/Stop status refresh loops ---- //-----------------------------------------------------
// Status refresh toggling
//-----------------------------------------------------
let friendlyStatusIntervalHandle = null; let friendlyStatusIntervalHandle = null;
let enemyStatusIntervalHandle = null; let enemyStatusIntervalHandle = null;
async function toggleFriendlyStatus() { async function toggleFriendlyStatus() {
const btn = document.getElementById("friendly-status-btn"); const btn = document.getElementById("friendly-status-btn");
if (!btn) {
console.error("friendly-status-btn not found");
return;
}
if (friendlyStatusIntervalHandle) { if (friendlyStatusIntervalHandle) {
clearInterval(friendlyStatusIntervalHandle); clearInterval(friendlyStatusIntervalHandle);
@@ -178,7 +255,6 @@ async function toggleFriendlyStatus() {
const id = toInt(document.getElementById("friendly-id").value); const id = toInt(document.getElementById("friendly-id").value);
const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10); const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10);
if (!id) return alert("Enter friendly faction ID");
await fetch("/api/start_friendly_status", { await fetch("/api/start_friendly_status", {
method: "POST", method: "POST",
@@ -187,17 +263,12 @@ async function toggleFriendlyStatus() {
}); });
friendlyStatusIntervalHandle = setInterval(() => refreshStatus("friendly"), interval * 1000); friendlyStatusIntervalHandle = setInterval(() => refreshStatus("friendly"), interval * 1000);
btn.textContent = "Stop Refresh";
// do an immediate status fetch as well
refreshStatus("friendly"); refreshStatus("friendly");
btn.textContent = "Stop Refresh";
} }
async function toggleEnemyStatus() { async function toggleEnemyStatus() {
const btn = document.getElementById("enemy-status-btn"); const btn = document.getElementById("enemy-status-btn");
if (!btn) {
console.error("enemy-status-btn not found");
return;
}
if (enemyStatusIntervalHandle) { if (enemyStatusIntervalHandle) {
clearInterval(enemyStatusIntervalHandle); clearInterval(enemyStatusIntervalHandle);
@@ -208,7 +279,6 @@ async function toggleEnemyStatus() {
const id = toInt(document.getElementById("enemy-id").value); const id = toInt(document.getElementById("enemy-id").value);
const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10); const interval = Math.max(1, toInt(document.getElementById("refresh-interval").value) || 10);
if (!id) return alert("Enter enemy faction ID");
await fetch("/api/start_enemy_status", { await fetch("/api/start_enemy_status", {
method: "POST", method: "POST",
@@ -217,39 +287,31 @@ async function toggleEnemyStatus() {
}); });
enemyStatusIntervalHandle = setInterval(() => refreshStatus("enemy"), interval * 1000); enemyStatusIntervalHandle = setInterval(() => refreshStatus("enemy"), interval * 1000);
btn.textContent = "Stop Refresh";
refreshStatus("enemy"); refreshStatus("enemy");
btn.textContent = "Stop Refresh";
} }
// ------------------- //-----------------------------------------------------
// DRAG & DROP SYSTEM // Drag & Drop for all zones
// ------------------- //-----------------------------------------------------
function setupDropZones() { function setupDropZones() {
const dropZones = document.querySelectorAll(".drop-zone, .member-list"); const zones = document.querySelectorAll(".drop-zone, .member-list");
dropZones.forEach(zone => { zones.forEach(zone => {
// attach listeners only once
if (zone.dataset._drag_listeners_attached) return; if (zone.dataset._drag_listeners_attached) return;
zone.addEventListener("dragover", (e) => { zone.addEventListener("dragover", (e) => {
e.preventDefault(); e.preventDefault();
// check payload validity const raw = e.dataTransfer.getData("text/plain");
let raw = null;
try {
raw = e.dataTransfer.getData("text/plain");
} catch (err) {
raw = null;
}
let valid = false; let valid = false;
if (raw) {
try { try {
const p = JSON.parse(raw); const p = JSON.parse(raw);
const kind = p.kind; const zoneType = zone.classList.contains("friendly-zone") ? "friendly" :
const zoneType = zone.classList.contains("friendly-zone") || zone.id.includes("friendly") ? "friendly" : zone.classList.contains("enemy-zone") ? "enemy" :
zone.classList.contains("enemy-zone") || zone.id.includes("enemy") ? "enemy" : null; null;
if (zoneType && kind === zoneType) valid = true;
if (!zoneType || p.kind === zoneType) valid = true;
} catch {} } catch {}
}
zone.classList.toggle("dragover-valid", valid); zone.classList.toggle("dragover-valid", valid);
zone.classList.toggle("dragover-invalid", !valid); zone.classList.toggle("dragover-invalid", !valid);
}); });
@@ -262,32 +324,26 @@ function setupDropZones() {
e.preventDefault(); e.preventDefault();
zone.classList.remove("dragover-valid", "dragover-invalid"); zone.classList.remove("dragover-valid", "dragover-invalid");
let raw = null;
try {
raw = e.dataTransfer.getData("text/plain");
} catch (err) {
raw = null;
}
if (!raw) return;
let payload; let payload;
try { payload = JSON.parse(raw); } catch { return; } try {
const { kind, id } = payload; payload = JSON.parse(e.dataTransfer.getData("text/plain"));
const idn = parseInt(id); } catch { return; }
if (!kind || !idn) return;
const zoneType = zone.classList.contains("friendly-zone") || zone.id.includes("friendly") ? "friendly" : const zoneType = zone.classList.contains("friendly-zone") ? "friendly" :
zone.classList.contains("enemy-zone") || zone.id.includes("enemy") ? "enemy" : null; zone.classList.contains("enemy-zone") ? "enemy" :
if (zoneType !== kind) return; null;
const map = kind === "friendly" ? friendlyMembers : enemyMembers; if (zoneType && payload.kind !== zoneType) return;
const member = map.get(idn);
const map = payload.kind === "friendly" ? friendlyMembers : enemyMembers;
const member = map.get(payload.id);
if (!member) return; if (!member) return;
const prev = member.domElement?.parentElement; // Move the card
if (prev && prev !== zone) prev.removeChild(member.domElement); const prev = member.domElement.parentElement;
if (prev !== zone) prev.removeChild(member.domElement);
zone.appendChild(member.domElement); zone.appendChild(member.domElement);
// persist assignment in localStorage
saveAssignments(); saveAssignments();
}); });
@@ -295,72 +351,76 @@ function setupDropZones() {
}); });
} }
// ------------------- //-----------------------------------------------------
// localStorage helpers for battle groups (the format used earlier) // Save and load group assignments
// ------------------- //-----------------------------------------------------
function saveAssignments() { function saveAssignments() {
const groups = document.querySelectorAll(".group"); const groups = document.querySelectorAll(".group");
const data = {}; const data = {};
groups.forEach(g => { groups.forEach(g => {
const gid = g.dataset.id; const gid = g.dataset.id;
data[gid] = { friendly: [], enemy: [] }; data[gid] = {
g.querySelectorAll(".friendly-zone .member-card").forEach(c => data[gid].friendly.push(parseInt(c.dataset.id))); friendly: [],
g.querySelectorAll(".enemy-zone .member-card").forEach(c => data[gid].enemy.push(parseInt(c.dataset.id))); enemy: [],
};
g.querySelectorAll(".friendly-zone .member-card").forEach(c =>
data[gid].friendly.push(parseInt(c.dataset.id))
);
g.querySelectorAll(".enemy-zone .member-card").forEach(c =>
data[gid].enemy.push(parseInt(c.dataset.id))
);
}); });
localStorage.setItem("battleAssignments", JSON.stringify(data)); localStorage.setItem("battleAssignments", JSON.stringify(data));
} }
function loadAssignmentsFromStorage() { function loadAssignmentsFromStorage() {
const data = JSON.parse(localStorage.getItem("battleAssignments") || "{}"); const data = JSON.parse(localStorage.getItem("battleAssignments") || "{}");
Object.keys(data).forEach(gid => { Object.keys(data).forEach(gid => {
const group = document.querySelector(`.group[data-id="${gid}"]`); const group = document.querySelector(`.group[data-id="${gid}"]`);
if (!group) return; if (!group) return;
data[gid].friendly.forEach(id => { data[gid].friendly.forEach(id => {
const member = friendlyMembers.get(id); const m = friendlyMembers.get(id);
if (member) group.querySelector(".friendly-zone").appendChild(member.domElement); if (m?.domElement) group.querySelector(".friendly-zone").appendChild(m.domElement);
}); });
data[gid].enemy.forEach(id => { data[gid].enemy.forEach(id => {
const member = enemyMembers.get(id); const m = enemyMembers.get(id);
if (member) group.querySelector(".enemy-zone").appendChild(member.domElement); if (m?.domElement) group.querySelector(".enemy-zone").appendChild(m.domElement);
}); });
}); });
} }
// ---- Allow clicking a card to log for debug (optional) ---- //-----------------------------------------------------
function attachCardClickLogging() { // Wire up buttons
document.addEventListener("click", (e) => { //-----------------------------------------------------
const el = e.target.closest(".member-card");
if (!el) return;
const id = parseInt(el.dataset.id);
const kind = el.dataset.kind;
const map = kind === "friendly" ? friendlyMembers : enemyMembers;
const member = map.get(id);
if (member) console.log("Member clicked:", member);
});
}
// ---- Initialize UI wiring ----
function wireUp() { function wireUp() {
document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly); document.getElementById("friendly-populate-btn").addEventListener("click", populateFriendly);
document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy); document.getElementById("enemy-populate-btn").addEventListener("click", populateEnemy);
document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus); document.getElementById("friendly-status-btn").addEventListener("click", toggleFriendlyStatus);
document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus); document.getElementById("enemy-status-btn").addEventListener("click", toggleEnemyStatus);
document.getElementById("reset-groups-btn").addEventListener("click", () => {
// clear storage and put members back
localStorage.removeItem("battleAssignments");
// move all back to lists
friendlyMembers.forEach(m => { if (m.domElement) friendlyContainer.appendChild(m.domElement); });
enemyMembers.forEach(m => { if (m.domElement) enemyContainer.appendChild(m.domElement); });
});
setupDropZones(); document.getElementById("reset-groups-btn").addEventListener("click", () => {
attachCardClickLogging(); localStorage.removeItem("battleAssignments");
friendlyMembers.forEach(m => friendlyContainer.appendChild(m.domElement));
enemyMembers.forEach(m => enemyContainer.appendChild(m.domElement));
});
} }
// ---- On load: wire and attempt to load any existing JSON lists ---- //-----------------------------------------------------
// On Load
//-----------------------------------------------------
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
wireUp(); wireUp();
await loadMembers("friendly"); await loadMembers("friendly");
await loadMembers("enemy"); await loadMembers("enemy");
loadAssignmentsFromStorage(); // restore positions after load
loadAssignmentsFromStorage();
}); });