Bitcointalk weekly post & Merit Tracker

Weekly post count + Merit received + custom goals + local board details + customizable excluded boards

// ==UserScript==
// @name        Bitcointalk weekly post & Merit Tracker
// @namespace   https://bitcointalk.org
// @version     1.9.9
// @description Weekly post count + Merit received + custom goals + local board details + customizable excluded boards
// @author      Ace
// @match       https://bitcointalk.org/*
// @grant       none
// ==/UserScript==
(function () {
  'use strict';

  // Lista CORRETTA degli ID delle SOLE board locali (per paese/lingua)
  const localBoardIds = [
    // Italiano
    28, 107, 132, 153, 162, 169, 171, 170, 115, 144, 145, 165, 175, 200, 205,
    // Portoghese
    29, 69, 131, 134, 135, 181, 206,
    // Spagnolo
    27, 101, 102, 103, 104, 105, 130, 151,
    // Cinese
    30, 117, 118, 119, 146, 196,
    // Russo
    10, 18, 20, 21, 22, 23, 55, 66, 72, 90, 91, 185, 236, 237, 248, 256,
    // Francese
    13, 47, 48, 49, 50, 54, 149, 183, 184, 186, 187, 188, 208, 210, 211, 258,
    // Tedesco
    16, 269, 36, 60, 61, 62, 63, 64, 139, 140, 152, 270,
    // Olandese
    79, 80, 94, 116, 143, 147, 148, 150,
    // Turco
    133, 155, 156, 157, 158, 174, 180, 189, 190, 230, 232, 235, 239, 265,
    // Polacco
    142, 163, 164, 263, 264,
    // Indonesiano
    191, 192, 193, 194, 276, 277, 278,
    // Croato
    201, 220, 221, 272, 273,
    // Filippino
    219, 243, 260, 268, 274,
    // Arabo
    241, 242, 253, 266, 267, 271,
    // Giapponese
    252, 255,
    // Nigeriano
    275, 279, 280,
    // Greco
    120, 136, 179, 195, 246, 247,
    // Ebraico
    95,
    // Rumeno
    108, 109, 110, 111, 112, 113, 114, 166, 259
  ];

  const usernames = ['*ace*', 'lillominato89'];
  let selectedUser = localStorage.getItem('btwk_user') || usernames[0];
  let startDayIndex = parseInt(localStorage.getItem(`btwk_dayIndex_${selectedUser}`)) || 5; // Friday
  let timezoneOffset = parseInt(localStorage.getItem(`btwk_tzOffset_${selectedUser}`)) || 0;
  let currentWeekOffset = 0;
  let collapsed = localStorage.getItem('btwk_collapsed') === 'true';

  // Default excluded boards: Offtopic (9), Games & Rounds (71), Mega Threads (243), Services (52)
  const defaultExcludedBoards = { 9: true, 71: true, 243: true, 52: true };

  // Available boards to exclude
  const availableBoards = [
    { id: 9, name: "Offtopic" },
    { id: 71, name: "Games & Rounds" },
    { id: 243, name: "Mega Threads" },
    { id: 52, name: "Services" }
  ];

  const defaultGoals = {
    minGambling: 10,
    maxLocal: 5,
    maxValidPosts: 20,
    showMerits: true,
    excludedBoards: { ...defaultExcludedBoards },
    excludeLocalBoards: false
  };

  function getUserGoals(user) {
    const str = localStorage.getItem(`btwk_goals_${user}`);
    if (!str) return { ...defaultGoals };
    try {
      const goals = JSON.parse(str);
      if (!goals.excludedBoards) {
        goals.excludedBoards = { ...defaultExcludedBoards };
      }
      return goals;
    } catch (e) {
      console.error("Error parsing goals:", e);
      return { ...defaultGoals };
    }
  }

  function saveUserGoals(user, goals) {
    localStorage.setItem(`btwk_goals_${user}`, JSON.stringify(goals));
  }

  function getWeekRange(offset = 0) {
    const now = new Date();
    const utc = new Date(now.getTime() + timezoneOffset * 60 * 60 * 1000);
    const day = utc.getUTCDay();
    const daysSinceStart = (day + 7 - startDayIndex) % 7;
    const start = new Date(utc);
    start.setUTCDate(utc.getUTCDate() - daysSinceStart + offset * 7);
    start.setUTCHours(0, 0, 0, 0);
    const end = new Date(start);
    end.setUTCDate(start.getUTCDate() + 7);
    end.setUTCHours(0, 0, 0, Math.floor(Math.random() * 1000));
    return {
      from: start.toISOString().split('.')[0],
      to: end.toISOString(),
      label: `${start.toISOString().slice(0, 10)} → ${new Date(end - 1).toISOString().slice(0, 10)} (UTC${timezoneOffset >= 0 ? '+' : ''}${timezoneOffset})`,
    };
  }

  function fetchBoardStats() {
    const { from, to, label } = getWeekRange(currentWeekOffset);
    const url = `https://api.ninjastic.space/users/${selectedUser}/boards?from=${from}&to=${to}`;
    fetch(url)
      .then((res) => res.json())
      .then((json) => {
        if (!json || json.result !== 'success' || !json.data) {
          renderStats(`❌ Error fetching data`);
          return;
        }
        const boards = json.data.boards || [];
        const totalWithBoard = json.data.total_results_with_board || 0;
        const totalAll = json.data.total_results || 0;
        const unclassified = totalAll - totalWithBoard;
        let gambling = 0;
        let local = 0;
        let excluded = 0;
        let localBoardsDetail = {};
        const otherBoards = [];
        const goals = getUserGoals(selectedUser);
        const excludedBoardIds = Object.keys(goals.excludedBoards)
          .filter(id => goals.excludedBoards[id])
          .map(Number);
        const excludeLocalBoards = goals.excludeLocalBoards;
        boards.forEach((b) => {
          if (!b || !b.key) return;
          // Gambling boards (ID 228 and 56)
          if ([228, 56].includes(b.key)) {
            gambling += b.count;
          }
          // Excluded boards (customizable)
          else if (excludedBoardIds.includes(b.key)) {
            excluded += b.count;
            otherBoards.push({ name: `⛔ ${b.name}`, count: b.count });
          }
          // Local boards (IDs in localBoardIds list)
          else if (localBoardIds.includes(b.key)) {
            if (!excludeLocalBoards) {
              local += b.count;
              if (!localBoardsDetail[b.name]) {
                localBoardsDetail[b.name] = 0;
              }
              localBoardsDetail[b.name] += b.count;
            } else {
              excluded += b.count;
              otherBoards.push({ name: `⛔ ${b.name}`, count: b.count });
            }
          }
          // Other boards
          else {
            otherBoards.push({ name: b.name, count: b.count });
          }
        });
        const validLocal = Math.min(local, goals.maxLocal);
        const excessLocal = local > goals.maxLocal ? local - goals.maxLocal : 0;
        const validTotal = totalAll - excluded - unclassified - excessLocal;
        const gamblingCheck = gambling >= goals.minGambling ? '✅' : '❌';
        const localCheck = (local === 0) ? '✔️' : (local >= goals.maxLocal ? '✅' : '☑️');
        let html = `<b>👤 Account:</b> ${selectedUser} <span id="btwk_settings_btn" style="cursor:pointer;">⚙️</span><br>`;
        html += `<b>📅 Week:</b><br>${label}<br><br>`;
        html += `🧮 <b>Post valid:</b> (${validTotal} / ${goals.maxValidPosts}) Total: ${totalAll}<br>`;
        html += `🧩 <b>Unclassified:</b> ${unclassified}<br>`;
        html += `🃏 <b>Gambling:</b> ${gambling} / min ${goals.minGambling} ${gamblingCheck}<br>`;
        html += `🌍 <b>Local:</b> ${local} / max ${goals.maxLocal} ${localCheck}<br>`;
        if (gamblingCheck === '✅' && localCheck === '✅') {
          html += `<div style="font-family: monospace; color: #00ff00; margin: 5px 0; font-weight: bold;">WELL DONE!</div>`;
        }
        if (Object.keys(localBoardsDetail).length > 0) {
          html += `<b>📌 Local boards:</b><br>`;
          for (const [boardName, count] of Object.entries(localBoardsDetail)) {
            html += `• ${boardName}: ${count}<br>`;
          }
        }
        html += `<br>`;
        if (otherBoards.length > 0) {
          html += `<b>📌 Other boards:</b><br>`;
          otherBoards.forEach((b) => {
            html += `• ${b.name}: ${b.count}<br>`;
          });
        }
        renderStats(html);
        addSettingsListener();
      })
      .catch((err) => {
        renderStats(`⚠️ Network error: ${err.message}`);
      });
  }

  function fetchMerits() {
    const { from, to } = getWeekRange(currentWeekOffset);
    const url = `https://api.allorigins.win/get?url=${encodeURIComponent(`https://bpip.org/smerit.aspx?to=${selectedUser}&start=${from}&end=${to}`)}`;
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        if (!data || !data.contents) {
          renderMerits(`❌ Error loading Merits: No data received`);
          return;
        }
        const html = data.contents;
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');
        const table = doc.querySelector('table');
        if (!table) {
          renderMerits(`❌ Error loading Merits: No table found`);
          return;
        }
        const rows = Array.from(table.querySelectorAll('tbody tr'));
        const fromMap = {};
        let total = 0;
        rows.forEach((row) => {
          const tds = row.querySelectorAll('td');
          if (tds.length >= 4) {
            const from = tds[1].innerText.replace('(Summary)', '').trim();
            const amount = parseInt(tds[3].innerText.trim());
            fromMap[from] = (fromMap[from] || 0) + amount;
            total += amount;
          }
        });
        let htmlOut = `<b>⭐ Merits received: ${total}</b><br>`;
        if (total === 0) {
          htmlOut += `No Merits received this week.`;
        } else {
          Object.entries(fromMap)
            .sort((a, b) => b[1] - a[1])
            .forEach(([from, count]) => {
              htmlOut += `• ${from}: ${count}<br>`;
            });
        }
        renderMerits(htmlOut);
      })
      .catch((err) => {
        renderMerits(`❌ Error loading Merits: ${err.message}`);
      });
  }

  function renderStats(html) {
    const div = document.getElementById('btwk_stats');
    if (div) div.innerHTML = html;
  }

  function renderMerits(html) {
    const div = document.getElementById('btwk_merits');
    if (div) div.innerHTML = html;
  }

  function addSettingsListener() {
    const btn = document.getElementById('btwk_settings_btn');
    if (!btn) return;
    btn.onclick = () => toggleSettingsBox();
  }

  function toggleSettingsBox() {
    let box = document.getElementById('btwk_settings_box');
    if (box) return box.remove();
    const goals = getUserGoals(selectedUser);
    const dayOptions = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
      .map((day, i) => `<option value="${i}" ${i === startDayIndex ? 'selected' : ''}>${day}</option>`)
      .join('');

    const excludedBoardsHTML = availableBoards.map(board => {
      const isChecked = goals.excludedBoards[board.id] ? 'checked' : '';
      return `
        <label style="display: block; margin: 5px 0;">
          <input type="checkbox" id="exclude_${board.id}" ${isChecked}> Exclude ${board.name}
        </label>
      `;
    }).join('');

    box = document.createElement('div');
    box.id = 'btwk_settings_box';
    box.style.marginTop = '8px';
    box.style.padding = '8px';
    box.style.background = '#333';
    box.style.color = '#eee';
    box.style.borderRadius = '10px';
    box.innerHTML = `
      <b>⚙️ Settings for <i>${selectedUser}</i></b><br><br>
      <label>Minimum Gambling:
        <input type="number" id="goalMinGambling" min="0" value="${goals.minGambling}" style="width:60px; margin-left:8px;">
      </label><br><br>
      <label>Maximum Local:
        <input type="number" id="goalMaxLocal" min="0" value="${goals.maxLocal}" style="width:60px; margin-left:18px;">
      </label><br><br>
      <label>Max Valid Posts:
        <input type="number" id="goalMaxValidPosts" min="0" value="${goals.maxValidPosts}" style="width:60px; margin-left:10px;">
      </label><br><br>
      <label>📅 Week starts on:
        <select id="btwk_day_select" style="margin-left:4px;">${dayOptions}</select>
      </label><br><br>
      <label>🕓 UTC
        <input type="number" id="btwk_tz_input" value="${timezoneOffset}" style="width:40px; margin-left:18px;">
      </label><br><br>
      <label>
        <input type="checkbox" id="btwk_show_merits" ${goals.showMerits ? 'checked' : ''}> Show Merits received
      </label><br><br>
      <details style="margin-top: 10px; margin-bottom: 10px;">
        <summary style="cursor: pointer; font-weight: bold;">📋 Excluded Boards</summary>
        ${excludedBoardsHTML}
        <label style="display: block; margin: 5px 0;">
          <input type="checkbox" id="excludeLocalBoards" ${goals.excludeLocalBoards ? 'checked' : ''}> Exclude Local Boards
        </label>
      </details>
      <button id="saveGoalsBtn" style="padding:4px 10px; cursor:pointer;">💾 Save</button>
    `;
    const container = document.getElementById('btwk_content');
    container.appendChild(box);
    document.getElementById('saveGoalsBtn').onclick = () => {
      const newMin = parseInt(document.getElementById('goalMinGambling').value) || 0;
      const newMax = parseInt(document.getElementById('goalMaxLocal').value) || 0;
      const newMaxValidPosts = parseInt(document.getElementById('goalMaxValidPosts').value) || 0;
      const newDayIndex = parseInt(document.getElementById('btwk_day_select').value);
      const newTzOffset = parseInt(document.getElementById('btwk_tz_input').value) || 0;
      const newShowMerits = document.getElementById('btwk_show_merits').checked;
      const newExcludeLocalBoards = document.getElementById('excludeLocalBoards').checked;
      const newExcludedBoards = { ...defaultExcludedBoards };
      availableBoards.forEach(board => {
        newExcludedBoards[board.id] = document.getElementById(`exclude_${board.id}`).checked;
      });
      saveUserGoals(selectedUser, {
        minGambling: newMin,
        maxLocal: newMax,
        maxValidPosts: newMaxValidPosts,
        showMerits: newShowMerits,
        excludedBoards: newExcludedBoards,
        excludeLocalBoards: newExcludeLocalBoards
      });
      startDayIndex = newDayIndex;
      timezoneOffset = newTzOffset;
      localStorage.setItem(`btwk_dayIndex_${selectedUser}`, startDayIndex);
      localStorage.setItem(`btwk_tzOffset_${selectedUser}`, timezoneOffset);
      box.remove();
      updateBoxContent();
      update();
    };
  }

  function renderBox() {
    if (document.getElementById('btwk_box')) return;
    const box = document.createElement('div');
    box.id = 'btwk_box';
    box.style.position = 'fixed';
    box.style.bottom = '10px';
    box.style.right = '10px';
    box.style.background = '#222';
    box.style.color = '#fff';
    box.style.padding = '12px';
    box.style.borderRadius = '12px';
    box.style.fontSize = '13px';
    box.style.width = '280px';
    box.style.zIndex = '9999';
    box.style.boxShadow = '0 0 8px rgba(0,0,0,0.6)';
    const toggleBtn = document.createElement('button');
    toggleBtn.innerText = collapsed ? '➕' : '➖';
    toggleBtn.style.position = 'absolute';
    toggleBtn.style.top = '5px';
    toggleBtn.style.right = '5px';
    toggleBtn.style.background = '#444';
    toggleBtn.style.color = '#fff';
    toggleBtn.style.border = 'none';
    toggleBtn.style.cursor = 'pointer';
    toggleBtn.style.fontSize = '14px';
    toggleBtn.style.padding = '2px 6px';
    toggleBtn.style.borderRadius = '4px';
    toggleBtn.onclick = () => {
      collapsed = !collapsed;
      localStorage.setItem('btwk_collapsed', collapsed);
      updateBoxContent();
      toggleBtn.innerText = collapsed ? '➕' : '➖';
    };
    box.appendChild(toggleBtn);
    const content = document.createElement('div');
    content.id = 'btwk_content';
    box.appendChild(content);
    document.body.appendChild(box);
    updateBoxContent();
  }

  function updateBoxContent() {
    const container = document.getElementById('btwk_content');
    if (!container) return;
    const goals = getUserGoals(selectedUser);
    if (collapsed) {
      container.innerHTML = '<i style="opacity:0.7;">Tracker minimized</i>';
    } else {
      container.innerHTML = `
        <div style="margin-bottom:8px;">
          <label>👤
            <select id="btwk_user_select">${usernames.map((u) => `<option value="${u}"${u === selectedUser ? ' selected' : ''}>${u}</option>`).join('')}</select>
          </label>
        </div>
        <div style="margin-bottom:8px;">
          <button id="btwk_prev">⏪</button>
          <button id="btwk_next">⏩</button>
        </div>
        <div id="btwk_stats">⏳ Loading...</div>
        ${goals.showMerits ? '<hr><div id="btwk_merits">⏳ Loading Merits...</div>' : ''}
      `;
      document.getElementById('btwk_user_select').onchange = (e) => {
        selectedUser = e.target.value;
        localStorage.setItem('btwk_user', selectedUser);
        startDayIndex = parseInt(localStorage.getItem(`btwk_dayIndex_${selectedUser}`)) || 5;
        timezoneOffset = parseInt(localStorage.getItem(`btwk_tzOffset_${selectedUser}`)) || 0;
        update();
      };
      document.getElementById('btwk_prev').onclick = () => {
        currentWeekOffset--;
        update();
      };
      document.getElementById('btwk_next').onclick = () => {
        currentWeekOffset++;
        update();
      };
      update();
    }
  }

  function update() {
    if (!collapsed) {
      fetchBoardStats();
      const goals = getUserGoals(selectedUser);
      if (goals.showMerits) fetchMerits();
    }
  }

  renderBox();
  update();
})();