// day-session.jsx — Park Day: record a day in the park, propose the rides.
//
// What this can and cannot do, because the whole design falls out of it:
//
// iOS gives a web app no location whatsoever while it is not the thing on
// screen. There is no background geolocation API in WebKit, and no Background
// Sync or Periodic Background Sync to wake up and flush with either. So this
// records while the app is open and stops dead when it isn't — no amount of
// service-worker cleverness changes that, and the UI must never imply
// otherwise. A screen wake lock (which does now work in installed home-screen
// web apps, iOS 18.4+) removes the one failure mode that is fixable: the phone
// dimming in someone's hand while they walk.
//
// The gaps that leaves are not a defect to be papered over. A gap is a
// recorded fact with two endpoints and a duration, and a twelve-minute one
// that began at the entrance to Pirates is the single strongest ride signal
// available — better than any dwell heuristic, because it is exactly what
// riding a ride looks like from the phone's point of view. Hence the resume
// sample: the first thing this does on coming back to the foreground is take
// a fix and write it flagged, so the hole has a far edge.
//
// State lives on the module rather than in a component, same as the queue
// calibration tracker it is modelled on, so switching tabs does not end
// someone's day.
(function () {
  const API_BASE = window.PH365_API_BASE || '';

  // ── Tunables ───────────────────────────────────────────────────────────
  const SAMPLE_INTERVAL_MS = 15_000;      // how often we consider a sample
  const FLUSH_INTERVAL_MS  = 30_000;      // batched upload
  const MAX_DURATION_MS    = 15 * 60 * 60 * 1000;  // safety auto-stop
  const MAX_BUFFER         = 1000;        // cap if the backend is hard-down

  // Movement gate. A sample is only kept if the person has actually gone
  // somewhere, or a minute has passed. Standing in a queue for 45 minutes at
  // one sample per 15s is 180 rows that all say the same thing; this turns
  // that into 45 without changing the drawn path at all, and write volume —
  // not sample rate — is what makes a day's tracking expensive.
  const MIN_MOVE_M   = 10;
  const MIN_IDLE_MS  = 60_000;

  // Fix quality. Anything over the hard max is discarded outright: in a park
  // that is a WiFi-trilateration guess from indoors, and it is wrong by more
  // than the distance between two lands. PATH_MAX is softer — those samples
  // are kept, because "roughly here, roughly now" is enough for dwell
  // detection, but they are skipped when drawing the line.
  const ACCURACY_HARD_MAX_M = 150;
  const ACCURACY_PATH_MAX_M = 50;
  // No one walks across Disneyland at 15 m/s. A fix implying it is multipath
  // off a show building, not movement.
  const MAX_SPEED_MPS = 15;

  // Ride detection.
  const DEFAULT_RADIUS_M   = 60;      // around attractions.lat/lon
  const MIN_ANCHOR_RADIUS_M = 45;     // floor under a calibrated entrance radius
  const DWELL_MIN_MS       = 90_000;  // 90s inside the radius
  const DWELL_MIN_SAMPLES  = 3;
  const GAP_MIN_MS         = 4 * 60 * 1000;   // a gap this long might be a ride
  const GAP_MAX_MS         = 90 * 60 * 1000;  // longer than this, they left
  const MERGE_WINDOW_MS    = 10 * 60 * 1000;  // fold nearby proposals together
  const DUPE_WINDOW_MS     = 20 * 60 * 1000;  // vs. rides already logged

  // Park centres, duplicated from screens-extra.jsx rather than imported —
  // that file exports screens, not helpers, and a day session must not depend
  // on a screen having been loaded.
  const PARK_CENTERS = {
    DLR: { lat: 33.8121, lon: -117.9190 },
    DCA: { lat: 33.8068, lon: -117.9209 },
  };

  // ── Small helpers ──────────────────────────────────────────────────────
  function haversineM(lat1, lon1, lat2, lon2) {
    const R = 6371000;
    const toRad = d => d * Math.PI / 180;
    const dLat = toRad(lat2 - lat1);
    const dLon = toRad(lon2 - lon1);
    const a = Math.sin(dLat / 2) ** 2
      + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
    return 2 * R * Math.asin(Math.sqrt(a));
  }

  function detectPark(lat, lon) {
    const dDLR = haversineM(lat, lon, PARK_CENTERS.DLR.lat, PARK_CENTERS.DLR.lon);
    const dDCA = haversineM(lat, lon, PARK_CENTERS.DCA.lat, PARK_CENTERS.DCA.lon);
    return dDLR <= dDCA ? 'DLR' : 'DCA';
  }

  async function authedFetch(path, init = {}) {
    const supabase = window.PH365_SUPABASE;
    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 res = await fetch(`${API_BASE}${path}`, { ...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 }));
  }

  // ── Tracker state ──────────────────────────────────────────────────────
  let day = null;   // { dayId, parkId, startedAt, watchId, buffer, ... } | null
  const listeners = new Set();

  function snapshot() {
    if (!day) return { active: false };
    return {
      active: true,
      dayId:        day.dayId,
      parkId:       day.parkId,
      startedAt:    day.startedAt,
      sampleCount:  day.sampleCount,
      pendingCount: day.buffer.length,
      lastSampleAt: day.lastSampleAt,
      lastFixAt:    day.lastPosition ? new Date(day.lastPosition.timestamp).toISOString() : null,
      accuracyM:    day.lastPosition ? Math.round(day.lastPosition.coords.accuracy ?? 0) : null,
      highAccuracy: day.highAccuracy,
      wakeLockHeld: !!day.wakeLock,
      lastError:    day.lastError,
    };
  }

  function notify() {
    const snap = snapshot();
    for (const cb of listeners) {
      try { cb(snap); } catch (e) { console.warn('[park-day] listener threw', e); }
    }
  }

  function subscribe(cb) {
    listeners.add(cb);
    cb(snapshot());
    return () => listeners.delete(cb);
  }

  // ── Geolocation watch ──────────────────────────────────────────────────
  // The watch is re-registered when the accuracy mode changes, because
  // PositionOptions are fixed at registration. Cheap, and the alternative is
  // running the GPS flat-out for ten hours to track someone standing in a
  // queue. Movement is the only escalation trigger: proximity to an
  // attraction is not, since in a theme park you are near an attraction
  // essentially always, and using that would mean never leaving high accuracy.
  const MODE_SWITCH_COOLDOWN_MS = 120_000;

  function startWatch(highAccuracy) {
    return navigator.geolocation.watchPosition(
      pos => {
        if (!day) return;
        day.lastPosition = pos;
        day.lastError = null;
      },
      err => {
        if (!day) return;
        day.lastError = err?.message || 'geolocation error';
        notify();
      },
      {
        enableHighAccuracy: highAccuracy,
        maximumAge: highAccuracy ? 5_000 : 30_000,
        timeout: 30_000,
      }
    );
  }

  function setAccuracyMode(highAccuracy) {
    if (!day || day.highAccuracy === highAccuracy) return;
    if (Date.now() - day.lastModeSwitchAt < MODE_SWITCH_COOLDOWN_MS) return;
    if (day.watchId != null) navigator.geolocation.clearWatch(day.watchId);
    day.highAccuracy = highAccuracy;
    day.lastModeSwitchAt = Date.now();
    day.watchId = startWatch(highAccuracy);
  }

  // ── Wake lock ──────────────────────────────────────────────────────────
  // Always released by the platform when the page hides, so it has to be
  // re-taken on every return to the foreground. Same pattern as step mode in
  // screens-ears.jsx.
  async function grabWakeLock() {
    if (!day || day.wakeLock || document.hidden) return;
    try {
      if (!('wakeLock' in navigator)) return;
      day.wakeLock = await navigator.wakeLock.request('screen');
      day.wakeLock.addEventListener?.('release', () => { if (day) day.wakeLock = null; });
      notify();
    } catch {
      // Refused (low battery, or the platform simply says no). The day still
      // records; the screen is just allowed to time out.
    }
  }

  function releaseWakeLock(state) {
    const wl = state?.wakeLock;
    if (state) state.wakeLock = null;
    wl?.release?.().catch(() => {});
  }

  // ── Sampling ───────────────────────────────────────────────────────────
  function considerSample({ force = false, isResume = false } = {}) {
    if (!day?.lastPosition) return;
    const p = day.lastPosition;
    const acc = p.coords.accuracy ?? null;
    const at = p.timestamp || Date.now();

    if (acc != null && acc > ACCURACY_HARD_MAX_M) return;
    // The same fix twice is not a new sample. watchPosition on iOS will
    // happily hand back an unchanged position object for minutes at a time
    // when nothing is moving.
    if (!force && day.lastFixTimestamp === at) return;

    const lat = Number(p.coords.latitude.toFixed(4));
    const lon = Number(p.coords.longitude.toFixed(4));
    const last = day.lastKept;

    if (last) {
      const dt = at - last.at;
      const moved = haversineM(last.lat, last.lon, lat, lon);
      // Teleport rejection, scaled by how sure we are of either end.
      if (dt > 0) {
        const tolerance = (acc ?? 0) + (last.acc ?? 0);
        if (moved - tolerance > MAX_SPEED_MPS * (dt / 1000)) return;
      }
      if (!force && moved < MIN_MOVE_M && dt < MIN_IDLE_MS) {
        // Standing still. Note it for the accuracy-mode decision, drop the row.
        day.stillSince = day.stillSince ?? at;
        if (at - day.stillSince > MIN_IDLE_MS * 2) setAccuracyMode(false);
        return;
      }
      if (moved >= MIN_MOVE_M) {
        day.stillSince = null;
        setAccuracyMode(true);
      }
    }

    day.buffer.push({
      recorded_at: new Date(at).toISOString(),
      lat, lon,
      accuracy_m: acc != null ? Math.round(acc) : null,
      is_resume: isResume,
    });
    if (day.buffer.length > MAX_BUFFER) day.buffer.splice(0, day.buffer.length - MAX_BUFFER);
    day.lastKept = { lat, lon, at, acc };
    day.lastFixTimestamp = at;
    day.lastSampleAt = new Date(at).toISOString();
    notify();
  }

  // One immediate fix, independent of the watch. Used on resume, where
  // waiting up to 15s for the watch to produce something would put the far
  // edge of the gap in the wrong place.
  function takeImmediateFix({ isResume = false } = {}) {
    if (!day || !('geolocation' in navigator)) return;
    navigator.geolocation.getCurrentPosition(
      pos => {
        if (!day) return;
        day.lastPosition = pos;
        considerSample({ force: true, isResume });
        flush().catch(() => {});
      },
      () => {},
      { enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 }
    );
  }

  async function flush({ keepalive = false } = {}) {
    if (!day || !day.buffer.length) return;
    const dayId = day.dayId;
    const batch = day.buffer.splice(0, Math.min(day.buffer.length, 200));
    try {
      const result = await authedFetch('/api/day/samples', {
        method: 'POST',
        keepalive,
        body: JSON.stringify({ day_id: dayId, samples: batch }),
      });
      if (day && day.dayId === dayId) {
        day.sampleCount += result.inserted ?? batch.length;
        day.lastError = null;
      }
    } catch (e) {
      // Put them back for the next flush rather than losing the morning to
      // one dead spot in the park's wifi.
      if (day && day.dayId === dayId) {
        day.buffer.unshift(...batch);
        if (day.buffer.length > MAX_BUFFER) day.buffer.length = MAX_BUFFER;
        day.lastError = e.message;
      }
      throw e;
    } finally {
      notify();
    }
  }

  // ── Lifecycle ──────────────────────────────────────────────────────────
  function attach(session) {
    day = {
      dayId:     session.id,
      parkId:    session.park_id || null,
      startedAt: session.started_at || new Date().toISOString(),
      buffer: [],
      sampleCount: 0,
      lastSampleAt: null,
      lastError: null,
      lastPosition: null,
      lastKept: null,
      lastFixTimestamp: null,
      stillSince: null,
      highAccuracy: true,
      lastModeSwitchAt: Date.now(),
      watchId: null,
      wakeLock: null,
      sampleTimer: null,
      flushTimer: null,
      onVisibility: null,
    };

    day.watchId = startWatch(true);

    day.sampleTimer = setInterval(() => considerSample(), SAMPLE_INTERVAL_MS);

    day.flushTimer = setInterval(() => {
      flush().catch(e => console.warn('[park-day] flush failed:', e));
      if (day && Date.now() - new Date(day.startedAt).getTime() > MAX_DURATION_MS) {
        endDay({ endReason: 'timeout' }).catch(() => {});
      }
    }, FLUSH_INTERVAL_MS);

    day.onVisibility = () => {
      if (!day) return;
      if (document.hidden) {
        // About to lose the sensor entirely. Get what we have on the wire
        // while the page still exists; keepalive lets the request outlive it.
        releaseWakeLock(day);
        flush({ keepalive: true }).catch(() => {});
        notify();
      } else {
        grabWakeLock();
        takeImmediateFix({ isResume: true });
      }
    };
    document.addEventListener('visibilitychange', day.onVisibility);

    grabWakeLock();
    takeImmediateFix();
    notify();
  }

  async function startDay() {
    if (day) return snapshot();
    if (!('geolocation' in navigator)) throw new Error('Location is unavailable on this device.');
    const supabase = window.PH365_SUPABASE;
    if (!supabase) throw new Error('Sign-in is not configured, so a day cannot be saved.');
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) throw new Error('Sign in to record a park day.');

    // Ask for a fix before creating the session, so a denied permission fails
    // here rather than leaving an empty day sitting open in the database.
    const first = await new Promise((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(
        resolve,
        err => reject(new Error(err.code === 1
          ? 'Location permission denied. Allow location for this app to record your day.'
          : (err.message || 'Could not get a location fix.'))),
        { enableHighAccuracy: true, timeout: 20_000, maximumAge: 0 }
      );
    });

    const parkId = detectPark(first.coords.latitude, first.coords.longitude);
    const session = await authedFetch('/api/day/session', {
      method: 'POST',
      body: JSON.stringify({ park_id: parkId }),
    });

    attach(session);
    day.lastPosition = first;
    considerSample({ force: true });
    emit('ph365-park-day-started', { dayId: session.id });
    return snapshot();
  }

  async function endDay({ endReason = 'ended' } = {}) {
    if (!day) return null;
    const t = day;
    day = null;   // null first, so any in-flight 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);
    if (t.onVisibility) document.removeEventListener('visibilitychange', t.onVisibility);
    releaseWakeLock(t);

    if (t.buffer.length) {
      try {
        await authedFetch('/api/day/samples', {
          method: 'POST',
          body: JSON.stringify({ day_id: t.dayId, samples: t.buffer.slice(0, 200) }),
        });
      } catch (e) {
        console.warn('[park-day] final flush failed:', e);
      }
    }

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

    emit('ph365-park-day-ended', { dayId: t.dayId, endReason, day: closed });
    notify();
    return closed || { id: t.dayId };
  }

  // Called once on load. An installed PWA is killed and relaunched constantly;
  // without this, walking out of a dead zone and back into the app would start
  // the morning over rather than continue it.
  async function resumeIfOpen() {
    if (day) return snapshot();
    const supabase = window.PH365_SUPABASE;
    if (!supabase) return { active: false };
    try {
      const { data: { user } } = await supabase.auth.getUser();
      if (!user) return { active: false };
      const open = await authedFetch('/api/day/current');
      if (!open) return { active: false };
      // Don't silently resume a day someone started and abandoned days ago.
      if (Date.now() - new Date(open.started_at).getTime() > MAX_DURATION_MS) {
        await authedFetch(`/api/day/session/${open.id}`, {
          method: 'PATCH',
          body: JSON.stringify({ end_reason: 'timeout' }),
        });
        return { active: false };
      }
      attach(open);
      emit('ph365-park-day-resumed', { dayId: open.id });
      return snapshot();
    } catch (e) {
      console.warn('[park-day] resume check failed:', e);
      return { active: false };
    }
  }

  // ── Reads + deletes ────────────────────────────────────────────────────
  const listDays   = () => authedFetch('/api/day/sessions');
  const getDay     = (id) => authedFetch(`/api/day/session/${id}`);

  async function deleteDay(id) {
    const out = await authedFetch(`/api/day/session/${id}`, { method: 'DELETE' });
    if (day?.dayId === id) {
      // Deleting the day you are currently recording stops the recording;
      // continuing to write breadcrumbs to a deleted day is not a thing
      // anyone asking for deletion wants.
      const t = day;
      day = null;
      if (t.watchId != null) navigator.geolocation.clearWatch(t.watchId);
      if (t.sampleTimer) clearInterval(t.sampleTimer);
      if (t.flushTimer) clearInterval(t.flushTimer);
      if (t.onVisibility) document.removeEventListener('visibilitychange', t.onVisibility);
      releaseWakeLock(t);
      notify();
    }
    emit('ph365-park-day-deleted', { dayId: id });
    return out;
  }

  async function deleteAllHistory() {
    const out = await authedFetch('/api/day/history', { method: 'DELETE' });
    if (day) {
      const t = day;
      day = null;
      if (t.watchId != null) navigator.geolocation.clearWatch(t.watchId);
      if (t.sampleTimer) clearInterval(t.sampleTimer);
      if (t.flushTimer) clearInterval(t.flushTimer);
      if (t.onVisibility) document.removeEventListener('visibilitychange', t.onVisibility);
      releaseWakeLock(t);
    }
    emit('ph365-park-day-deleted', { all: true });
    notify();
    return out;
  }

  // ── Ride detection ─────────────────────────────────────────────────────
  // Proposals only. Nothing here writes to user_visits; the end-of-day screen
  // asks, and a tap is what turns a guess into a ride. Detection at 30m of GPS
  // error cannot tell Alice in Wonderland from Casey Jr. — they are closer
  // together than the error bar — so a silent auto-log would not merely be
  // untidy, it would mint achievements off a coin flip.

  // Calibrated queue entrances when an admin has set one, attractions.lat/lon
  // otherwise. The fallback is the primary path in practice: entrance anchors
  // are admin-populated and may not exist for any ride at all.
  async function loadGeofences() {
    const attractions = window.PH365?.ATTRACTIONS || [];
    const fences = new Map();
    for (const a of attractions) {
      if (a.lat == null || a.lon == null) continue;
      fences.set(a.id, {
        id: a.id, name: a.name, type: a.type,
        lat: Number(a.lat), lon: Number(a.lon),
        radius: DEFAULT_RADIUS_M, anchored: false,
      });
    }
    const supabase = window.PH365_SUPABASE;
    if (supabase) {
      try {
        const { data } = await supabase
          .from('attraction_queues')
          .select('attraction_id, entrance_lat, entrance_lon, entrance_radius_m');
        for (const q of (data || [])) {
          const base = fences.get(q.attraction_id);
          if (!base) continue;
          fences.set(q.attraction_id, {
            ...base,
            lat: Number(q.entrance_lat),
            lon: Number(q.entrance_lon),
            // An anchor is a precise point, but the phone reading it is not,
            // so the calibrated radius gets a floor. A 25m rope width is a
            // true statement about the queue and a false one about GPS.
            radius: Math.max(Number(q.entrance_radius_m) || 0, MIN_ANCHOR_RADIUS_M),
            anchored: true,
          });
        }
      } catch (e) {
        console.warn('[park-day] queue anchors unavailable, using map coords:', e);
      }
    }
    return [...fences.values()];
  }

  function nearest(fences, lat, lon) {
    let best = null;
    for (const f of fences) {
      const d = haversineM(lat, lon, f.lat, f.lon);
      if (d <= f.radius && (!best || d < best.distance)) best = { fence: f, distance: d };
    }
    return best;
  }

  // samples must be time-ordered. visits are the rides already logged for
  // this day, so a confirmed ride stops being proposed.
  async function detectRides({ samples, visits = [] }) {
    const fences = await loadGeofences();
    const byId = new Map(fences.map(f => [f.id, f]));
    if (!samples?.length) return [];

    const proposals = [];

    // 1. Dwell — consecutive samples sitting inside one attraction's radius.
    let run = null;
    const closeRun = () => {
      if (run && run.count >= DWELL_MIN_SAMPLES && (run.end - run.start) >= DWELL_MIN_MS) {
        proposals.push({
          attractionId: run.id, start: run.start, end: run.end,
          kind: 'dwell', minDistance: run.minDistance,
        });
      }
      run = null;
    };
    for (const s of samples) {
      const at = new Date(s.recorded_at).getTime();
      const hit = nearest(fences, s.lat, s.lon);
      if (!hit) { closeRun(); continue; }
      // A run cannot span a gap. Two fixes at Pirates ninety minutes apart are
      // not someone standing at Pirates for ninety minutes — they are two
      // separate visits with the app closed in between, and treating them as
      // one dwell invents an hour and a half of queueing that never happened.
      if (run && at - run.end > GAP_MIN_MS) closeRun();
      if (run && run.id === hit.fence.id) {
        run.end = at;
        run.count++;
        run.minDistance = Math.min(run.minDistance, hit.distance);
      } else {
        closeRun();
        run = { id: hit.fence.id, start: at, end: at, count: 1, minDistance: hit.distance };
      }
    }
    closeRun();

    // 2. Gaps — the app went away while standing at a ride, and came back
    //    later. That is what riding looks like from here.
    for (let i = 1; i < samples.length; i++) {
      const prev = samples[i - 1];
      const cur  = samples[i];
      const prevAt = new Date(prev.recorded_at).getTime();
      const curAt  = new Date(cur.recorded_at).getTime();
      const dt = curAt - prevAt;
      if (dt < GAP_MIN_MS || dt > GAP_MAX_MS) continue;
      const hit = nearest(fences, prev.lat, prev.lon);
      if (!hit) continue;
      proposals.push({
        attractionId: hit.fence.id, start: prevAt, end: curAt,
        kind: 'gap', minDistance: hit.distance, gapStart: prevAt,
      });
    }

    // 3. Merge. A dwell that runs into a gap at the same ride is one ride,
    //    and it is the most confident kind of proposal there is: they stood
    //    in the line, then the phone went away.
    proposals.sort((a, b) => a.start - b.start);
    const merged = [];
    for (const p of proposals) {
      const last = merged[merged.length - 1];
      if (last && last.attractionId === p.attractionId && p.start - last.end <= MERGE_WINDOW_MS) {
        last.end = Math.max(last.end, p.end);
        last.minDistance = Math.min(last.minDistance, p.minDistance);
        last.kinds.add(p.kind);
        if (p.gapStart != null) last.gapStart = Math.min(last.gapStart ?? p.gapStart, p.gapStart);
      } else {
        merged.push({ ...p, kinds: new Set([p.kind]) });
      }
    }

    // 4. Drop anything the user already logged for this ride around this time,
    //    whether they tapped it themselves during the day or confirmed it on
    //    an earlier pass through this screen.
    const logged = visits.map(v => ({
      id: v.attraction_id, at: new Date(v.visited_at).getTime(),
    }));

    return merged
      .filter(p => !logged.some(l =>
        l.id === p.attractionId && l.at >= p.start - DUPE_WINDOW_MS && l.at <= p.end + DUPE_WINDOW_MS))
      .map(p => {
        const fence = byId.get(p.attractionId);
        const both = p.kinds.has('dwell') && p.kinds.has('gap');
        return {
          attractionId: p.attractionId,
          name: fence?.name || p.attractionId,
          start: new Date(p.start).toISOString(),
          end: new Date(p.end).toISOString(),
          // The moment the phone went away is the best guess at when they
          // boarded — specifically the FIRST such moment, not the start of the
          // merged window, which is when they joined the line. With no gap to
          // go on, the middle of the time they spent there.
          ridenAt: new Date(p.gapStart ?? (p.start + p.end) / 2).toISOString(),
          minutes: Math.max(1, Math.round((p.end - p.start) / 60_000)),
          confidence: both ? 'high' : (p.kinds.has('gap') ? 'medium' : 'low'),
          basis: both ? 'waited here, then the app went away'
               : p.kinds.has('gap') ? 'the app went away here'
               : 'stayed here a while',
          anchored: !!fence?.anchored,
        };
      });
  }

  // Path for drawing: drops the low-confidence fixes and splits the trace at
  // gaps, so the map never draws a straight line through a hole and calls it
  // a walk that happened.
  function pathSegments(samples, { maxAccuracy = ACCURACY_PATH_MAX_M, gapMs = GAP_MIN_MS } = {}) {
    const segs = [];
    let cur = [];
    let lastAt = null;
    for (const s of (samples || [])) {
      if (s.accuracy_m != null && s.accuracy_m > maxAccuracy) continue;
      const at = new Date(s.recorded_at).getTime();
      if (lastAt != null && at - lastAt > gapMs && cur.length) { segs.push(cur); cur = []; }
      cur.push(s);
      lastAt = at;
    }
    if (cur.length) segs.push(cur);
    return segs.filter(s => s.length >= 2);
  }

  window.PH365_DAY = {
    startDay, endDay, resumeIfOpen, subscribe, snapshot,
    listDays, getDay, deleteDay, deleteAllHistory,
    detectRides, pathSegments, haversineM, detectPark,
    ACCURACY_PATH_MAX_M,
  };

  // Resume after the app code has settled. Deferred to load so it does not
  // race live-data.jsx for the Supabase session.
  if (document.readyState === 'complete') setTimeout(resumeIfOpen, 1200);
  else window.addEventListener('load', () => setTimeout(resumeIfOpen, 1200));
})();
