Torn Church Prayer Streak Tracker (Web version only)

Streak expiry in H:MM:SS (24h since last prayer, with ±30s grace), 24h count, consecutive days; pulse last 10m, red <1h; theme-aware; Refresh now; auto-refresh 5m & on visiting/leaving /church.php; history (last 10) with absolute dates; backfills up to 15 days; delta fetch; cached last prayer; gentle backoff; shows Expires/Expired at.

// ==UserScript==
// @name         Torn Church Prayer Streak Tracker (Web version only)
// @namespace    http://tampermonkey.net/
// @version      2.9
// @description  Streak expiry in H:MM:SS (24h since last prayer, with ±30s grace), 24h count, consecutive days; pulse last 10m, red <1h; theme-aware; Refresh now; auto-refresh 5m & on visiting/leaving /church.php; history (last 10) with absolute dates; backfills up to 15 days; delta fetch; cached last prayer; gentle backoff; shows Expires/Expired at.
// @match        https://www.torn.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ---------- Styles (theme-aware) ----------
  (function injectStyles() {
    if (document.getElementById('prayer-streak-style')) return;
    const style = document.createElement('style');
    style.id = 'prayer-streak-style';
    style.textContent = `
      @keyframes prayerPulse {
        0%   { transform: scale(1);   text-shadow: none; }
        50%  { transform: scale(1.03); text-shadow: 0 0 6px currentColor; }
        100% { transform: scale(1);   text-shadow: none; }
      }
      .prayer-pulse { animation: prayerPulse 1s infinite; }
      .prayer-warn { color: #d32f2f; }

      #prayerHistory { margin-top: 6px; }
      #prayerHistory ul { margin: 6px 0 0 0; padding-left: 16px; }
      #prayerHistory li { margin: 2px 0; }

      #prayerTracker .btn {
        color: inherit;
        background: color-mix(in srgb, currentColor 10%, transparent);
        border: 1px solid currentColor;
        border-radius: 6px;
        padding: 4px 8px;
        font-size: 12px;
        line-height: 1;
        cursor: pointer;
        transition: background-color 120ms ease, opacity 120ms ease, transform 50ms ease;
      }
      #prayerTracker .btn:hover {
        background: color-mix(in srgb, currentColor 18%, transparent);
      }
      #prayerTracker .btn:disabled {
        opacity: .6;
        cursor: default;
      }
      #prayerTracker .btn:active:not(:disabled) {
        transform: translateY(1px);
        background: color-mix(in srgb, currentColor 24%, transparent);
      }
      #prayerTracker .btn-row {
        display: flex;
        gap: 6px;
        flex-wrap: wrap;
        margin-top: 6px;
      }
      #prayerTracker .meta {
        margin-top: 6px;
        font-size: 12px;
        opacity: .85;
      }
      #prayerTracker .meta .status {
        opacity: .9;
      }

      /* Flexbox layout to position the label and number side by side */
      .prayer-tracker-row {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 6px;
      }

      .prayer-tracker-row .label {
        text-align: left;  /* Left-align label */
        flex: 1;
        padding-right: 10px;  /* Add space between label and value */
      }

      .prayer-tracker-row .value {
        text-align: right;  /* Right-align value */
        font-weight: bold;
        flex: 0 0 80px;  /* Ensure values have a fixed width to avoid overflow and misalignment */
      }

      /* Title styling */
      #prayerTracker > div:first-child {
        text-align: center;
        width: 100%;
        font-size: 16px;
        margin-bottom: 12px;
      }

      /* Ensure PDA layout displays properly */
      @media (max-width: 600px) {
        #prayerTracker {
          width: 100%;
          font-size: 14px;
          padding: 10px;
        }

        #prayerTracker .btn-row {
          flex-direction: column;
        }

        /* Position the tracker inside the sidebar */
        #prayerTracker {
          position: absolute;
          bottom: 10px;
          left: 10px;
          width: calc(100% - 20px); /* Full width with padding */
          z-index: 9999;  /* Ensures it's on top but behind main content */
          box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.5); /* Add a shadow for visual clarity */
        }

        #prayerHistory {
          display: block;
          margin-top: 10px;
        }
      }

      /* Prevent the tracker from floating above all content */
      #prayerTracker {
        position: relative;
        z-index: 10;  /* Lower than main content, but above other background elements */
      }
    `;
    document.head.appendChild(style);
  })();

  // ---------- Local storage keys ----------
  const LS_KEY = 'tornApiKey';
  const LS_LAST_SEEN_TS = 'tornPrayerLastSeenTs';     // number (unix seconds)
  const LS_CACHE = 'tornPrayerCache';                 // JSON array of cached entries (subset)
  const LS_LAST_PRAYER_TS = 'tornPrayerLastPrayerTs'; // number (unix seconds)

  // ---------- Constants ----------
  const ONE_DAY_MS = 24 * 60 * 60 * 1000;
  const GRACE_MS   = 30 * 1000; // ±30 seconds grace

  // ---------- Utilities ----------
  const pad2 = n => n.toString().padStart(2, '0');
  function formatAbsolute(tsMs){
    const d = new Date(tsMs);
    const hh = pad2(d.getHours()), mm = pad2(d.getMinutes());
    const day = d.getDate();
    const month = d.toLocaleString(undefined, { month: 'short' });
    const year = d.getFullYear();
    return `${day} ${month} ${year} ${hh}:${mm}`;
  }
  function isPrayer(entry){
    const t = (entry.title||'').toString().toLowerCase();
    const c = (entry.category||'').toString().toLowerCase();
    return c==='church' || t.includes('church');
  }
  const nowSec = () => Math.floor(Date.now()/1000);

  // ---------- API key ----------
  let apiKey = localStorage.getItem(LS_KEY);
  function promptForApiKey() {
    const k = prompt("Enter your Torn API key (requires 'log' access):", apiKey || '');
    if (k && k.trim()) {
      apiKey = k.trim();
      localStorage.setItem(LS_KEY, apiKey);
      fetchPrayerLogs(true);
    }
  }

  // ---------- Panel ----------
  function createPanel() {
    if (document.getElementById('prayerTracker')) return;
    // Adjust sidebar selector based on PDA or Desktop version
    const sidebar = document.querySelector('#pda-sidebar') || document.querySelector('#sidebar') || document.body;

    const panel = document.createElement('div');
    panel.id = 'prayerTracker';
    panel.style.display = 'flex';
    panel.style.flexDirection = 'column';
    panel.style.alignItems = 'center';
    panel.style.padding = '6px 10px';
    panel.style.marginTop = 'auto'; // Push the panel downwards in the sidebar
    panel.style.width = 'calc(100% - 20px)';
    panel.style.paddingBottom = '20px'; // Ensure there's space at the bottom
    panel.style.marginBottom = '10px'; // Prevent panel from touching the very bottom of the sidebar

    panel.innerHTML =
      '<div>Church Prayer Tracker</div>' +  // Title aligned center
      '<div class="prayer-tracker-row"><span class="label">Pray Streak ends in:</span> <span class="value" id="prayerCountdown">Loading...</span></div>' +
      '<div class="prayer-tracker-row"><span class="label">Prayers in past 24h:</span> <span class="value" id="prayerCount">Loading...</span></div>' +
      '<div class="prayer-tracker-row"><span class="label">Streak (days):</span> <span class="value" id="prayerStreak">Loading...</span></div>' +
      '<div class="btn-row">' +
      '  <button id="toggleHistory" class="btn" type="button">Show pray history</button>' + // Moved to the top
      '  <button id="refreshNow" class="btn" type="button">Refresh now</button>' +          // Moved down
      '  <button id="resetApiKey" class="btn" type="button">Reset API Key</button>' +        // Moved last
      '</div>' +
      '<div class="meta">Last updated: <span id="lastUpdated">—</span> <span class="status" id="updateStatus"></span></div>' +
      '<div id="prayerHistory" style="display:none;">' +
      '  <div style="margin-top:6px; font-weight:600;">Recent prayers</div>' +
      '  <ul id="prayerHistoryList"></ul>' +
      '</div>';

    sidebar.appendChild(panel);

    document.getElementById('resetApiKey').addEventListener('click', () => {
      localStorage.removeItem(LS_KEY);
      apiKey = null;
      alert('API key cleared. Please enter a new one.');
      promptForApiKey();
    });
    document.getElementById('toggleHistory').addEventListener('click', () => {
      const box = document.getElementById('prayerHistory');
      const btn = document.getElementById('toggleHistory');
      const isHidden = box.style.display === 'none';
      box.style.display = isHidden ? 'block' : 'none';
      btn.textContent = isHidden ? 'Hide pray history' : 'Show pray history';
    });
    document.getElementById('refreshNow').addEventListener('click', () => {
      fetchPrayerLogs(true);
    });
  }

  // ---------- Countdown / UI ----------
  let countdownIntervalId = null;
  let isFetching = false;
  let retryTimeoutId = null;

  function setLastUpdated(statusText = '') {
    const t = document.getElementById('lastUpdated');
    if (t) t.textContent = new Date().toLocaleTimeString();
    const s = document.getElementById('updateStatus');
    if (s) s.textContent = statusText ? `— ${statusText}` : '';
  }

  function startCountdown(lastPrayerMs){
    const countdownEl = document.getElementById('prayerCountdown');
    if (!countdownEl) return;

    const nominalExpiry = lastPrayerMs + ONE_DAY_MS; // last prayer + 24h

    if (countdownIntervalId) clearInterval(countdownIntervalId);

    function tick(){
      const diff = nominalExpiry - Date.now();

      // Respect grace: treat as active until 30s past nominal expiry
      if (diff <= -GRACE_MS) {
        countdownEl.textContent = '0:00:00';
        countdownEl.classList.remove('prayer-pulse');
        countdownEl.classList.add('prayer-warn');
        return;
      }

      // Active — compute remaining (clamp at 0 for display if within grace overrun)
      const remaining = Math.max(0, diff);
      const h = Math.floor(remaining / 3600000);
      const m = Math.floor((remaining % 3600000) / 60000);
      const s = Math.floor((remaining % 60000) / 1000);
      countdownEl.textContent = `${h}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;

      if (remaining < 3600000) countdownEl.classList.add('prayer-warn'); else countdownEl.classList.remove('prayer-warn');
      if (remaining < 600000)  countdownEl.classList.add('prayer-pulse'); else countdownEl.classList.remove('prayer-pulse');
    }

    tick();
    countdownIntervalId = setInterval(tick, 1000);
  }

  function renderHistory(prayers){
    const list=document.getElementById('prayerHistoryList'); if(!list) return;
    list.innerHTML='';
    if (!prayers || prayers.length===0){ const li=document.createElement('li'); li.textContent='No prayers found.'; list.appendChild(li); return; }
    const recent=prayers.slice(0,10);
    for (const p of recent){
      const tsMs=p.timestamp*1000;
      const li=document.createElement('li');
      li.textContent = formatAbsolute(tsMs);
      li.title = new Date(tsMs).toLocaleString();
      list.appendChild(li);
    }
  }

  function applyLogs(prayers){
    createPanel();

    const countdownEl = document.getElementById('prayerCountdown');
    const countEl     = document.getElementById('prayerCount');
    const streakEl    = document.getElementById('prayerStreak');

    // Always sort newest -> oldest
    prayers.sort((a,b)=>b.timestamp-a.timestamp);

    renderHistory(prayers);

    if (prayers.length===0){
      if (countdownIntervalId) clearInterval(countdownIntervalId);
      countdownEl.textContent='No prayers found';
      countdownEl.classList.remove('prayer-pulse','prayer-warn');
      countEl.textContent='0';
      streakEl.textContent='0';
      return;
    }

    const lastTs = prayers[0].timestamp;
    localStorage.setItem(LS_LAST_PRAYER_TS, String(lastTs));

    const lastMs = lastTs*1000;
    startCountdown(lastMs);

    const nowMs=Date.now();
    // 24h count with grace
    countEl.textContent = String(prayers.filter(p => (nowMs - p.timestamp*1000) <= (ONE_DAY_MS + GRACE_MS)).length);

    // Streak (stop when gap > 24h + grace)
    let streak=1;
    for (let i=0;i<prayers.length-1;i++){
      const gapSec=prayers[i].timestamp - prayers[i+1].timestamp;
      if (gapSec <= (24*60*60) + (GRACE_MS/1000)) streak++;
      else break;
    }
    streakEl.textContent=String(streak);
  }

  // Use cached last prayer to keep countdown ticking even if API fails
  function applyFromCacheIfAvailable() {
    const cached = localStorage.getItem(LS_CACHE);
    const arr = cached ? JSON.parse(cached) : [];
    if (Array.isArray(arr) && arr.length) {
      applyLogs(arr);
      return true;
    }
    const lastTsStr = localStorage.getItem(LS_LAST_PRAYER_TS);
    if (lastTsStr) {
      const ts = Number(lastTsStr) || 0;
      if (ts > 0) {
        applyLogs([{ timestamp: ts, title: 'Church pray', category: 'Church' }]);
        return true;
      }
    }
    return false;
  }

  // ---------- Fetch with Delta + 15-day backfill if needed ----------
  async function fetchPrayerLogs(manual = false){
    if (!apiKey || isFetching) return;
    isFetching=true;

    const refreshBtn = document.getElementById('refreshNow');
    const prevLabel = refreshBtn ? refreshBtn.textContent : '';
    if (manual && refreshBtn) {
      refreshBtn.disabled = true;
      refreshBtn.textContent = 'Refreshing…';
    }

    // Clear any pending retry
    if (retryTimeoutId) {
      clearTimeout(retryTimeoutId);
      retryTimeoutId = null;
    }

    try {
      const now = nowSec();
      const ONE_DAY = 24*60*60;
      const collected = new Map(); // `${timestamp}|${id}` -> entry

      // Start from cache so UI can render even before network completes
      const hadCache = applyFromCacheIfAvailable();

      // 1) DELTA FETCH: only ask for logs since last seen (with small overlap)
      const lastSeenStr = localStorage.getItem(LS_LAST_SEEN_TS);
      let lastSeen = lastSeenStr ? Number(lastSeenStr) : 0;
      if (Number.isFinite(lastSeen) && lastSeen > 0) {
        lastSeen = Math.max(0, lastSeen - 120); // 2-min overlap
      }

      function mergeList(list) {
        for (const e of list) if (isPrayer(e)) {
          collected.set(`${e.timestamp}|${e.log||''}`, e);
        }
      }

      if (!lastSeen) {
        const first = await fetch(`https://api.torn.com/user/0?selections=log&key=${apiKey}`).then(r=>r.json());
        if (first.error) throw new Error(first.error.error||'API error');
        mergeList(Object.values(first.log||{}));
      } else {
        const j = await fetch(`https://api.torn.com/user/0?selections=log&from=${lastSeen}&to=${now}&key=${apiKey}`).then(r=>r.json());
        if (j.error) throw new Error(j.error.error||'API error');
        mergeList(Object.values(j.log||{}));

        const cached = localStorage.getItem(LS_CACHE);
        if (cached) {
          try {
            const arr = JSON.parse(cached);
            if (Array.isArray(arr)) mergeList(arr);
          } catch {}
        }
      }

      // 2) If fewer than 10, backfill day-by-day up to 15 days
      const needMore = () => {
        let count = 0;
        for (const e of collected.values()) if (isPrayer(e)) count++;
        return count < 10;
      };

      for (let d = 0; d < 15 && needMore(); d++) {
        const toTs   = now - d*ONE_DAY;
        const fromTs = toTs - ONE_DAY;
        const url = `https://api.torn.com/user/0?selections=log&from=${fromTs}&to=${toTs}&key=${apiKey}`;
        const j = await fetch(url).then(r=>r.json());
        if (j.error) throw new Error(j.error.error||'API error');
        mergeList(Object.values(j.log||{}));
      }

      const merged = Array.from(collected.values()).filter(isPrayer).sort((a,b)=>b.timestamp-a.timestamp);

      // Update cache and lastSeen
      if (merged.length) {
        localStorage.setItem(LS_CACHE, JSON.stringify(merged.slice(0, 20)));
        localStorage.setItem(LS_LAST_SEEN_TS, String(merged[0].timestamp));
      }

      applyLogs(merged);
      setLastUpdated(hadCache ? 'synced' : 'ok');
    } catch (e){
      console.error('Prayer fetch error:', e);
      const msg = (e && e.message) ? e.message.toLowerCase() : '';
      if (msg.includes('access') || msg.includes('permission') || msg.includes('incorrect')) {
        alert(`Error fetching prayer logs: ${e.message||e}`);
        localStorage.removeItem(LS_KEY); apiKey=null; promptForApiKey();
      } else {
        // Silent failure: keep countdown using cache; schedule gentle retry
        applyFromCacheIfAvailable();
        setLastUpdated('error; retrying…');
        retryTimeoutId = setTimeout(() => fetchPrayerLogs(false), 60*1000);
      }
    } finally {
      isFetching=false;
      const refreshBtn = document.getElementById('refreshNow');
      if (manual && refreshBtn) {
        refreshBtn.disabled = false;
        refreshBtn.textContent = prevLabel || 'Refresh now';
      }
    }
  }

  // ---------- Smart refresh ----------
  function setupAutoRefresh(){
    setInterval(() => fetchPrayerLogs(false), 5*60*1000);

    let lastPath=location.pathname;
    function onLocationChange(){
      const curr=location.pathname;
      if (curr==='/church.php') fetchPrayerLogs(false);
      if (lastPath==='/church.php' && curr!=='/church.php') fetchPrayerLogs(false);
      lastPath=curr;
    }

    // Adjust for Torn PDA's dynamic page updates
    window.addEventListener('popstate', onLocationChange); // Detect history navigation
    window.addEventListener('locationchange', onLocationChange); // Detect internal navigation
    if (location.pathname==='/church.php') fetchPrayerLogs(false);
  }

  // ---------- Boot ----------
  createPanel();
  if (!apiKey){ promptForApiKey(); } else {
    // Draw from cache immediately (snappy), then fetch
    applyFromCacheIfAvailable();
    fetchPrayerLogs(false);
  }
  setupAutoRefresh();
})();