<!DOCTYPE html>
<html lang="da">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>GTA V – Aktivitetskort</title>
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
  <style>
    html, body, #map { height: 100%; margin: 0; }
    body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#111; }
    #map { background:#1862ad; } /* matches the map image's own ocean fill exactly, so the image bounds don't show as a seam */

    .panel {
      position:absolute; z-index:1000; top:12px; left:12px;
      background:rgba(15,18,22,.94); color:#fff;
      border:1px solid rgba(255,255,255,.16);
      border-radius:10px; padding:10px 12px;
      box-shadow:0 6px 24px rgba(0,0,0,.35);
      width:280px;
      transition: transform .22s ease;
    }
    /* Slides the whole panel fully off-screen to the left. The handle
       (attached to the panel's own right edge) rides along with it, so it
       ends up sitting flush against the viewport edge as a pull-tab. */
    .panel.collapsed { transform: translateX(calc(-100% - 12px)); }
    .panel-handle {
      position:absolute; z-index:1001; top:16px; right:-28px;
      width:28px; height:44px;
      background:rgba(15,18,22,.94); color:#fff;
      border:1px solid rgba(255,255,255,.16); border-left:none;
      border-radius:0 8px 8px 0;
      display:flex; align-items:center; justify-content:center;
      cursor:pointer; font-size:14px;
      box-shadow:0 6px 24px rgba(0,0,0,.35);
    }
    .panel-handle:hover { background:#272c33; }
    .panel-header { display:flex; align-items:center; }
    .title { font-weight:750; font-size:15px; }
    .sub { margin-top:3px; font-size:12px; color:#b9c0c8; }
    .status { margin-top:7px; font-size:12px; }
    .dot {
      display:inline-block; width:9px; height:9px; border-radius:50%;
      background:#1687ff; border:2px solid #fff; vertical-align:-2px; margin-right:5px;
    }
    button {
      appearance:none; border:1px solid rgba(255,255,255,.18);
      background:#272c33; color:white; border-radius:7px;
      padding:6px 9px; cursor:pointer; font-size:12px;
    }
    button:hover { background:#343b44; }
    button:disabled { opacity:.45; cursor:default; }

    .search-row { margin-top:10px; }
    .search-row input {
      width:100%; box-sizing:border-box;
      background:#1a1e24; color:#fff;
      border:1px solid rgba(255,255,255,.16); border-radius:7px;
      padding:7px 9px; font-size:12px;
    }
    .search-row input::placeholder { color:#7c8794; }

    .sync-row {
      margin-top:8px; font-size:11px; color:#8f99a5;
      display:flex; align-items:center; gap:6px;
    }
    .sync-dot {
      display:inline-block; width:7px; height:7px; border-radius:50%; flex:0 0 auto;
    }
    .sync-dot.sync-on { background:#22c55e; box-shadow:0 0 5px #22c55e; }
    .sync-dot.sync-off { background:#6a7482; }
    .sync-dot.sync-connecting { background:#f59e0b; }

    .cat-list { margin-top:10px; display:flex; flex-direction:column; gap:2px; }
    .cat-row {
      display:flex; align-items:center; gap:8px;
      padding:6px 4px; border-radius:6px; cursor:pointer;
      user-select:none;
    }
    .cat-row:hover { background:rgba(255,255,255,.06); }
    .cat-row.disabled-cat { opacity:.5; }
    .cat-row input[type="checkbox"] { accent-color:#1687ff; cursor:pointer; }
    .cat-swatch {
      width:14px; height:14px; border-radius:50%; flex:0 0 auto;
      border:2px solid #fff; box-shadow:0 1px 3px rgba(0,0,0,.5);
      display:flex; align-items:center; justify-content:center; font-size:8px;
    }
    .cat-name { flex:1; font-size:12.5px; }
    .cat-count { font-size:11px; color:#8f99a5; font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
    .cat-empty-hint { font-size:10.5px; color:#6a7482; margin:1px 0 4px 22px; }

    .panel-actions { margin-top:10px; display:flex; gap:8px; }

    .dot-marker {
      width:10px; height:10px; border-radius:50%;
      border:2px solid #fff;
      box-shadow:0 1px 4px rgba(0,0,0,.6);
      transition:opacity .15s, filter .15s;
    }
    .dot-marker.disabled-marker {
      opacity:.28;
      filter:grayscale(100%);
    }

    .cursor-readout {
      position:absolute; z-index:1000; left:12px; bottom:12px;
      background:rgba(15,18,22,.85); color:#c7cdd4;
      border:1px solid rgba(255,255,255,.14);
      border-radius:7px; padding:5px 9px;
      font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      font-size:11.5px; pointer-events:none;
    }

    .leaflet-tooltip { font-size:11px; }
    .popup-title { font-weight:700; margin-bottom:5px; }
    .popup-cat { font-size:11px; color:#b9c0c8; margin-bottom:5px; }
    .coords { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; line-height:1.55; }
    .popup-row {
      margin-top:8px; display:flex; align-items:stretch; gap:8px;
    }
    .popup-timer {
      box-sizing:border-box;
      flex:1; display:flex; align-items:center;
      padding:6px 9px; border:1px solid rgba(255,255,255,.18);
      border-radius:7px; background:#272c33;
      font-size:12px; white-space:nowrap;
    }
    .popup-timer strong {
      font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      color:#ffd58a;
    }
    .popup-ready strong { color:#7ee787; }
    .popup-row button { box-sizing:border-box; margin:0; flex:0 0 auto; }
    .popup-switch-row { margin-top:8px; }
    .popup-switch-row button { width:100%; }
    .error { color:#ff9b94; }
  </style>
</head>
<body>
<div id="map"></div>
<div class="panel" id="panel">
  <button id="collapseBtn" class="panel-handle" type="button" title="Skjul panel">◂</button>
  <div class="panel-header">
    <div class="title">GTA V – Aktivitetskort</div>
  </div>
  <div class="panel-body" id="panelBody">
    <div class="sub">Postkasser, husrøveri, breve &amp; bankrøveri</div>
    <div class="status" id="status"><span class="dot"></span>Indlæser lokationer…</div>
    <div class="sync-row" id="syncStatus"><span class="sync-dot sync-off"></span>Tjekker delt tilstand…</div>

    <div class="search-row">
      <input id="searchInput" type="text" placeholder="Søg efter navn…" autocomplete="off" />
    </div>

    <div class="cat-list" id="catList"></div>

    <div class="panel-actions">
      <button id="fitBtn" type="button">Zoom til synlige</button>
    </div>
  </div>
</div>

<div class="cursor-readout" id="cursorReadout">X: —  Y: —</div>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://www.gstatic.com/firebasejs/12.17.1/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/12.17.1/firebase-database-compat.js"></script>
<script>
(() => {
  const IMAGE_URL = "GTAV-HD-MAP-roadmap.jpg";
  const MAILBOX_DATA_URL = "https://raw.githubusercontent.com/DurtyFree/gta-v-data-dumps/master/objectslocations/worldPostBoxes.json";
  const DUMPSTER_DATA_URL = "https://raw.githubusercontent.com/DurtyFree/gta-v-data-dumps/master/objectslocations/worldBinsDumpsters.json";

  // Egne lokationer — format: { name: "...", x: 000.0, y: 000.0, z: 00.0 }
  const HOUSE_ROBBERY_POINTS = [
    { name: "Husrøveri #1", x: 2536.73, y: 2846.94, z: 37.59 },
    { name: "Husrøveri #2", x: 839.53, y: -2308.25, z: 29.66 },
    { name: "Husrøveri #3", x: 732.26, y: 473.87, z: 138.84 },
    { name: "Husrøveri #4", x: -1380.17, y: 733.51, z: 181.97 },
    { name: "Husrøveri #5", x: -692.81, y: 76.55, z: 54.86 },
    { name: "Husrøveri #6", x: -915.13, y: -444.44, z: 38.61 },
    { name: "Husrøveri #7", x: -860.44, y: -354.7, z: 37.68 },
    { name: "Husrøveri #8", x: -883.0, y: -1285.85, z: 12.2 }
  ];
  const SELL_LETTERS_POINTS = [
    { name: "Brevmanden #1", x: -1081.97, y: -1673.02, z: 3.7 },
    { name: "Brevmanden #2", x: -1122.42, y: -1445.73, z: 4.07 },
    { name: "Brevmanden #3", x: -1022.64, y: -1022.53, z: 1.15 },
    { name: "Brevmanden #4", x: -1490.26, y: -325.54, z: 45.94 }
  ];
  const BANK_ROBBERY_POINTS = [
    { name: "Bankrøveri #1", x: -773.34, y: 896.12, z: 202.9 }
  ];

  // Delt cooldown-status via Firebase Realtime Database. Sæt til null for
  // kun lokal (per-browser) tilstand.
  const FIREBASE_CONFIG = {
    apiKey: "AIzaSyCpnd1QihcbFkwN2f8m-UrQFlYVRVk9wsc",
    authDomain: "legacyrp-59cd8.firebaseapp.com",
    databaseURL: "https://legacyrp-59cd8-default-rtdb.europe-west1.firebasedatabase.app",
    projectId: "legacyrp-59cd8"
  };

  // Coordinate transform used by GTA V Leaflet maps (only affects the
  // background image alignment — marker latlngs are raw game X/Y):
  // x' = 0.02072*x + 117.3
  // y' = -0.0205*y + 172.8
  const crs = L.extend({}, L.CRS.Simple, {
    projection: L.Projection.LonLat,
    transformation: new L.Transformation(0.02072, 117.3, -0.0205, 172.8),
    scale: zoom => Math.pow(2, zoom),
    zoom: scale => Math.log(scale) / Math.LN2,
    distance: (a, b) => {
      const dx = b.lng - a.lng, dy = b.lat - a.lat;
      return Math.sqrt(dx*dx + dy*dy);
    },
    infinite: true
  });

  // Full 8192x8192 image bounds expressed in GTA coordinates.
  const west  = -117.3 / 0.02072;
  const east  = (256 - 117.3) / 0.02072;
  const north = 172.8 / 0.0205;
  const south = (172.8 - 256) / 0.0205;
  const imageBounds = L.latLngBounds([south, west], [north, east]);

  const map = L.map("map", {
    crs,
    minZoom: 0,
    maxZoom: 7,
    zoomControl: false,
    attributionControl: false,
    preferCanvas: true,
    doubleClickZoom: false // two quick clicks on a marker shouldn't also zoom the map
  });
  L.control.zoom({ position: "topright" }).addTo(map);

  L.imageOverlay(IMAGE_URL, imageBounds, { interactive:false }).addTo(map);
  map.fitBounds(imageBounds, { padding:[10,10] });

  function esc(s) {
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  }

  function copyCoords(x,y,z,btn) {
    const text = `vector3(${x.toFixed(5)}, ${y.toFixed(5)}, ${z.toFixed(5)})`;
    navigator.clipboard.writeText(text).then(() => {
      const old = btn.textContent;
      btn.textContent = "Kopieret";
      setTimeout(() => btn.textContent = old, 900);
    }).catch(() => prompt("Kopiér koordinater:", text));
  }
  window.copyCoords = copyCoords;

  // ---------------------------------------------------------------------
  // Cooldown timers
  //  - A normal category has NO timer by default.
  //  - Add `cooldownMs` to a category only when that specific category
  //    should have a per-point cooldown.
  //  - Shared cooldown categories still use their fixed reset schedule.
  // ---------------------------------------------------------------------
  const SHARED_RESET_HOURS = [4, 12, 18];
  const STORAGE_KEY = "gta5_activity_map_cooldowns_v2";
  const timers = {};

  // Which point is "active" for a sharedCooldown category (catId -> idx).
  // While a shared cooldown is running, only this point stays visible —
  // the rest are hidden entirely so it's obvious where he's standing.
  const sharedSelections = {};
  const SELECTIONS_STORAGE_KEY = "gta5_activity_map_selections_v1";

  function loadSelections() {
    try {
      const saved = JSON.parse(localStorage.getItem(SELECTIONS_STORAGE_KEY) || "{}");
      Object.entries(saved).forEach(([k, v]) => { sharedSelections[k] = v; });
    } catch (_) {}
  }
  function saveSelections() {
    try { localStorage.setItem(SELECTIONS_STORAGE_KEY, JSON.stringify(sharedSelections)); } catch (_) {}
  }

  function nextResetTime(now) {
    const d = new Date(now);
    for (const h of SHARED_RESET_HOURS) {
      const candidate = new Date(d.getFullYear(), d.getMonth(), d.getDate(), h, 0, 0, 0);
      if (candidate.getTime() > now) return candidate;
    }
    return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, SHARED_RESET_HOURS[0], 0, 0, 0);
  }

  // { db, cooldownsRef } once connected — null means local-only fallback.
  let fb = null;

  function loadTimers() {
    try {
      const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
      const now = Date.now();
      for (const [id, end] of Object.entries(saved)) {
        if (Number(end) > now) timers[id] = Number(end);
      }
      saveTimers();
    } catch (_) {}
  }
  function saveTimers() {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(timers)); } catch (_) {}
  }
  function formatRemaining(ms) {
    const total = Math.max(0, Math.ceil(ms / 1000));
    const min = Math.floor(total / 60);
    const sec = total % 60;
    return `${String(min).padStart(2,"0")}:${String(sec).padStart(2,"0")}`;
  }
  // Same as formatRemaining but also shows hours, for the shared reset
  // cooldown which can be up to ~10h out (18:00 → 04:00).
  function formatRemainingLong(ms) {
    const total = Math.max(0, Math.ceil(ms / 1000));
    const h = Math.floor(total / 3600);
    const m = Math.floor((total % 3600) / 60);
    const s = total % 60;
    const mm = String(m).padStart(2,"0"), ss = String(s).padStart(2,"0");
    return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
  }
  function formatClock(date) {
    return `${String(date.getHours()).padStart(2,"0")}:${String(date.getMinutes()).padStart(2,"0")}`;
  }

  function setSyncStatus(state) {
    const el = document.getElementById("syncStatus");
    if (!el) return;
    if (state === "on") {
      el.innerHTML = `<span class="sync-dot sync-on"></span>Delt live med gruppen`;
    } else if (state === "connecting") {
      el.innerHTML = `<span class="sync-dot sync-connecting"></span>Forbinder til delt tilstand…`;
    } else {
      el.innerHTML = `<span class="sync-dot sync-off"></span>Lokal tilstand — kun denne browser`;
    }
  }

  // Re-applies dimmed/disabled marker state from the current `timers` object
  // (only categories with explicit cooldownMs), plus sharedCooldown categories.
  function applyAllCooldownVisuals() {
    const now = Date.now();
    CATEGORIES.forEach(cat => {
      if (hasPerPointCooldown(cat)) {
        cat.points.forEach((_, idx) => {
          const id = timerKey(cat, idx);
          setCooldownVisual(cat, idx, !!(timers[id] && timers[id] > now));
        });
      } else if (cat.sharedCooldown) {
        applySharedCooldownVisual(cat);
      }
    });
    refreshMarkerVisibility();
    refreshOpenPopups();
  }

  function initFirebase() {
    // Always seed from local storage first, synchronously — Firebase (if
    // configured) then overlays fresher server data once it responds. This
    // way a reload never shows a briefly-empty/wrong state while waiting
    // on the network, and it's a real fallback if any Firebase path is
    // unreachable (e.g. missing security rule) rather than just going blank.
    loadTimers();
    loadSelections();

    if (!FIREBASE_CONFIG || typeof firebase === "undefined") {
      setSyncStatus("off");
      return;
    }

    setSyncStatus("connecting");
    try {
      firebase.initializeApp(FIREBASE_CONFIG);
      const db = firebase.database();
      const cooldownsRef = db.ref("cooldowns");
      fb = { db, cooldownsRef };

      db.ref(".info/connected").on("value", snap => {
        setSyncStatus(snap.val() ? "on" : "connecting");
      });

      // Database is the source of truth once connected — this is what
      // makes "taget" status shared across everyone's browser. The active
      // point for a sharedCooldown category rides along as a sibling key
      // ("<catId>:selection") under this SAME node/listener/security rule,
      // so it can never arrive out of sync with its own cooldown entry.
      cooldownsRef.on("value", snapshot => {
        const data = snapshot.val() || {};
        const now = Date.now();
        Object.keys(timers).forEach(id => delete timers[id]);
        Object.keys(sharedSelections).forEach(k => delete sharedSelections[k]);
        Object.entries(data).forEach(([id, val]) => {
          if (id.endsWith(":selection")) {
            sharedSelections[id.slice(0, -":selection".length)] = val;
          } else if (Number(val) > now) {
            timers[id] = Number(val);
          }
        });
        applyAllCooldownVisuals();
      });
    } catch (err) {
      console.error("Firebase-opsætning fejlede, falder tilbage til lokal tilstand:", err);
      fb = null;
      setSyncStatus("off");
    }
  }

  // ---------------------------------------------------------------------
  // Category definitions
  // ---------------------------------------------------------------------
  const CATEGORIES = [
    {
      id: "mailbox",
      label: "Postkasser",
      color: "#1687ff",
      cooldownMs: 30 * 60 * 1000, // ONLY mailboxes: 30 minutes
      timerHint: "Klik en postkasse for at starte 30 min cooldown",
      remoteUrl: MAILBOX_DATA_URL,
      points: [] // filled after fetch
    },
    {
      id: "dumpster",
      label: "Dumpsters",
      color: "#14b8a6",
      cooldownMs: 20 * 60 * 1000, // ONLY dumpsters: 20 minutes
      timerHint: "Klik en dumpster for at starte 20 min cooldown",
      remoteUrl: DUMPSTER_DATA_URL,
      modelNames: ["prop_dumpster_01a", "prop_dumpster_02a", "prop_dumpster_02b"],
      defaultEnabled: false, // off by default (~hundreds of points, easy to turn on)
      points: [] // both dumpster models are loaded into this one category
    },
    {
      id: "houseRobbery",
      label: "Husrøveri",
      color: "#e11d48",
      sharedCooldown: true, // one cooldown shared across all locations
      points: HOUSE_ROBBERY_POINTS
    },
    {
      id: "sellLetters",
      label: "Sælg breve",
      color: "#22c55e",
      sharedCooldown: true, // one cooldown shared across all 4 points
      points: SELL_LETTERS_POINTS
    },
    {
      id: "bankRobbery",
      label: "Bankrøveri",
      color: "#f59e0b",
      points: BANK_ROBBERY_POINTS
    }
  ];

  const catById = {};
  CATEGORIES.forEach(cat => {
    cat.group = L.layerGroup();
    cat.enabled = cat.defaultEnabled !== false;
    cat.markers = {}; // pointId -> marker
    catById[cat.id] = cat;
  });

  // Remembers which category checkboxes are on/off across reloads. A saved
  // choice always wins; categories the user has never touched fall back to
  // their own defaultEnabled (true unless a category opts out, like dumpsters).
  const FILTERS_STORAGE_KEY = "gta5_activity_map_filters_v1";

  function loadCategoryFilters() {
    let saved = {};
    try { saved = JSON.parse(localStorage.getItem(FILTERS_STORAGE_KEY) || "{}"); } catch (_) {}
    CATEGORIES.forEach(cat => {
      if (Object.prototype.hasOwnProperty.call(saved, cat.id)) {
        cat.enabled = !!saved[cat.id];
      }
    });
  }
  function saveCategoryFilters() {
    const toSave = {};
    CATEGORIES.forEach(cat => { toSave[cat.id] = cat.enabled; });
    try { localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(toSave)); } catch (_) {}
  }
  loadCategoryFilters();

  function buildIcon(cat) {
    return L.divIcon({
      className: "",
      html: `<div class="dot-marker" style="background:${cat.color}"></div>`,
      iconSize: [14,14],
      iconAnchor: [7,7]
    });
  }

  function markerId(cat, idx) { return `${cat.id}:${idx}`; }

  // A category only gets a normal per-point timer when it explicitly defines
  // a positive `cooldownMs`. New categories therefore have NO timer by default.
  function hasPerPointCooldown(cat) {
    return Number.isFinite(cat.cooldownMs) && cat.cooldownMs > 0;
  }

  // The cooldown key: per-point normally, but one shared key for every
  // point in a sharedCooldown category (so they all read/write the same
  // timer, "using" one disables all of them).
  function timerKey(cat, idx) {
    return cat.sharedCooldown ? `${cat.id}:shared` : markerId(cat, idx);
  }

  // Dims every point in a sharedCooldown category except the selected one,
  // which stays fully active/visible — same dimmed look as mailboxes, just
  // applied to 3 of the 4 points instead of the one that was clicked.
  function applySharedCooldownVisual(cat) {
    const tId = timerKey(cat, 0);
    const active = !!(timers[tId] && timers[tId] > Date.now());
    const selectedIdx = sharedSelections[cat.id];
    cat.points.forEach((_, idx) => {
      const isSelected = active && selectedIdx !== undefined && String(selectedIdx) === String(idx);
      // sinkZIndex=false: only 4 points here, no dense-overlap problem to
      // solve, and the -10000 mailbox offset was sinking these behind the
      // ~800 mailbox markers elsewhere on the map — looked like they'd
      // vanished even though they were only dimmed.
      setCooldownVisual(cat, idx, active && !isSelected, false);
    });
  }

  // "Vælg denne i stedet" button in the popup — moves which point is active
  // without touching the shared end time (the 04/12/18 reset keeps its own
  // schedule regardless of who's currently marked as the active one).
  function switchSharedSelection(catId, idx) {
    const cat = catById[catId];
    if (!cat) return;
    const tId = timerKey(cat, idx);
    if (!(timers[tId] && timers[tId] > Date.now())) return; // nothing active to switch

    sharedSelections[cat.id] = idx;
    applySharedCooldownVisual(cat);
    refreshOpenPopups();

    // The popup sits right on top of its own marker, so the dim→active
    // change on THIS marker is invisible while it's open — close it so
    // the switch is actually visible on the map right away.
    const marker = cat.markers[markerId(cat, idx)];
    if (marker) marker.closePopup();

    if (fb) {
      fb.db.ref("cooldowns/" + cat.id + ":selection").set(idx).catch(() => saveSelections());
    } else {
      saveSelections();
    }
  }
  window.switchSharedSelection = switchSharedSelection;

  function setCooldownVisual(cat, idx, active, sinkZIndex = true) {
    const marker = cat.markers[markerId(cat, idx)];
    if (!marker) return;

    // Cooling-down markers sink behind ready ones so overlapping mailboxes
    // stay clickable one at a time instead of blocking each other.
    if (sinkZIndex) marker.setZIndexOffset(active ? -10000 : 1000);

    const el = marker.getElement();
    if (!el) return;
    const inner = el.querySelector(".dot-marker");
    if (inner) inner.classList.toggle("disabled-marker", active);
  }

  function popupHTML(cat, idx, point) {
    let timerBlock = "";
    let switchBlock = "";

    if (hasPerPointCooldown(cat) || cat.sharedCooldown) {
      const id = timerKey(cat, idx);
      const now = Date.now();
      const active = timers[id] && timers[id] > now;
      let timerText;
      if (!active) {
        timerText = "KLAR";
      } else if (cat.sharedCooldown) {
        timerText = `${formatClock(new Date(timers[id]))} (${formatRemainingLong(timers[id] - now)})`;
      } else {
        timerText = formatRemaining(timers[id] - now);
      }
      const timerClass = active ? "popup-timer" : "popup-timer popup-ready";
      timerBlock = `<div class="${timerClass}"><strong>${timerText}</strong></div>`;

      // On a disabled point in a sharedCooldown category, offer to make
      // THIS one the active one instead (same end time, new selection).
      if (cat.sharedCooldown && active) {
        const selectedIdx = sharedSelections[cat.id];
        const isSelected = selectedIdx !== undefined && String(selectedIdx) === String(idx);
        if (!isSelected) {
          switchBlock = `<button onclick="switchSharedSelection('${cat.id}',${idx})">Vælg denne i stedet</button>`;
        }
      }
    }

    return `
      <div class="popup-title">${esc(point.name)}</div>
      <div class="popup-cat">${esc(cat.label)}${point.model ? ` · ${esc(point.model)}` : ""}</div>
      <div class="coords">
        vector3(${esc(point.x.toFixed(5))}, ${esc(point.y.toFixed(5))}, ${esc(point.z.toFixed(5))})
      </div>
      <div class="popup-row">
        ${timerBlock}
        <button onclick="copyCoords(${point.x},${point.y},${point.z},this)">Kopiér vector3</button>
      </div>
      ${switchBlock ? `<div class="popup-switch-row">${switchBlock}</div>` : ""}
    `;
  }

  function refreshOpenPopups() {
    CATEGORIES.forEach(cat => {
      Object.entries(cat.markers).forEach(([id, marker]) => {
        if (marker.isPopupOpen && marker.isPopupOpen()) {
          const idx = id.split(":")[1];
          marker.setPopupContent(popupHTML(cat, idx, cat.points[idx]));
        }
      });
    });
  }

  // No always-on timer list — countdown text only shows inside a marker's
  // popup. Whichever client notices an expiry clears it from the shared
  // database too (harmless if more than one does it at once).
  function processTimers() {
    const now = Date.now();
    let changed = false;

    let selectionsChanged = false;
    for (const [id, end] of Object.entries(timers)) {
      if (end <= now) {
        delete timers[id];
        const [catId, idxPart] = id.split(":");
        const cat = catById[catId];
        if (cat) {
          if (idxPart === "shared") {
            delete sharedSelections[catId];
            selectionsChanged = true;
            if (fb) fb.db.ref("cooldowns/" + catId + ":selection").remove().catch(() => {});
            applySharedCooldownVisual(cat);
          } else {
            setCooldownVisual(cat, idxPart, false);
          }
        }
        changed = true;

        if (fb) fb.db.ref("cooldowns/" + id).remove().catch(() => {});
      }
    }
    if (changed && !fb) saveTimers();
    if (selectionsChanged && !fb) saveSelections();

    refreshOpenPopups();
  }

  function startTimer(cat, idx) {
    const id = timerKey(cat, idx);
    const now = Date.now();
    if (timers[id] && timers[id] > now) return;

    // No implicit/global cooldown: a normal category must explicitly define
    // cooldownMs. Shared cooldown categories use the fixed reset schedule.
    if (!cat.sharedCooldown && !hasPerPointCooldown(cat)) return;

    const end = cat.sharedCooldown
      ? nextResetTime(now).getTime()
      : now + cat.cooldownMs;

    // Optimistic local update for instant feedback; the live listener
    // reconciles moments later and pushes the same state to everyone else.
    timers[id] = end;
    if (cat.sharedCooldown) {
      sharedSelections[cat.id] = idx;
      applySharedCooldownVisual(cat);
    } else {
      setCooldownVisual(cat, idx, true);
    }

    if (fb) {
      fb.db.ref("cooldowns/" + id).set(end).catch(err => {
        console.error("Kunne ikke gemme cooldown delt, gemmer kun lokalt:", err);
        saveTimers();
      });
      if (cat.sharedCooldown) {
        fb.db.ref("cooldowns/" + cat.id + ":selection").set(idx).catch(() => saveSelections());
      }
    } else {
      saveTimers();
      if (cat.sharedCooldown) saveSelections();
    }
  }

  initFirebase();
  // 1s matches the mm:ss/HH:MM display resolution exactly (no visible loss),
  // and keeps popup content from being rebuilt too often — rebuilding more
  // than needed could occasionally swap out a button out from under a click
  // that lands in the same instant.
  setInterval(processTimers, 1000);

  // ---------------------------------------------------------------------
  // Rendering
  // ---------------------------------------------------------------------
  function renderCategory(cat) {
    cat.group.clearLayers();
    cat.markers = {};

    const icon = buildIcon(cat);

    cat.points.forEach((point, idx) => {
      const latlng = L.latLng(point.y, point.x);

      // riseOnHover lifts a marker to the front while the cursor is over
      // it, so overlapping markers are still individually reachable.
      const m = L.marker(latlng, {
        icon, keyboard:false,
        riseOnHover:true, riseOffset:20000,
        zIndexOffset:1000
      }).addTo(cat.group);
      const id = markerId(cat, idx);
      cat.markers[id] = m;

      m.bindTooltip(`${point.name} · ${point.x.toFixed(1)}, ${point.y.toFixed(1)}`, {
        direction:"top", offset:[0,-9], opacity:.94
      });
      m.bindPopup(popupHTML(cat, idx, point), { maxWidth:300 });

      if (hasPerPointCooldown(cat) || cat.sharedCooldown) {
        // Leaflet opens the bound popup on click by default — we replace
        // that with our own two-step behaviour, so remove its handler.
        m.off("click");
        m.on("click", () => {
          const tId = timerKey(cat, idx);
          const wasActive = !!(timers[tId] && timers[tId] > Date.now());
          if (!wasActive) {
            // First click just takes it (all 4 for sælg breve) — no popup.
            startTimer(cat, idx);
            m.setPopupContent(popupHTML(cat, idx, point));
            return;
          }
          // Already taken: click again to see status/coords/copy.
          m.setPopupContent(popupHTML(cat, idx, point));
          m.openPopup();
        });
        if (hasPerPointCooldown(cat)) {
          m.on("add", () => setTimeout(() => {
            const tId = timerKey(cat, idx);
            setCooldownVisual(cat, idx, !!(timers[tId] && timers[tId] > Date.now()));
          }, 0));
        }
      }
    });

    // sharedCooldown categories: apply the dimmed/active state once, after
    // every point's marker exists (rather than per-marker).
    if (cat.sharedCooldown) {
      setTimeout(() => applySharedCooldownVisual(cat), 0);
    }
  }

  function categoryBounds(cat) {
    if (!cat.points.length) return null;
    const bounds = L.latLngBounds();
    cat.points.forEach(p => bounds.extend(L.latLng(p.y, p.x)));
    return bounds;
  }

  // ---------------------------------------------------------------------
  // Filter panel (categories + search)
  // ---------------------------------------------------------------------
  function buildCatListUI() {
    const container = document.getElementById("catList");
    container.innerHTML = CATEGORIES.map(cat => `
      <div>
        <label class="cat-row ${cat.points.length ? "" : "disabled-cat"}" data-cat="${cat.id}">
          <input type="checkbox" data-cat-toggle="${cat.id}" ${cat.enabled ? "checked" : ""} />
          <span class="cat-swatch" style="background:${cat.color}"></span>
          <span class="cat-name">${esc(cat.label)}</span>
          <span class="cat-count" data-cat-count="${cat.id}">${cat.points.length}</span>
        </label>
        ${cat.points.length
          ? (hasPerPointCooldown(cat)
              ? `<div class="cat-empty-hint">${esc(cat.timerHint || "Klik lokationen for at starte cooldown")}</div>`
              : cat.sharedCooldown
                ? `<div class="cat-empty-hint">Klik én lokation for at disable de andre til kl. 04/12/18</div>`
                : "")
          : `<div class="cat-empty-hint">Ingen lokationer tilføjet endnu</div>`}
      </div>
    `).join("");

    container.querySelectorAll("[data-cat-toggle]").forEach(input => {
      input.addEventListener("change", () => {
        const cat = catById[input.dataset.catToggle];
        cat.enabled = input.checked;
        saveCategoryFilters();
        applyVisibility();
      });
    });
  }

  function updateCatCounts() {
    CATEGORIES.forEach(cat => {
      const el = document.querySelector(`[data-cat-count="${cat.id}"]`);
      if (el) el.textContent = String(cat.points.length);
    });
  }

  function applyVisibility() {
    CATEGORIES.forEach(cat => {
      const shouldShow = cat.enabled;
      const isShown = map.hasLayer(cat.group);
      if (shouldShow && !isShown) cat.group.addTo(map);
      if (!shouldShow && isShown) map.removeLayer(cat.group);
    });
    refreshMarkerVisibility();
  }

  // Search filter only — sharedCooldown dimming is handled separately by
  // applySharedCooldownVisual (points stay visible, just dimmed).
  function refreshMarkerVisibility() {
    const q = document.getElementById("searchInput").value.trim().toLowerCase();
    CATEGORIES.forEach(cat => {
      cat.points.forEach((point, idx) => {
        const marker = cat.markers[markerId(cat, idx)];
        if (!marker) return;
        const el = marker.getElement();
        if (!el) return;
        const matches = !q || point.name.toLowerCase().includes(q);
        el.style.display = matches ? "" : "none";
      });
    });
  }

  document.getElementById("searchInput").addEventListener("input", () => refreshMarkerVisibility());

  document.getElementById("fitBtn").addEventListener("click", () => {
    const bounds = L.latLngBounds();
    let any = false;
    CATEGORIES.forEach(cat => {
      if (!cat.enabled) return;
      const b = categoryBounds(cat);
      if (b) { bounds.extend(b); any = true; }
    });
    if (any) map.fitBounds(bounds.pad(0.12));
    else map.fitBounds(imageBounds);
  });

  function updateStatus() {
    const parts = CATEGORIES.map(cat => `${cat.points.length} ${cat.label.toLowerCase()}`);
    document.getElementById("status").innerHTML =
      `<span class="dot"></span>${parts.join(" · ")}`;
  }

  // ---------------------------------------------------------------------
  // Collapsible filter panel
  // ---------------------------------------------------------------------
  const PANEL_COLLAPSED_KEY = "gta5_activity_map_panel_collapsed_v1";
  const panelEl = document.getElementById("panel");
  const collapseBtn = document.getElementById("collapseBtn");

  function setPanelCollapsed(collapsed) {
    panelEl.classList.toggle("collapsed", collapsed);
    collapseBtn.textContent = collapsed ? "▸" : "◂";
    collapseBtn.title = collapsed ? "Vis panel" : "Skjul panel";
    try { localStorage.setItem(PANEL_COLLAPSED_KEY, collapsed ? "1" : "0"); } catch (_) {}
  }

  collapseBtn.addEventListener("click", () => {
    setPanelCollapsed(!panelEl.classList.contains("collapsed"));
  });

  let startCollapsed = false;
  try { startCollapsed = localStorage.getItem(PANEL_COLLAPSED_KEY) === "1"; } catch (_) {}
  setPanelCollapsed(startCollapsed);

  // ---------------------------------------------------------------------
  // Cursor coordinate readout (handy for finding new x/y while exploring)
  // ---------------------------------------------------------------------
  const cursorReadout = document.getElementById("cursorReadout");
  map.on("mousemove", e => {
    cursorReadout.textContent = `X: ${e.latlng.lng.toFixed(1)}  Y: ${e.latlng.lat.toFixed(1)}`;
  });
  map.on("mouseout", () => { cursorReadout.textContent = "X: —  Y: —"; });

  // ---------------------------------------------------------------------
  // Init: render static categories immediately, then fetch world-object data
  // for postboxes and dumpsters.
  // ---------------------------------------------------------------------
  const REMOTE_CATEGORY_IDS = new Set(["mailbox", "dumpster"]);
  CATEGORIES.filter(c => !REMOTE_CATEGORY_IDS.has(c.id)).forEach(cat => renderCategory(cat));
  buildCatListUI();
  applyVisibility();
  updateStatus();

  function refreshAfterRemoteLoad(cat) {
    renderCategory(cat);
    buildCatListUI();
    applyVisibility();
    updateStatus();
  }

  function loadMailboxPoints() {
    const cat = catById.mailbox;
    return fetch(cat.remoteUrl)
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => {
        const boxes = data.filter(o => o.Name === "prop_postbox_01a");
        cat.points = boxes.map((o, i) => ({
          name: `Postkasse #${i + 1}`,
          model: o.Name,
          x: Number(o.Position.X),
          y: Number(o.Position.Y),
          z: Number(o.Position.Z)
        }));
        refreshAfterRemoteLoad(cat);
        return cat;
      });
  }

  function loadDumpsterPoints() {
    const cat = catById.dumpster;
    const allowedModels = new Set(cat.modelNames);

    return fetch(cat.remoteUrl)
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => {
        const dumpsters = data.filter(o => allowedModels.has(o.Name));
        cat.points = dumpsters.map((o, i) => ({
          name: `Dumpster #${i + 1}`,
          model: o.Name,
          x: Number(o.Position.X),
          y: Number(o.Position.Y),
          z: Number(o.Position.Z)
        }));
        refreshAfterRemoteLoad(cat);
        return cat;
      });
  }

  Promise.allSettled([
    loadMailboxPoints(),
    loadDumpsterPoints()
  ]).then(results => {
    const errors = [];
    if (results[0].status === "rejected") errors.push(`postkasser: ${results[0].reason?.message || results[0].reason}`);
    if (results[1].status === "rejected") errors.push(`dumpsters: ${results[1].reason?.message || results[1].reason}`);

    if (errors.length) {
      document.getElementById("status").innerHTML =
        `<span class="error">Kunne ikke indlæse ${esc(errors.join(" · "))}</span>`;
      return;
    }

    // Keep the same startup behaviour as before: zoom to the mailbox area.
    const b = categoryBounds(catById.mailbox);
    if (b) map.fitBounds(b.pad(0.12));
  });
})();
</script>
</body>
</html>