// user-actions.jsx — User-initiated writes: check-in, rate, toggle checklist.
// Talks to the Render API (window.PH365_API_BASE). Each write returns the
// inserted/updated row and emits a 'ph365-user-data-changed' event so
// dependent screens can refresh.
(function () {
  const supabase = window.PH365_SUPABASE;
  const API_BASE = window.PH365_API_BASE || '';

  // ── Helpers ────────────────────────────────────────────────────────────
  async function authedFetch(path, init = {}) {
    const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
    if (supabase) {
      const { data: { session } } = await supabase.auth.getSession();
      if (session?.access_token) headers.Authorization = `Bearer ${session.access_token}`;
    }
    const url = API_BASE ? `${API_BASE}${path}` : path;
    const res = await fetch(url, { ...init, headers });
    if (!res.ok) {
      let detail = `${res.status}`;
      try { detail = (await res.json()).error || detail; } catch {}
      throw new Error(`API ${path} failed: ${detail}`);
    }
    return res.json();
  }

  function emit(name, detail) {
    window.dispatchEvent(new CustomEvent(name, { detail }));
    window.dispatchEvent(new CustomEvent('ph365-user-data-changed', { detail: { kind: name, ...detail } }));
  }

  // ── Check-in: log a ride visit ─────────────────────────────────────────
  // dayId / visitedAt are for Park Day: a ride confirmed on the end-of-day
  // screen is backdated to when the detection says it happened, rather than
  // to the moment the user tapped Keep. Left unset, a check-in during an
  // active day still attaches itself to that day, so a manual "I just rode
  // this" and a confirmed detection land in the same timeline.
  async function checkIn({ attractionId, waitMinutes = null, source = 'manual', dayId = null, visitedAt = null }) {
    if (!attractionId) throw new Error('attractionId required');

    const activeDay = dayId ?? (window.PH365_DAY?.snapshot?.().dayId ?? null);

    // Direct Supabase write — RLS ensures user_id = auth.uid().
    // Signed-out users fall through to the localStorage path so a guest
    // tapping "I rode this" still sees it persist across refresh.
    if (supabase) {
      const { data: { user } } = await supabase.auth.getUser();
      if (user) {
        const base = {
          user_id: user.id,
          attraction_id: attractionId,
          wait_minutes: waitMinutes,
        };
        let { data, error } = await supabase
          .from('user_visits')
          .insert({
            ...base,
            source: source === 'gps' ? 'gps' : 'manual',
            ...(activeDay ? { day_id: activeDay } : {}),
            ...(visitedAt ? { visited_at: visitedAt } : {}),
          })
          .select()
          .single();

        // The Park Day migration adds user_visits.day_id and .source. If this
        // build is live before that migration has been applied, PostgREST
        // rejects the whole insert for an unknown column — which would take
        // the ordinary "I just rode this" button down with it. Retry without
        // the new columns rather than let a pending migration break check-in.
        if (error && /column .*(day_id|source)/i.test(error.message || '')) {
          console.warn('[checkIn] park-day columns missing, retrying without:', error.message);
          ({ data, error } = await supabase
            .from('user_visits')
            .insert({ ...base, ...(visitedAt ? { visited_at: visitedAt } : {}) })
            .select()
            .single());
        }
        if (error) throw error;

        // Trigger achievement re-evaluation (server endpoint)
        try {
          const result = await authedFetch('/api/user/evaluate-achievements', { method: 'POST' });
          const newlyUnlocked = result?.newly_unlocked ?? [];
          if (newlyUnlocked.length) {
            for (const u of newlyUnlocked) {
              window.dispatchEvent(new CustomEvent('ph365-achievement-unlocked', { detail: u }));
            }
          }
        } catch (e) {
          console.warn('[checkIn] achievement eval failed:', e);
        }

        emit('ph365-checkin', { visit: data });
        return data;
      }
    }

    // Fallback: localStorage so the prototype still functions for guests.
    const key = 'ph365_local_visits';
    const list = JSON.parse(localStorage.getItem(key) || '[]');
    const visit = {
      id: 'local-' + Date.now(),
      attraction_id: attractionId,
      wait_minutes: waitMinutes,
      visited_at: new Date().toISOString(),
      source,
    };
    list.push(visit);
    localStorage.setItem(key, JSON.stringify(list));
    emit('ph365-checkin', { visit, local: true });
    return visit;
  }

  // ── Rate an attraction (0-10 stars + optional review) ──────────────────
  async function rateAttraction({ attractionId, stars, reviewText = null, isPublic = true }) {
    if (!attractionId || stars == null || stars < 0 || stars > 10) {
      throw new Error('attractionId + stars (0-10) required');
    }
    if (!supabase) {
      const key = 'ph365_local_ratings';
      const dict = JSON.parse(localStorage.getItem(key) || '{}');
      dict[attractionId] = { stars, reviewText, ratedAt: new Date().toISOString() };
      localStorage.setItem(key, JSON.stringify(dict));
      emit('ph365-rated', { attractionId, stars, local: true });
      return { stars };
    }

    const { data: { user } } = await supabase.auth.getUser();
    if (!user) throw new Error('Not signed in');

    const { data, error } = await supabase
      .from('user_ratings')
      .upsert({
        user_id: user.id,
        attraction_id: attractionId,
        stars,
        review_text: reviewText,
        is_public: isPublic,
        updated_at: new Date().toISOString(),
      }, { onConflict: 'user_id,attraction_id' })
      .select()
      .single();
    if (error) throw error;
    emit('ph365-rated', { attractionId, stars });
    return data;
  }

  // ── Rate a checklist item (food / show / etc.) ─────────────────────────
  // Stored on the user_checklist row itself — creates the row if needed so
  // a user can rate without first toggling "done."
  async function rateChecklistItem({ checklistId, templateId, name, category, landId, stars, reviewText = null, verb, isPublic = true }) {
    if (stars == null || stars < 0 || stars > 10) throw new Error('stars (0-10) required');
    if (!supabase) {
      const key = 'ph365_local_checklist_ratings';
      const dict = JSON.parse(localStorage.getItem(key) || '{}');
      dict[templateId || checklistId || name] = { stars, reviewText, ratedAt: new Date().toISOString() };
      localStorage.setItem(key, JSON.stringify(dict));
      emit('ph365-rated', { templateId: templateId || checklistId, stars, local: true });
      return { stars };
    }

    const { data: { user } } = await supabase.auth.getUser();
    if (!user) throw new Error('Not signed in');

    // Update-by-id path is only reached when we have an existing row id
    // and no template_id (legacy rows). Modern rows are uniquely keyed on
    // (user_id, template_id) — see migration 20260503030000 — so we upsert.
    if (checklistId && !templateId) {
      const { data, error } = await supabase
        .from('user_checklist')
        .update({
          stars, review_text: reviewText, is_public: isPublic,
        })
        .eq('id', checklistId)
        .eq('user_id', user.id)
        .select()
        .single();
      if (error) throw error;
      emit('ph365-rated', { templateId: templateId || checklistId, stars });
      return data;
    }

    if (templateId) {
      const { data, error } = await supabase
        .from('user_checklist')
        .upsert({
          user_id: user.id, name, category, land_id: landId,
          template_id: templateId,
          verb: verb || 'COMPLETED',
          done: true,
          completed_at: new Date().toISOString(),
          stars, review_text: reviewText, is_public: isPublic,
        }, { onConflict: 'user_id,template_id' })
        .select()
        .single();
      if (error) throw error;
      emit('ph365-rated', { templateId, stars });
      return data;
    }

    const { data, error } = await supabase
      .from('user_checklist')
      .insert({
        user_id: user.id, name, category, land_id: landId,
        verb, done: true, completed_at: new Date().toISOString(),
        stars, review_text: reviewText, is_public: isPublic,
      })
      .select()
      .single();
    if (error) throw error;
    emit('ph365-rated', { templateId: templateId || name, stars });
    return data;
  }

  // ── Open the rating modal from anywhere ────────────────────────────────
  // The modal listens for 'ph365-open-rating' and dispatches the right
  // save function depending on `kind` ('attraction' | 'checklist').
  function openRatingModal(payload) {
    window.dispatchEvent(new CustomEvent('ph365-open-rating', { detail: payload }));
  }

  // ── Toggle a checklist item done / undone ─────────────────────────────
  // Always upsert on (user_id, template_id) — the migration
  // 20260503030000_user_checklist_unique adds the matching constraint.
  // This makes the checklist a true lifetime log: tap-tap-tap on the same
  // item collapses to one row, and the live-data overlay always finds a
  // single deterministic done/undone state on refresh.
  // Local users (no Supabase config OR signed-out guest) persist to
  // localStorage so the checklist survives a refresh even without auth.
  async function toggleChecklist({ checklistId, templateId, name, category, landId, parkId, done, verb }) {
    const localKey = 'ph365_local_checklist';
    const writeLocal = () => {
      const dict = JSON.parse(localStorage.getItem(localKey) || '{}');
      dict[templateId || checklistId || name] = {
        done, verb,
        templateId: templateId || null,
        name, category, landId,
        completedAt: done ? new Date().toISOString() : null,
      };
      localStorage.setItem(localKey, JSON.stringify(dict));
    };

    if (!supabase) {
      writeLocal();
      emit('ph365-checklist-toggled', { id: templateId || checklistId, done, local: true });
      return { done };
    }

    const { data: { user } } = await supabase.auth.getUser();
    if (!user) {
      // Signed out: persist locally instead of throwing — checklist still works.
      writeLocal();
      emit('ph365-checklist-toggled', { id: templateId || checklistId, done, local: true });
      return { done };
    }

    // Authenticated path. Upsert when we have a template_id (every UI call
    // does today); fall back to the old update-by-id path for the rare
    // legacy row that lacks one.
    if (templateId) {
      const { data, error } = await supabase
        .from('user_checklist')
        .upsert({
          user_id: user.id, name, category,
          land_id: landId,
          template_id: templateId,
          done,
          verb: done ? verb : null,
          completed_at: done ? new Date().toISOString() : null,
        }, { onConflict: 'user_id,template_id' })
        .select()
        .single();
      if (error) throw error;
      emit('ph365-checklist-toggled', { id: data.id, templateId, done });
      return data;
    }

    if (!checklistId) {
      // No template_id and no existing row id — create a fresh row.
      const { data, error } = await supabase
        .from('user_checklist')
        .insert({
          user_id: user.id, name, category,
          land_id: landId, done,
          verb: done ? verb : null,
          completed_at: done ? new Date().toISOString() : null,
        })
        .select()
        .single();
      if (error) throw error;
      emit('ph365-checklist-toggled', { id: data.id, done });
      return data;
    }

    const { data, error } = await supabase
      .from('user_checklist')
      .update({ done, verb: done ? verb : null, completed_at: done ? new Date().toISOString() : null })
      .eq('id', checklistId)
      .eq('user_id', user.id)
      .select()
      .single();
    if (error) throw error;
    emit('ph365-checklist-toggled', { id: checklistId, done });
    return data;
  }

  // ── Clear all visits for an attraction (used when un-checking a ride
  // from the lifetime checklist; user_visits is otherwise append-only). ──
  async function clearVisitsFor(attractionId) {
    if (!attractionId) throw new Error('attractionId required');
    if (!supabase) {
      const key = 'ph365_local_visits';
      const list = JSON.parse(localStorage.getItem(key) || '[]')
        .filter(v => v.attraction_id !== attractionId);
      localStorage.setItem(key, JSON.stringify(list));
      emit('ph365-visits-cleared', { attractionId, local: true });
      return { deleted: true };
    }
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) {
      const key = 'ph365_local_visits';
      const list = JSON.parse(localStorage.getItem(key) || '[]')
        .filter(v => v.attraction_id !== attractionId);
      localStorage.setItem(key, JSON.stringify(list));
      emit('ph365-visits-cleared', { attractionId, local: true });
      return { deleted: true };
    }
    const { error } = await supabase
      .from('user_visits')
      .delete()
      .eq('user_id', user.id)
      .eq('attraction_id', attractionId);
    if (error) throw error;
    emit('ph365-visits-cleared', { attractionId });
    return { deleted: true };
  }

  // ── Drop a queue pin (V0.1: just notes posted_wait + timestamp) ───────
  async function dropQueuePin({ attractionId, postedWait }) {
    if (!supabase) {
      sessionStorage.setItem('ph365_pin_' + attractionId, JSON.stringify({
        postedWait, droppedAt: Date.now(),
      }));
      emit('ph365-pin-dropped', { attractionId, postedWait, local: true });
      return { droppedAt: Date.now() };
    }

    const { data: { user } } = await supabase.auth.getUser();
    if (!user) throw new Error('Not signed in');

    const { data, error } = await supabase
      .from('queue_pins')
      .insert({ user_id: user.id, attraction_id: attractionId, posted_wait: postedWait })
      .select()
      .single();
    if (error) {
      // Table may not exist yet — fall back to session store, don't fail the UI
      console.warn('[dropQueuePin] table missing, falling back:', error);
      return { droppedAt: Date.now(), fallback: true };
    }
    emit('ph365-pin-dropped', { attractionId, pin: data });
    return data;
  }

  // ── Queue-line calibration tracker ────────────────────────────────────
  // One-at-a-time: starting a new tracker stops any previous one. State is
  // held on this module (not on a React component) so a Detail-screen unmount
  // doesn't kill an in-flight calibration walk — admins navigate away while
  // the tracker keeps recording. PWA limitation: samples stop when the screen
  // sleeps. Phase 2 (Capacitor iOS) lifts that limit.
  const SAMPLE_INTERVAL_MS = 10_000;   // 1 sample / 10s — design target
  const FLUSH_INTERVAL_MS  = 30_000;   // batch upload every 30s
  const MAX_DURATION_MS    = 90 * 60 * 1000;   // safety auto-stop after 90 min

  let tracker = null;  // { sessionId, attractionId, startedAt, watchId, ... } | null
  const trackerListeners = new Set();

  function emitTrackerState() {
    const snapshot = trackerSnapshot();
    for (const cb of trackerListeners) {
      try { cb(snapshot); } catch (e) { console.warn('tracker listener threw', e); }
    }
  }

  function trackerSnapshot() {
    if (!tracker) return { active: false };
    return {
      active: true,
      sessionId:    tracker.sessionId,
      attractionId: tracker.attractionId,
      startedAt:    tracker.startedAt,
      sampleCount:  tracker.sampleCount,
      pendingCount: tracker.buffer.length,
      lastSampleAt: tracker.lastSampleAt,
      lastError:    tracker.lastError,
      source:       tracker.source,
    };
  }

  function subscribeTracker(cb) {
    trackerListeners.add(cb);
    cb(trackerSnapshot());
    return () => trackerListeners.delete(cb);
  }

  async function startCalibration({ attractionId, postedWaitAtStart = null }) {
    if (!attractionId) throw new Error('attractionId required');
    if (tracker) await stopCalibration({ endReason: 'left' });  // replace any prior session
    if (!('geolocation' in navigator)) throw new Error('Geolocation unavailable');

    const session = await authedFetch('/api/queue/session', {
      method: 'POST',
      body: JSON.stringify({ attraction_id: attractionId, posted_wait_at_start: postedWaitAtStart }),
    });

    tracker = {
      sessionId:    session.id,
      attractionId,
      startedAt:    session.started_at || new Date().toISOString(),
      source:      session.source,
      buffer:      [],
      sampleCount: 0,
      lastSampleAt: null,
      lastError:   null,
      watchId:     null,
      flushTimer:  null,
      sampleTimer: null,
      lastPosition: null,
    };

    // High-accuracy continuous watch — we throttle the writes ourselves so
    // every iOS browser event doesn't generate a sample.
    tracker.watchId = navigator.geolocation.watchPosition(
      pos => { tracker && (tracker.lastPosition = pos); },
      err => {
        if (!tracker) return;
        tracker.lastError = err?.message || 'geolocation error';
        emitTrackerState();
      },
      { enableHighAccuracy: true, maximumAge: 5_000, timeout: 30_000 }
    );

    tracker.sampleTimer = setInterval(() => {
      if (!tracker?.lastPosition) return;
      const p = tracker.lastPosition;
      tracker.buffer.push({
        recorded_at: new Date(p.timestamp || Date.now()).toISOString(),
        lat: Number(p.coords.latitude.toFixed(5)),
        lon: Number(p.coords.longitude.toFixed(5)),
        accuracy_m: Math.round(p.coords.accuracy ?? 0),
        posted_wait: postedWaitAtStart,
      });
      tracker.lastSampleAt = new Date().toISOString();
      emitTrackerState();
    }, SAMPLE_INTERVAL_MS);

    tracker.flushTimer = setInterval(() => {
      flushSamples().catch(e => console.warn('[calibration] flush failed:', e));
      // Hard safety cap so a forgotten tracker doesn't run forever.
      if (Date.now() - new Date(tracker.startedAt).getTime() > MAX_DURATION_MS) {
        stopCalibration({ endReason: 'timeout' }).catch(() => {});
      }
    }, FLUSH_INTERVAL_MS);

    emit('ph365-calibration-started', { sessionId: session.id, attractionId });
    emitTrackerState();
    return session;
  }

  async function flushSamples() {
    if (!tracker || !tracker.buffer.length) return;
    const batch = tracker.buffer.splice(0, tracker.buffer.length);
    try {
      const result = await authedFetch('/api/queue/samples', {
        method: 'POST',
        body: JSON.stringify({ session_id: tracker.sessionId, samples: batch }),
      });
      if (tracker) {
        tracker.sampleCount += result.inserted ?? batch.length;
        tracker.lastError = null;
      }
    } catch (e) {
      // Put samples back so the next flush retries them. Cap the buffer to
      // avoid unbounded growth if the backend is hard-down.
      if (tracker) {
        tracker.buffer.unshift(...batch);
        if (tracker.buffer.length > 1000) tracker.buffer.length = 1000;
        tracker.lastError = e.message;
      }
      throw e;
    } finally {
      emitTrackerState();
    }
  }

  async function stopCalibration({ endReason = 'boarded', notes = null } = {}) {
    if (!tracker) return null;
    const t = tracker;
    tracker = null;  // null first so any concurrent flush gives up cleanly

    if (t.watchId != null) navigator.geolocation.clearWatch(t.watchId);
    if (t.sampleTimer) clearInterval(t.sampleTimer);
    if (t.flushTimer) clearInterval(t.flushTimer);

    // Best-effort final flush. Don't block session-close on it.
    if (t.buffer.length) {
      try {
        await authedFetch('/api/queue/samples', {
          method: 'POST',
          body: JSON.stringify({ session_id: t.sessionId, samples: t.buffer }),
        });
      } catch (e) {
        console.warn('[calibration] final flush failed:', e);
      }
    }

    let closed;
    try {
      closed = await authedFetch(`/api/queue/session/${t.sessionId}`, {
        method: 'PATCH',
        body: JSON.stringify({ end_reason: endReason, notes }),
      });
    } catch (e) {
      console.warn('[calibration] session close failed:', e);
    }

    emit('ph365-calibration-stopped', { sessionId: t.sessionId, endReason, session: closed });
    emitTrackerState();
    return closed;
  }

  // ── Manual pin-drop calibration (admin) ─────────────────────────────
  // Module-level state so the calibration survives navigation between
  // Detail → Map. Subscribers (Map screen) get notified on changes.
  let calibration = null;  // { setId, attractionId, pins: [...] } | null
  const calibrationListeners = new Set();
  function emitCalibration() {
    const snap = calibrationSnapshot();
    for (const cb of calibrationListeners) {
      try { cb(snap); } catch (e) { console.warn(e); }
    }
  }
  function calibrationSnapshot() {
    if (!calibration) return { active: false };
    return {
      active: true,
      setId:        calibration.setId,
      attractionId: calibration.attractionId,
      pinCount:     calibration.pins.length,
      pins:         calibration.pins.slice(),
      startedAt:    calibration.startedAt,
      lastPinAt:    calibration.pins.length ? calibration.pins[calibration.pins.length - 1].placed_at : null,
    };
  }
  function subscribeCalibration(cb) {
    calibrationListeners.add(cb);
    cb(calibrationSnapshot());
    return () => calibrationListeners.delete(cb);
  }

  async function startCalibrationSet(attractionId) {
    if (!attractionId) throw new Error('attractionId required');
    if (calibration) await cancelCalibrationSet().catch(() => {});
    const set = await authedFetch('/api/queue/calibration', {
      method: 'POST',
      body: JSON.stringify({ attraction_id: attractionId }),
    });
    calibration = {
      setId:        set.id,
      attractionId,
      startedAt:    set.started_at,
      pins:         [],
    };
    emit('ph365-calibration-set-started', { setId: set.id, attractionId });
    emitCalibration();
    return set;
  }

  async function dropCalibrationPin(lat, lon) {
    if (!calibration) throw new Error('No active calibration set');
    const pin = await authedFetch(`/api/queue/calibration/${calibration.setId}/pin`, {
      method: 'POST',
      body: JSON.stringify({ lat: Number(lat), lon: Number(lon) }),
    });
    calibration.pins.push(pin);
    emitCalibration();
    return pin;
  }

  async function undoCalibrationPin() {
    if (!calibration) return;
    await authedFetch(`/api/queue/calibration/${calibration.setId}/pin/last`, { method: 'DELETE' });
    calibration.pins.pop();
    emitCalibration();
  }

  async function updateCalibrationPin(pinId, lat, lon) {
    if (!calibration) throw new Error('No active calibration set');
    const updated = await authedFetch(
      `/api/queue/calibration/${calibration.setId}/pin/${pinId}`,
      { method: 'PATCH', body: JSON.stringify({ lat: Number(lat), lon: Number(lon) }) }
    );
    const idx = calibration.pins.findIndex(p => p.id === pinId);
    if (idx !== -1) calibration.pins[idx] = updated;
    emitCalibration();
    return updated;
  }

  async function finishCalibrationSet({ activate = true } = {}) {
    if (!calibration) throw new Error('No active calibration set');
    const result = await authedFetch(`/api/queue/calibration/${calibration.setId}/finish`, {
      method: 'PATCH',
      body: JSON.stringify({ activate }),
    });
    const finished = { ...calibration };
    calibration = null;
    emit('ph365-calibration-set-finished', { setId: finished.setId, attractionId: finished.attractionId, activate });
    emitCalibration();
    return result;
  }

  async function cancelCalibrationSet() {
    if (!calibration) return;
    try {
      await authedFetch(`/api/queue/calibration/${calibration.setId}`, { method: 'DELETE' });
    } catch (e) {
      console.warn('[calibration] cancel failed (continuing client-side):', e);
    }
    const cancelled = { ...calibration };
    calibration = null;
    emit('ph365-calibration-set-cancelled', { setId: cancelled.setId, attractionId: cancelled.attractionId });
    emitCalibration();
  }

  // User-side: report your position in a queue (taps a pin from the map).
  async function reportQueuePosition({ attractionId, pinId = null }) {
    if (!attractionId) throw new Error('attractionId required');
    return authedFetch('/api/queue/report', {
      method: 'POST',
      body: JSON.stringify({ attraction_id: attractionId, pin_id: pinId }),
    });
  }

  // Public lookup of the active path with computed minutes_to_board per pin.
  async function getActivePath(attractionId) {
    const headers = { 'Content-Type': 'application/json' };
    const url = (API_BASE || '') + `/api/queue/active-path/${encodeURIComponent(attractionId)}`;
    const res = await fetch(url, { headers });
    if (!res.ok) throw new Error(`active-path ${res.status}`);
    return res.json();
  }

  // Effective wait = recent user-reported wait (if < 10 min old) else null.
  // Caller is expected to fall back to attraction.wait (queue-times) when
  // this returns { source: null }.
  async function getEffectiveWait(attractionId) {
    const url = (API_BASE || '') + `/api/queue/effective-wait/${encodeURIComponent(attractionId)}`;
    const res = await fetch(url);
    if (!res.ok) return { minutes: null, source: null, age_seconds: null, sample_count: 0 };
    return res.json();
  }

  // ── Anchor an attraction's queue entrance (admin only on the server side) ─
  async function anchorQueue({ attractionId, entranceLat, entranceLon, entranceRadiusM = 25, boardLat = null, boardLon = null }) {
    return authedFetch('/api/queue/anchor', {
      method: 'PUT',
      body: JSON.stringify({
        attraction_id: attractionId,
        entrance_lat: Number(entranceLat.toFixed(5)),
        entrance_lon: Number(entranceLon.toFixed(5)),
        entrance_radius_m: entranceRadiusM,
        board_lat: boardLat != null ? Number(boardLat.toFixed(5)) : null,
        board_lon: boardLon != null ? Number(boardLon.toFixed(5)) : null,
      }),
    });
  }

  async function getQueueAnchor(attractionId) {
    return authedFetch(`/api/queue/anchor/${encodeURIComponent(attractionId)}`);
  }

  window.PH365_ACTIONS = {
    checkIn, clearVisitsFor, rateAttraction, rateChecklistItem,
    toggleChecklist, dropQueuePin, openRatingModal,
    // GPS-based calibration (legacy — Phase 1 GPS approach)
    startCalibration, stopCalibration, anchorQueue, getQueueAnchor,
    subscribeTracker, trackerSnapshot,
    // Manual pin-drop calibration (current approach)
    startCalibrationSet, dropCalibrationPin, undoCalibrationPin,
    updateCalibrationPin, finishCalibrationSet, cancelCalibrationSet,
    subscribeCalibration, calibrationSnapshot,
    // User-side
    reportQueuePosition, getActivePath, getEffectiveWait,
  };
})();
