Bitcoin Forum
September 30, 2026, 03:18:58 PM *
News: Latest Bitcoin Core release: 31.1 [Torrent]
 
   Home   Help Search Login Register More  
Pages: [1]
  Print  
Author Topic: [Userscript] BitcoinTalk Merit History Plotter.  (Read 171 times)
satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
September 27, 2026, 06:42:52 AM
Last edit: September 27, 2026, 08:54:02 AM by satscraper
 #1

Credits:
 theymos  - administrator of bitcointalk.org and creator of its Merit system, the data this script visualizes.
 LoyceV   - creator of loyce.club, the merit-history archive this script reads from.
 Claude   -  AI assistant, used to help develop this script.

Code:

// ==UserScript==
// @name         BitcoinTalk Merit History Plotter 5.9.0
// @namespace    https://bitcointalk.org/
// @version      5.9.0
// @description  Plot a bitcointalk.org user's received-merit history in a single chart, switchable between By day / By Month / By last 120 days / By last 240 days / By last 360 days, combining loyce.club's history with any newer days on bitcointalk.org's own merit page (including entries shown as "Today"/"Yesterday" instead of a full date), with the account's real username (from its bitcointalk.org profile) shown alongside its ID
// @author       satscraper
// @match        https://bitcointalk.org/*
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      loyce.club
// @run-at       document-idle
// ==/UserScript==
//
// Credits:
//   theymos  - admin of bitcointalk.org's  Merit system, the data this script visualizes
//   LoyceV   - creator of loyce.club, the merit-history archive this script reads from
//   Claude   - Anthropic's AI assistant, used to help write this script

(function () {
  'use strict';

  const BTN_ID = 'merit-plotter-btn';

  // ---------- UI ----------

  function createButton() {
    if (document.getElementById(BTN_ID)) return;
    const btn = document.createElement('button');
    btn.id = BTN_ID;
    btn.textContent = 'Plot Merit History';
    btn.style.cssText = [
      'position:fixed', 'bottom:20px', 'right:20px', 'z-index:999999',
      'padding:10px 14px', 'background:#f7931a', 'color:#fff', 'border:none',
      'border-radius:6px', 'font-size:14px', 'font-family:sans-serif',
      'cursor:pointer', 'box-shadow:0 2px 6px rgba(0,0,0,.35)'
    ].join(';');
    btn.addEventListener('click', onButtonClick);
    document.body.appendChild(btn);
  }

  function guessUserIdFromPage() {
    // Works when currently viewing a profile: index.php?action=profile;u=NNN
    const m = location.href.match(/[?;&]u=(\d+)/);
    return m ? m[1] : '';
  }

  function onButtonClick() {
    const guess = guessUserIdFromPage();
    const id = prompt(
      'Enter the bitcointalk.org User ID (the numeric "u=" value from a profile URL, e.g. 3442679):',
      guess || ''
    );
    if (id === null) return; // cancelled
    const trimmed = id.trim();
    if (!/^\d+$/.test(trimmed)) {
      alert('Please enter a numeric user ID, e.g. 3442679');
      return;
    }
    fetchAndPlot(trimmed);
  }

  // ---------- Data fetching ----------
  // Two sources are combined:
  //  1) loyce.club/Merit/history/<id>.html \u2014 a fuller history, but it's a
  //     periodically-updated third-party scrape so it can lag behind by
  //     several days.
  //  2) bitcointalk.org's own index.php?action=merit;u=<id> page \u2014 only the
  //     last 120 days, but always current. Same-origin, so a plain fetch()
  //     works with the signed-in session automatically, no CORS/GM grant
  //     needed, and it's also where we read the account's real username.
 

  function fetchLoyceHistory(userId) {
    return new Promise((resolve, reject) => {
      const url = `https://loyce.club/Merit/history/${userId}.html`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload: function (res) {
          if (res.status !== 200) {
            reject(new Error('loyce.club HTTP ' + res.status));
            return;
          }
          try {
            resolve(parseMeritHistory(res.responseText));
          } catch (e) {
            reject(e);
          }
        },
        onerror: function () {
          reject(new Error('loyce.club network error'));
        }
      });
    });
  }

  function fetchBitcointalkMeritPage(userId) {
    const url = `https://bitcointalk.org/index.php?action=merit;u=${userId}`;
    return fetch(url, { credentials: 'same-origin' })
      .then(res => {
        if (!res.ok) throw new Error('bitcointalk.org merit page HTTP ' + res.status);
        return res.text();
      })
      .then(html => parseBitcointalkMeritPage(html));
  }

  function fetchBitcointalkUsername(userId) {
          const url = `https://bitcointalk.org/index.php?action=profile;u=${userId}`;
    return fetch(url, { credentials: 'same-origin' })
      .then(res => {
        if (!res.ok) throw new Error('bitcointalk.org profile page HTTP ' + res.status);
        return res.text();
      })
      .then(html => {
        const m = html.match(/<title>[^<]*?View the profile of\s+([^<]+?)\s*<\/title>/i);
        return m ? m[1].trim() : null;
      });
  }

  function startOfNextDay(d) {
    return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0);
  }

  function mergeReceived(loyceReceived, btReceived) {
    // loyceReceived is sorted ascending, so the last entry is the most recent
    // date loyce.club has for this user.
    const lastLoyceDate = loyceReceived.length ? loyceReceived[loyceReceived.length - 1].date : null;
    const cutoff = lastLoyceDate ? startOfNextDay(lastLoyceDate) : null;

    const appendedRecords = cutoff ? btReceived.filter(r => r.date >= cutoff) : btReceived.slice();
    const discardedCount = btReceived.length - appendedRecords.length;

    const merged = loyceReceived.concat(appendedRecords).sort((a, b) => a.date - b.date);
    return {
      merged,
      appended: appendedRecords.length,
      discarded: discardedCount,
      cutoffDate: cutoff
    };
  }

  function fetchAndPlot(userId) {
    Promise.all([
      fetchLoyceHistory(userId).catch(e => {
        console.warn('[Merit Plotter] loyce.club fetch/parse failed:', e);
        return { received: [], sent: [], candidateLines: 0, parsedLines: 0, loyceClaimedTotal: 0 };
      }),
      fetchBitcointalkMeritPage(userId).catch(e => {
        console.warn('[Merit Plotter] bitcointalk.org merit page fetch/parse failed:', e);
        const empty = [];
        empty.candidateLines = 0;
        empty.username = null;
        return empty;
      }),
      fetchBitcointalkUsername(userId).catch(e => {
        console.warn('[Merit Plotter] bitcointalk.org profile page fetch failed:', e);
        return null;
      })
    ]).then(([loyceParsed, btReceived, profileUsername]) => {
      const { merged, appended, discarded, cutoffDate } = mergeReceived(loyceParsed.received, btReceived);
      const computedSum = merged.reduce((s, r) => s + r.merit, 0);
      const loyceReceivedSum = loyceParsed.received.reduce((s, r) => s + r.merit, 0);

      const diagnostics = {
        loyceCandidateLines: loyceParsed.candidateLines || 0,
        loyceParsedLines: loyceParsed.parsedLines || 0,
        loyceReceivedCount: loyceParsed.received.length,
        loyceReceivedSum,
        loyceClaimedTotal: loyceParsed.loyceClaimedTotal || 0, // loyce.club's own running total, independent check
        loyceLastDate: loyceParsed.received.length ? loyceParsed.received[loyceParsed.received.length - 1].dateText : '(none)',
        cutoffDate: cutoffDate ? cutoffDate.toDateString() : '(no loyce data, no cutoff applied)',
        btCandidateLines: btReceived.candidateLines || 0,
        btReceivedCount: btReceived.length,
        btReceivedSum: btReceived.reduce((s, r) => s + r.merit, 0),
        appended,
        discarded,
        computedSum
      };
      console.log('[Merit Plotter] diagnostics:', diagnostics);
      if (diagnostics.loyceReceivedSum !== diagnostics.loyceClaimedTotal && diagnostics.loyceClaimedTotal > 0) {
        console.warn('[Merit Plotter] Our sum of loyce.club received entries (' + diagnostics.loyceReceivedSum +
          ') does NOT match loyce.club\u2019s own running total (' + diagnostics.loyceClaimedTotal +
          ') \u2014 this points to a parsing bug (some lines were skipped), not a data-completeness gap.');
      }

      if (!merged.length) {
        alert('No received-merit records were found for that user ID from either source. Double-check the ID, and that you\u2019re logged in to bitcointalk.org.');
        return;
      }
      const username = profileUsername || btReceived.username || null;
      openPlotTab(userId, { received: merged, diagnostics, username });
    });
  }

  // ---------- Parsing ----------
  // loyce.club history pages are plain numbered lines (rendered via <br>),


  const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };

  const LINE_RE = /(\d+)\.\s+([A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\s+\S+)\s*:\s*(-?\d+)\s*\(\s*\u03a3\s*(-?\d+)\s*\)\s*(to|from)\s+(.+?)\s*(?:\(Trust list\)\s*)?\(history\)\s*for\s+([^\n\r]*)/g;

  function parseMeritHistory(html) {
    // Normalize <br>/block-end tags to newlines so each entry is its own line,
    // regardless of whether the page uses <br>, <div>, <p> or <li> per entry.
    const normalized = html
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<\/(p|div|li|tr)>/gi, '\n');
    const doc = new DOMParser().parseFromString(normalized, 'text/html');
    const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');

    // Diagnostic: how many lines *look* like a numbered entry (start with
    // "NNN. Weekday DD Mon YYYY") versus how many LINE_RE actually captured.
    // A gap between these two numbers means some lines have a format LINE_RE
    // isn't matching -- i.e. a real parsing bug, not a data-completeness issue.
    const candidateLines = (text.match(/\d+\.\s+[A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M/g) || []).length;

    const received = [];
    const sent = [];
    let m;
    LINE_RE.lastIndex = 0;
    while ((m = LINE_RE.exec(text)) !== null) {
      const [, , dateStr, amountStr, sigmaStr, direction, otherUser, subject] = m;
      const date = parseHistoryDate(dateStr);
      const amount = parseFloat(amountStr);
      if (!date || isNaN(amount)) continue;
      const record = {
        date,
        merit: amount,
        dateText: dateStr.trim(),
        otherUser: otherUser.trim(),
        subject: subject.trim(),
        sigma: parseFloat(sigmaStr)
      };
      if (direction.toLowerCase() === 'from') received.push(record);
      else sent.push(record);
    }

    received.sort((a, b) => a.date - b.date);
    sent.sort((a, b) => a.date - b.date);

    
    const loyceClaimedTotal = received.length ? Math.max(...received.map(r => r.sigma)) : 0;

    return { received, sent, candidateLines, parsedLines: received.length + sent.length, loyceClaimedTotal };
  }

  function parseHistoryDate(text) {
    if (!text) return null;
    const m = text.match(/[A-Za-z]{3}\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM)/);
    if (!m) return null;
    const [, day, monStr, year, hh, mm, ss, ampm] = m;
    const month = MONTHS[monStr];
    if (month === undefined) return null;
    let hour = parseInt(hh, 10) % 12;
    if (ampm.toUpperCase() === 'PM') hour += 12;
    const d = new Date(parseInt(year, 10), month, parseInt(day, 10), hour, parseInt(mm, 10), parseInt(ss, 10));
    return isNaN(d.getTime()) ? null : d;
  }

 
  const BT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  const BT_LINE_RE = /(?:([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})|(Today|Yesterday))(?:\s+at)?,?\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM):\s*(\d+)\s+from\s+(.+?)\s+for\s+([^\n\r]*)/g;

  function parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm) {
    let hour = parseInt(hh, 10) % 12;
    if (ampm.toUpperCase() === 'PM') hour += 12;
    const minute = parseInt(mm, 10);
    const second = parseInt(ss, 10);
    if (monthName) {
      const monthIdx = BT_MONTHS.indexOf(monthName);
      if (monthIdx === -1) return null;
      const d = new Date(parseInt(year, 10), monthIdx, parseInt(day, 10), hour, minute, second);
      return isNaN(d.getTime()) ? null : d;
    }
    // "Today" / "Yesterday": relative to the actual current date this script
    // is running on (the date the page itself was generated for).
    const base = new Date();
    if (relativeWord.toLowerCase() === 'yesterday') base.setDate(base.getDate() - 1);
    const d = new Date(base.getFullYear(), base.getMonth(), base.getDate(), hour, minute, second);
    return isNaN(d.getTime()) ? null : d;
  }

  function extractReceivedSectionText(text) {
    const headingRe = /(Received|Sent)\s+in\s+the\s+last\s+\d+\s+days/gi;
    const marks = [];
    let hm;
    while ((hm = headingRe.exec(text)) !== null) {
      marks.push({ type: hm[1].toLowerCase(), index: hm.index, end: headingRe.lastIndex });
    }
    if (!marks.length) return text; // unknown layout: best effort, scan everything
    let combined = '';
    for (let i = 0; i < marks.length; i++) {
      if (marks[i].type !== 'received') continue;
      const start = marks[i].end;
      const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
      combined += text.slice(start, end) + '\n';
    }
    return combined;
  }

  function extractUsernameFromMeritPage(html) {
    // Try the <title> tag first: typically something like
    // "Bitcoin Forum - Merit for <username>" or similar phrasing.
    let m = html.match(/<title>([^<]*)<\/title>/i);
    if (m) {
      const t = m[1];
      const nm = t.match(/merit\s+for\s+(.+?)\s*$/i);
      if (nm) return nm[1].trim();
    }
    // Fallback: the phrase "Merit for X" appearing anywhere on the page,
    // e.g. as a heading above the Received/Sent sections.
    m = html.match(/merit\s+for\s+([^<\r\n]{1,60}?)(?:\s*<|\r|\n)/i);
    if (m) return m[1].trim();
    // Fallback: og:title meta tag.
    m = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
    if (m) {
      const name = m[1].replace(/merit\s+for\s+/i, '').trim();
      if (name) return name;
    }
    return null;
  }

  function parseBitcointalkMeritPage(html) {
    const normalized = html
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<\/(p|div|li|tr)>/gi, '\n');
    const doc = new DOMParser().parseFromString(normalized, 'text/html');
    const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');
    const scanText = extractReceivedSectionText(text);

    // Diagnostic: candidate lines (start with a date-looking prefix, either
    // absolute or the "Today"/"Yesterday" relative form) vs successfully
    // parsed ones.
    const candidateLines = (scanText.match(/(?:[A-Za-z]+\s+\d{1,2},\s+\d{4}|Today|Yesterday)(?:\s+at)?,?\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/g) || []).length;

    const records = [];
    let m;
    BT_LINE_RE.lastIndex = 0;
    while ((m = BT_LINE_RE.exec(scanText)) !== null) {
      const [, monthName, day, year, relativeWord, hh, mm, ss, ampm, amountStr, sender, subject] = m;
      const date = parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm);
      const merit = parseFloat(amountStr);
      if (!date || isNaN(merit)) continue;
      records.push({ date, merit, otherUser: sender.trim(), subject: subject.trim() });
    }
    records.candidateLines = candidateLines; // stowed on the array for convenience
    records.username = extractUsernameFromMeritPage(html);
    return records;
  }

  // ---------- Aggregation ----------

  const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

  function aggregateByDay(records) {
    const map = new Map(); // "YYYY-MM-DD" -> { date, total, count }
    for (const r of records) {
      const d = r.date;
      const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
      let entry = map.get(key);
      if (!entry) {
        entry = { date: new Date(d.getFullYear(), d.getMonth(), d.getDate()), total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => a.date - b.date);
  }

  function aggregateByMonth(records) {
    const map = new Map(); // "YYYY-MM" -> { date, total, count }
    for (const r of records) {
      const d = r.date;
      const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
      let entry = map.get(key);
      if (!entry) {
        entry = { date: new Date(d.getFullYear(), d.getMonth(), 1), total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => a.date - b.date);
  }

  function formatDay(d) {
    return String(d.getDate()).padStart(2, '0') + ' ' + MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear();
  }

  function formatMonth(d) {
    return MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear();
  }

  function toSeries(records, aggregateFn, formatFn) {
    const buckets = aggregateFn(records);
    return {
      labels: buckets.map(b => formatFn(b.date)),
      perEvent: buckets.map(b => b.total),
      tooltips: buckets.map(b => b.count + ' merit event' + (b.count > 1 ? 's' : ''))
    };
  }

  // ---------- Plot tab ----------

  function windowRecords(records, days) {
    if (days == null) return records; // no filtering
    const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
    return records.filter(r => r.date >= cutoff);
  }

    const MODE_DEFS = [
    { key: 'daily', label: 'By day', days: null, aggregate: aggregateByDay, format: formatDay },
    { key: 'monthly', label: 'By Month', days: null, aggregate: aggregateByMonth, format: formatMonth },
    { key: 'last120', label: 'By last 120 days', days: 120, aggregate: aggregateByDay, format: formatDay },
    { key: 'last240', label: 'By last 240 days', days: 240, aggregate: aggregateByDay, format: formatDay },
    { key: 'last360', label: 'By last 360 days', days: 360, aggregate: aggregateByDay, format: formatDay }
  ];

  function buildModes(receivedRecords) {
    const modes = {};
    for (const m of MODE_DEFS) {
      const recs = windowRecords(receivedRecords, m.days);
      const series = toSeries(recs, m.aggregate, m.format);
      modes[m.key] = {
        label: m.label,
        labels: series.labels,
        perEvent: series.perEvent,
        tooltips: series.tooltips,
        total: recs.reduce((s, r) => s + r.merit, 0)
      };
    }
    return modes;
  }

  function openPlotTab(userId, parsed) {
    const win = window.open('', '_blank');
    if (!win) {
      alert('Popup blocked \u2014 please allow popups for bitcointalk.org and click the button again.');
      return;
    }
    const modes = buildModes(parsed.received);
    const payload = {
      modes,
      receivedTotal: modes.daily.total, // all-time total, for the headline
      diagnostics: parsed.diagnostics,
      username: parsed.username || null
    };
    win.document.open();
    win.document.write(buildPlotHtml(userId, payload));
    win.document.close();
  }

  function buildPlotHtml(userId, payload) {
    const dataJson = JSON.stringify(payload).replace(/</g, '\\u003c'); // avoid premature </script>
    const displayName = 'user ' + userId + (payload.username ? ' (' + payload.username + ')' : '');

    return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Merit history \u2014 ${displayName}</title>
<style>
  body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background:#111; color:#eee; margin:0; padding:24px; }
  h1 { font-size:18px; font-weight:600; margin:0 0 4px; }
  #wrap { max-width:1000px; margin:0 auto; }
  .meta { color:#999; font-size:13px; margin-bottom:16px; }
  details.diag { margin-bottom:16px; font-size:12px; color:#aaa; }
  details.diag summary { cursor:pointer; color:#f7931a; }
  details.diag table { border-collapse:collapse; margin-top:8px; }
  details.diag td { padding:2px 10px 2px 0; }
  details.diag .warn { color:#ff8080; font-weight:600; }
  details.diag .ok { color:#7dd87d; }
  .toggle { margin-bottom:16px; }
  .toggle button { background:#222; color:#ccc; border:1px solid #444; padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; margin-right:8px; }
  .toggle button.active { background:#f7931a; color:#111; border-color:#f7931a; font-weight:600; }
  .chart-box { background:#1b1b1b; border-radius:8px; padding:16px 16px 8px; margin-bottom:28px; overflow-x:auto; position:relative; }
  .total-badge { position:sticky; left:8px; display:inline-block; background:#1b1b1b; border:2px solid #f7931a; border-radius:6px; padding:6px 12px; font-size:13px; font-weight:700; color:#f7931a; margin-bottom:10px; }
  svg { display:block; }
  .line-path { fill:none; stroke:#4dabf7; stroke-width:2; }
  .line-area { fill:rgba(77,171,247,0.15); stroke:none; }
  .dot { fill:#4dabf7; }
  .dot:hover { fill:#8ecbff; }
  .value-label-bg { fill:#222; stroke:#4dabf7; stroke-width:1; }
  .value-label-text { fill:#eee; font-size:10px; font-weight:600; }
  .axis-label { fill:#999; font-size:10px; }
  .grid-line { stroke:#333; stroke-width:1; }
  a { color:#f7931a; }
  #errorBox { display:none; white-space:pre-wrap; background:#3a1414; color:#ff8080; border:1px solid #661f1f; border-radius:6px; padding:12px; margin-bottom:20px; font-family:monospace; font-size:12px; }
</style>
</head>
<body>
<div id="wrap">
  <div id="errorBox"></div>
  <h1 id="title">Merit received over time \u2014 ${displayName}</h1>
  <div class="meta">
    Total received: <span id="totalReceived"></span>
    &nbsp;\u2014 sources: <a href="https://loyce.club/Merit/history/${userId}.html" target="_blank" rel="noopener">loyce.club</a>
    + <a href="https://bitcointalk.org/index.php?action=merit;u=${userId}" target="_blank" rel="noopener">bitcointalk.org merit page</a>, merged &amp; de-duplicated
  </div>
  <details class="diag" id="diagBox"><summary>Diagnostics (click to expand)</summary><div id="diagContent"></div></details>
  <div class="toggle" id="modeToggle"></div>
  <div class="chart-box">
    <div class="total-badge" id="totalBadge"></div>
    <div id="perEvent"></div>
  </div>
</div>
<script>
  window.onerror = function (msg, src, line, col, err) {
    const box = document.getElementById('errorBox');
    box.style.display = 'block';
    box.textContent = 'Script error: ' + msg + ' (line ' + line + ')' + (err && err.stack ? '\\n' + err.stack : '');
    return false;
  };

  try {
  const data = ${dataJson};
  document.getElementById('totalReceived').textContent = data.receivedTotal;

  if (data.diagnostics) {
    const d = data.diagnostics;
    const loyceMismatch = d.loyceClaimedTotal > 0 && d.loyceReceivedSum !== d.loyceClaimedTotal;
    const rows = [
      ['loyce.club \u2014 candidate lines found', d.loyceCandidateLines],
      ['loyce.club \u2014 lines successfully parsed (sent + received)', d.loyceParsedLines],
      ['loyce.club \u2014 received entries parsed', d.loyceReceivedCount],
      ['loyce.club \u2014 our sum of received amounts', d.loyceReceivedSum],
      ['loyce.club\\'s OWN running total (\u03a3, independent check)', d.loyceClaimedTotal],
      ['loyce.club \u2014 last date it has for this user', d.loyceLastDate],
      ['bitcointalk.org merit page \u2014 candidate lines found (Received section)', d.btCandidateLines],
      ['bitcointalk.org merit page \u2014 received entries parsed', d.btReceivedCount],
      ['bitcointalk.org merit page \u2014 sum of those entries', d.btReceivedSum],
      ['cutoff \u2014 only bitcointalk.org entries strictly after this date are kept', d.cutoffDate],
      ['merge \u2014 new entries appended (after cutoff)', d.appended],
      ['merge \u2014 entries discarded (on/before loyce.club\\'s last date)', d.discarded],
      ['FINAL \u2014 computed total across merged data', d.computedSum]
    ];
    let html = '<table>' + rows.map(r => '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>').join('') + '</table>';
    if (loyceMismatch) {
      html += '<p class="warn">MISMATCH: our sum of loyce.club received entries (' + d.loyceReceivedSum +
        ') does not match loyce.club\\'s own running total (' + d.loyceClaimedTotal +
        '). That points to a parsing bug on lines loyce.club has but this script is dropping \u2014 ' +
        'please report this.</p>';
    } else if (d.loyceClaimedTotal > 0) {
      html += '<p class="ok">Our parsing of loyce.club matches loyce.club\\'s own running total exactly.</p>';
    }
    if (d.btCandidateLines > d.btReceivedCount) {
      html += '<p class="warn">' + (d.btCandidateLines - d.btReceivedCount) + ' line(s) on the bitcointalk.org ' +
        'merit page looked like entries but failed to fully parse \u2014 check the console (F12).</p>';
    }
    document.getElementById('diagContent').innerHTML = html;
  } else {
    document.getElementById('diagBox').style.display = 'none';
  }

  const SVG_NS = 'http://www.w3.org/2000/svg';
  const MARGIN = { top: 24, right: 10, bottom: 60, left: 40 };
  const CHART_H = 420;
  const POINT_GAP = 36;

  function el(tag, attrs) {
    const e = document.createElementNS(SVG_NS, tag);
    for (const k in attrs) e.setAttribute(k, attrs[k]);
    return e;
  }

  function clearAndAppend(container, svg) {
    container.innerHTML = '';
    container.appendChild(svg);
  }

  function niceMax(v) {
    if (v <= 0) return 1;
    const mag = Math.pow(10, Math.floor(Math.log10(v)));
    const norm = v / mag;
    let step;
    if (norm <= 1) step = 1;
    else if (norm <= 2) step = 2;
    else if (norm <= 5) step = 5;
    else step = 10;
    return step * mag;
  }

  function renderLineChart(container, labels, values, tooltips) {
    const n = Math.max(values.length, 1);
    const width = Math.max(n * POINT_GAP + MARGIN.left + MARGIN.right, 300);
    const height = CHART_H;
    const innerW = width - MARGIN.left - MARGIN.right;
    const innerH = height - MARGIN.top - MARGIN.bottom;
    const maxVal = niceMax(Math.max(1, ...values));

    const svg = el('svg', { width, height, viewBox: '0 0 ' + width + ' ' + height });
    const g = el('g', { transform: 'translate(' + MARGIN.left + ',' + MARGIN.top + ')' });
    svg.appendChild(g);

    const gridCount = 5;
    for (let i = 0; i <= gridCount; i++) {
      const y = innerH - (innerH * i) / gridCount;
      const val = Math.round((maxVal * i) / gridCount);
      g.appendChild(el('line', { class: 'grid-line', x1: 0, x2: innerW, y1: y, y2: y }));
      const t = el('text', { class: 'axis-label', x: -6, y: y + 3, 'text-anchor': 'end' });
      t.textContent = val;
      g.appendChild(t);
    }

    const points = values.map((v, i) => {
      const x = i * POINT_GAP + POINT_GAP / 2;
      const y = innerH - (v / maxVal) * innerH;
      return [x, y];
    });

    if (points.length) {
      const linePath = 'M ' + points.map(p => p[0] + ' ' + p[1]).join(' L ');
      const areaPath = linePath + ' L ' + points[points.length - 1][0] + ' ' + innerH + ' L ' + points[0][0] + ' ' + innerH + ' Z';
      g.appendChild(el('path', { class: 'line-area', d: areaPath }));
      g.appendChild(el('path', { class: 'line-path', d: linePath }));
    }

    points.forEach(([x, y], i) => {
      const dot = el('circle', { class: 'dot', cx: x, cy: y, r: 3 });
      const title = el('title', {});
      title.textContent = labels[i] + ': ' + values[i] + ' merit' + (values[i] === 1 ? '' : 's') + (tooltips && tooltips[i] ? ' (' + tooltips[i] + ')' : '');
      dot.appendChild(title);
      g.appendChild(dot);
    });

    values.forEach((v, i) => {
      const [x, y] = points[i];
      const text = String(v);
      const labelW = Math.max(text.length * 7 + 10, 18);
      const labelH = 16;
      let labelY = y - 10 - labelH;
      if (labelY < 0) labelY = y + 10;
      const labelX = x - labelW / 2;
      g.appendChild(el('rect', { class: 'value-label-bg', x: labelX, y: labelY, width: labelW, height: labelH, rx: 3 }));
      const t = el('text', { class: 'value-label-text', x, y: labelY + labelH - 4, 'text-anchor': 'middle' });
      t.textContent = text;
      g.appendChild(t);
    });

    appendXLabels(g, labels, innerH, n);
    clearAndAppend(container, svg);
  }

  function appendXLabels(g, labels, innerH, n) {
    const step = Math.max(1, Math.ceil(n / 25));
    labels.forEach((lab, i) => {
      if (i % step !== 0) return;
      const x = i * POINT_GAP + POINT_GAP / 2;
      const t = el('text', {
        class: 'axis-label', x, y: innerH + 14,
        'text-anchor': 'end', transform: 'rotate(-60 ' + x + ' ' + (innerH + 14) + ')'
      });
      t.textContent = lab;
      g.appendChild(t);
    });
  }

  const MODE_KEYS = ['daily', 'monthly', 'last120', 'last240', 'last360'];
  const modeToggle = document.getElementById('modeToggle');
  const totalBadge = document.getElementById('totalBadge');
  const chartDiv = document.getElementById('perEvent');
  const buttons = {};

  MODE_KEYS.forEach(key => {
    const btn = document.createElement('button');
    btn.textContent = data.modes[key].label;
    btn.addEventListener('click', () => renderMode(key));
    modeToggle.appendChild(btn);
    buttons[key] = btn;
  });

  function renderMode(key) {
    const mode = data.modes[key];
    MODE_KEYS.forEach(k => buttons[k].classList.toggle('active', k === key));
    document.getElementById('totalReceived').textContent = mode.total;
    totalBadge.textContent = 'Total received: ' + mode.total + ' merit' + (mode.total === 1 ? '' : 's');
    if (!mode.labels.length) {
      chartDiv.innerHTML = '<p style="color:#777">No received-merit records in this view.</p>';
      return;
    }
    renderLineChart(chartDiv, mode.labels, mode.perEvent, mode.tooltips);
  }

  renderMode('daily');
  } catch (e) {
    const box = document.getElementById('errorBox');
    box.style.display = 'block';
    box.textContent = 'Render error: ' + e.message + '\\n' + (e.stack || '');
  }
<\/script>
</body>
</html>`;
  }

  // ---------- init ----------

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Plot Merit History\u2026', onButtonClick);
  }
  createButton();
})();





Quote from: satscraper

If you like what this script does, use Tampermonkey browser extension to run it.


▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
SquirrelJulietGarden
Legendary
*
Offline

Activity: 2128
Merit: 1004


Crypto Exchange Aggregator


View Profile
September 27, 2026, 07:16:25 AM
 #2

It is available on Bitlist.co's archive page for users. For example this one is for your account.
https://bitlist.co/user/id/3442679/merits?date_min=2018-01-24&date_max=2026-09-27

It's also available at Loyce.club
https://loyce.club/Merit/history/3442679.html

satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
September 27, 2026, 07:35:16 AM
Last edit: September 27, 2026, 07:54:38 AM by satscraper
 #3

It is available on Bitlist.co's archive page for users. For example this one is for your account.
https://bitlist.co/user/id/3442679/merits?date_min=2018-01-24&date_max=2026-09-27

It's also available at Loyce.club
https://loyce.club/Merit/history/3442679.html

I know this.

That said,  I developed this script for the following reasons:

Loyce.club data are not always up to date just example, for 3442679 the last date is Fri 11 Sep 2026, so the script merges them with the latest data taken from the user's profile. Besides, the plots script presents and visualizes data in more informative form than Loyce.club. That said, credit to LoyceV is hereby acknowledged.

Regarding https://bitlist.co, I found this script gives better visualization.

Sure, you are free to use whichever one suits you best.


▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
LoyceV
Legendary
*
Offline

Activity: 4172
Merit: 22836


Thick-Skinned Gang Leader and Golden Feather 2021


View Profile WWW
September 27, 2026, 09:56:41 AM
Merited by ABCbits (1), satscraper (1)
 #4

Loyce.club data are not always up to date just example, for 3442679 the last date is Fri 11 Sep 2026
There are 3 reasons for this: theymos' Merit data dump happens every Friday (merit.all.txt is updated shortly after this), processing all data takes more than a week on my current VPS, and the VPS was recently rebooted so all progress was lost.
If you can use my merit.all.txt, you'll have my most recent data weekly.

¡uʍop ǝpᴉsdn pɐǝɥ ɹnoʎ ɥʇᴉʍ ʎuunɟ ʞool no⅄
[MUSIC] The Ballad of LoyceV
satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
September 27, 2026, 10:39:37 AM
Last edit: September 27, 2026, 10:50:07 AM by satscraper
 #5

Loyce.club data are not always up to date just example, for 3442679 the last date is Fri 11 Sep 2026
There are 3 reasons for this: theymos' Merit data dump happens every Friday (merit.all.txt ...processing all data takes more than a week on my current VPS, and the VPS was recently rebooted so all progress was lost.

That is why script appends absent data  taken directly from https://bitcointalk.org/index.php?action=merit;u=<ID>. The latter is up-to-date, and virtually momentary.

And again, locev.club is more like a data archive that is hard to read and comprehend visually at least for me. That said, it helps to gather the data needed. Thanks for this.

▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
satscraper (OP)
Legendary
*
Offline

Activity: 1596
Merit: 3012



View Profile
September 29, 2026, 08:15:11 AM
Last edit: September 29, 2026, 09:12:57 AM by satscraper
 #6

BitcoinTalk Merit History Plotter script has been elevated to v. 6.0.0

It adds "By thread"  button showing all time totals per thread with default to top ~15 expandable by "show all".

Code:
// ==UserScript==
// @name         BitcoinTalk Merit History Plotter 6.0.0
// @namespace    https://bitcointalk.org/
// @version      6.0.0
// @description  Plot a bitcointalk.org user's received-merit history in a single chart, switchable between By day / By Month / By last 120 days / By last 240 days / By last 360 days, combining loyce.club's history with any newer days on bitcointalk.org's own merit page (including entries shown as "Today"/"Yesterday" instead of a full date), with the account's real username (from its bitcointalk.org profile) shown alongside its ID
// @author       satscraper
// @match        https://bitcointalk.org/*
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      loyce.club
// @run-at       document-idle
// ==/UserScript==
//
// Credits:
//   theymos  - admin of bitcointalk.org's  Merit system, the data this script visualizes
//   LoyceV   - creator of loyce.club, the merit-history archive this script reads from
//   Claude   - Anthropic's AI assistant, used to help write this script

(function () {
  'use strict';

  const BTN_ID = 'merit-plotter-btn';

  // Escapes a string for safe interpolation as HTML text content. Used
  // wherever data read from a remote page (username, etc.) is written into
  // the generated plot tab's markup \u2014 remote-sourced strings are never
  // trusted at the point they're turned into HTML, regardless of any
  // upstream regex constraints already applied when extracting them.
  function escapeHtml(str) {
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  // ---------- UI ----------

  function createButton() {
    if (document.getElementById(BTN_ID)) return;
    const btn = document.createElement('button');
    btn.id = BTN_ID;
    btn.textContent = 'Plot Merit History';
    btn.style.cssText = [
      'position:fixed', 'bottom:20px', 'right:20px', 'z-index:999999',
      'padding:10px 14px', 'background:#f7931a', 'color:#fff', 'border:none',
      'border-radius:6px', 'font-size:14px', 'font-family:sans-serif',
      'cursor:pointer', 'box-shadow:0 2px 6px rgba(0,0,0,.35)'
    ].join(';');
    btn.addEventListener('click', onButtonClick);
    document.body.appendChild(btn);
  }

  function guessUserIdFromPage() {
    // Works when currently viewing a profile: index.php?action=profile;u=NNN
    const m = location.href.match(/[?;&]u=(\d+)/);
    return m ? m[1] : '';
  }

  function onButtonClick() {
    const guess = guessUserIdFromPage();
    const id = prompt(
      'Enter the bitcointalk.org User ID (the numeric "u=" value from a profile URL, e.g. 3442679):',
      guess || ''
    );
    if (id === null) return; // cancelled
    const trimmed = id.trim();
    if (!/^\d+$/.test(trimmed)) {
      alert('Please enter a numeric user ID, e.g. 3442679');
      return;
    }
    fetchAndPlot(trimmed);
  }

  // ---------- Data fetching ----------
  // Two sources are combined:
  //  1) loyce.club/Merit/history/<id>.html \u2014 a fuller history, but it's a
  //     periodically-updated third-party scrape so it can lag behind by
  //     several days.
  //  2) bitcointalk.org's own index.php?action=merit;u=<id> page \u2014 only the
  //     last 120 days, but always current. Same-origin, so a plain fetch()
  //     works with the signed-in session automatically, no CORS/GM grant
  //     needed, and it's also where we read the account's real username.
  //
  // Only bitcointalk.org entries dated AFTER loyce.club's most recent date are
  // appended \u2014 anything on or before that cutoff day is discarded outright,
  // rather than trying to de-duplicate by matching timestamps. An earlier
  // version tried exact timestamp+sender+amount matching, but bitcointalk.org
  // displays times in each viewer's own forum timezone setting while
  // loyce.club always shows CEST, so the same transaction can carry two
  // different displayed times across the two pages \u2014 exact-time matching
  // then fails to recognize it as a duplicate and double-counts it. A plain
  // date cutoff (loyce.club's last calendar day and everything on/before it is
  // never touched) sidesteps that entirely.

  function fetchLoyceHistory(userId) {
    return new Promise((resolve, reject) => {
      const url = `https://loyce.club/Merit/history/${userId}.html`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload: function (res) {
          if (res.status !== 200) {
            reject(new Error('loyce.club HTTP ' + res.status));
            return;
          }
          try {
            resolve(parseMeritHistory(res.responseText));
          } catch (e) {
            reject(e);
          }
        },
        onerror: function () {
          reject(new Error('loyce.club network error'));
        }
      });
    });
  }

  function fetchBitcointalkMeritPage(userId) {
    const url = `https://bitcointalk.org/index.php?action=merit;u=${userId}`;
    return fetch(url, { credentials: 'same-origin' })
      .then(res => {
        if (!res.ok) throw new Error('bitcointalk.org merit page HTTP ' + res.status);
        return res.text();
      })
      .then(html => parseBitcointalkMeritPage(html));
  }

  function fetchBitcointalkUsername(userId) {
    // The merit page's own title doesn't reliably contain the username, so the
    // profile page is used instead \u2014 its title is a fixed, confirmed SMF
    // string: "... - View the profile of <username>".
    const url = `https://bitcointalk.org/index.php?action=profile;u=${userId}`;
    return fetch(url, { credentials: 'same-origin' })
      .then(res => {
        if (!res.ok) throw new Error('bitcointalk.org profile page HTTP ' + res.status);
        return res.text();
      })
      .then(html => {
        const m = html.match(/<title>[^<]*?View the profile of\s+([^<]+?)\s*<\/title>/i);
        return m ? m[1].trim() : null;
      });
  }

  function startOfNextDay(d) {
    return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0);
  }

  function mergeReceived(loyceReceived, btReceived) {
    // loyceReceived is sorted ascending, so the last entry is the most recent
    // date loyce.club has for this user.
    const lastLoyceDate = loyceReceived.length ? loyceReceived[loyceReceived.length - 1].date : null;
    const cutoff = lastLoyceDate ? startOfNextDay(lastLoyceDate) : null;

    const appendedRecords = cutoff ? btReceived.filter(r => r.date >= cutoff) : btReceived.slice();
    const discardedCount = btReceived.length - appendedRecords.length;

    const merged = loyceReceived.concat(appendedRecords).sort((a, b) => a.date - b.date);
    return {
      merged,
      appended: appendedRecords.length,
      discarded: discardedCount,
      cutoffDate: cutoff
    };
  }

  function fetchAndPlot(userId) {
    Promise.all([
      fetchLoyceHistory(userId).catch(e => {
        console.warn('[Merit Plotter] loyce.club fetch/parse failed:', e);
        return { received: [], sent: [], candidateLines: 0, parsedLines: 0, loyceClaimedTotal: 0 };
      }),
      fetchBitcointalkMeritPage(userId).catch(e => {
        console.warn('[Merit Plotter] bitcointalk.org merit page fetch/parse failed:', e);
        const empty = [];
        empty.candidateLines = 0;
        empty.username = null;
        return empty;
      }),
      fetchBitcointalkUsername(userId).catch(e => {
        console.warn('[Merit Plotter] bitcointalk.org profile page fetch failed:', e);
        return null;
      })
    ]).then(([loyceParsed, btReceived, profileUsername]) => {
      const { merged, appended, discarded, cutoffDate } = mergeReceived(loyceParsed.received, btReceived);
      const computedSum = merged.reduce((s, r) => s + r.merit, 0);
      const loyceReceivedSum = loyceParsed.received.reduce((s, r) => s + r.merit, 0);

      const diagnostics = {
        loyceCandidateLines: loyceParsed.candidateLines || 0,
        loyceParsedLines: loyceParsed.parsedLines || 0,
        loyceReceivedCount: loyceParsed.received.length,
        loyceReceivedSum,
        loyceClaimedTotal: loyceParsed.loyceClaimedTotal || 0, // loyce.club's own running total, independent check
        loyceLastDate: loyceParsed.received.length ? loyceParsed.received[loyceParsed.received.length - 1].dateText : '(none)',
        cutoffDate: cutoffDate ? cutoffDate.toDateString() : '(no loyce data, no cutoff applied)',
        btCandidateLines: btReceived.candidateLines || 0,
        btReceivedCount: btReceived.length,
        btReceivedSum: btReceived.reduce((s, r) => s + r.merit, 0),
        appended,
        discarded,
        computedSum
      };
      console.log('[Merit Plotter] diagnostics:', diagnostics);
      if (diagnostics.loyceReceivedSum !== diagnostics.loyceClaimedTotal && diagnostics.loyceClaimedTotal > 0) {
        console.warn('[Merit Plotter] Our sum of loyce.club received entries (' + diagnostics.loyceReceivedSum +
          ') does NOT match loyce.club\u2019s own running total (' + diagnostics.loyceClaimedTotal +
          ') \u2014 this points to a parsing bug (some lines were skipped), not a data-completeness gap.');
      }

      if (!merged.length) {
        alert('No received-merit records were found for that user ID from either source. Double-check the ID, and that you\u2019re logged in to bitcointalk.org.');
        return;
      }
      const username = profileUsername || btReceived.username || null;
      openPlotTab(userId, { received: merged, diagnostics, username });
    });
  }

  // ---------- Parsing ----------
  // loyce.club history pages are plain numbered lines (rendered via <br>),
  // e.g.:
  //   499. Wed 09 Sep 2026 05:19:11 PM CEST: 2 (\u03a3 682) to ChiBitCty (Trust list) (history) for Re: ...
  //   12. Mon 03 Aug 2026 09:12:00 AM CEST: 1 (\u03a3 45) from SomeSender (Trust list) (history) for Re: ...
  // "to X" = merit this user SENT to X. "from X" = merit this user RECEIVED from X.
  // "(Trust list)" is treated as optional (some senders may not have one, e.g.
  // very new or restricted accounts) so a missing tag there doesn't silently
  // drop an otherwise-valid line; "(history)" is kept as the required anchor.

  const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };

  const LINE_RE = /(\d+)\.\s+([A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\s+\S+)\s*:\s*(-?\d+)\s*\(\s*\u03a3\s*(-?\d+)\s*\)\s*(to|from)\s+(.+?)\s*(?:\(Trust list\)\s*)?\(history\)\s*for\s+([^\n\r]*)/g;

  function parseMeritHistory(html) {
    // Normalize <br>/block-end tags to newlines so each entry is its own line,
    // regardless of whether the page uses <br>, <div>, <p> or <li> per entry.
    const normalized = html
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<\/(p|div|li|tr)>/gi, '\n');
    const doc = new DOMParser().parseFromString(normalized, 'text/html');
    const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');

    // Diagnostic: how many lines *look* like a numbered entry (start with
    // "NNN. Weekday DD Mon YYYY") versus how many LINE_RE actually captured.
    // A gap between these two numbers means some lines have a format LINE_RE
    // isn't matching -- i.e. a real parsing bug, not a data-completeness issue.
    const candidateLines = (text.match(/\d+\.\s+[A-Za-z]{3}\s+\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M/g) || []).length;

    const received = [];
    const sent = [];
    let m;
    LINE_RE.lastIndex = 0;
    while ((m = LINE_RE.exec(text)) !== null) {
      const [, , dateStr, amountStr, sigmaStr, direction, otherUser, subject] = m;
      const date = parseHistoryDate(dateStr);
      const amount = parseFloat(amountStr);
      if (!date || isNaN(amount)) continue;
      const record = {
        date,
        merit: amount,
        dateText: dateStr.trim(),
        otherUser: otherUser.trim(),
        subject: subject.trim(),
        sigma: parseFloat(sigmaStr)
      };
      if (direction.toLowerCase() === 'from') received.push(record);
      else sent.push(record);
    }

    received.sort((a, b) => a.date - b.date);
    sent.sort((a, b) => a.date - b.date);

    // loyce.club prints a running cumulative total (\u03a3) after every line, so
    // the highest \u03a3 among "received" lines is loyce.club's OWN claimed
    // lifetime received total, independent of how we summed the individual
    // amounts. If our sum doesn't match this, that's a parsing bug on our
    // side (dropped/mis-parsed lines), not a data-completeness gap.
    const loyceClaimedTotal = received.length ? Math.max(...received.map(r => r.sigma)) : 0;

    return { received, sent, candidateLines, parsedLines: received.length + sent.length, loyceClaimedTotal };
  }

  function parseHistoryDate(text) {
    if (!text) return null;
    const m = text.match(/[A-Za-z]{3}\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM)/);
    if (!m) return null;
    const [, day, monStr, year, hh, mm, ss, ampm] = m;
    const month = MONTHS[monStr];
    if (month === undefined) return null;
    let hour = parseInt(hh, 10) % 12;
    if (ampm.toUpperCase() === 'PM') hour += 12;
    const d = new Date(parseInt(year, 10), month, parseInt(day, 10), hour, parseInt(mm, 10), parseInt(ss, 10));
    return isNaN(d.getTime()) ? null : d;
  }

  // bitcointalk.org's own action=merit page format (only covers the last 120
  // days), e.g.:
  //   September 20, 2026, 09:19:54 PM: 2 from klarki for Re: some thread title
  // SMF (which bitcointalk.org runs) has a very common built-in "Today"/
  // "Yesterday" relative-date display: recent entries show as e.g.
  //   Today, 09:19:54 PM: 2 from klarki for Re: some thread title
  //   Yesterday at 09:19:54 PM: 2 from klarki for Re: some thread title
  // instead of a full "Month DD, YYYY" date, so both forms are matched (with
  // or without "at" before the time). The page lists both a "Received in the
  // last N days" and a "Sent in the last N days" section; only the Received
  // section is parsed. The username is also read from this same page (its
  // <title> or an on-page heading), so no extra request is needed just to
  // display it.

  const BT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  const BT_LINE_RE = /(?:([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})|(Today|Yesterday))(?:\s+at)?,?\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM):\s*(\d+)\s+from\s+(.+?)\s+for\s+([^\n\r]*)/g;

  function parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm) {
    let hour = parseInt(hh, 10) % 12;
    if (ampm.toUpperCase() === 'PM') hour += 12;
    const minute = parseInt(mm, 10);
    const second = parseInt(ss, 10);
    if (monthName) {
      const monthIdx = BT_MONTHS.indexOf(monthName);
      if (monthIdx === -1) return null;
      const d = new Date(parseInt(year, 10), monthIdx, parseInt(day, 10), hour, minute, second);
      return isNaN(d.getTime()) ? null : d;
    }
    // "Today" / "Yesterday": relative to the actual current date this script
    // is running on (the date the page itself was generated for).
    const base = new Date();
    if (relativeWord.toLowerCase() === 'yesterday') base.setDate(base.getDate() - 1);
    const d = new Date(base.getFullYear(), base.getMonth(), base.getDate(), hour, minute, second);
    return isNaN(d.getTime()) ? null : d;
  }

  function extractReceivedSectionText(text) {
    const headingRe = /(Received|Sent)\s+in\s+the\s+last\s+\d+\s+days/gi;
    const marks = [];
    let hm;
    while ((hm = headingRe.exec(text)) !== null) {
      marks.push({ type: hm[1].toLowerCase(), index: hm.index, end: headingRe.lastIndex });
    }
    if (!marks.length) return text; // unknown layout: best effort, scan everything
    let combined = '';
    for (let i = 0; i < marks.length; i++) {
      if (marks[i].type !== 'received') continue;
      const start = marks[i].end;
      const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
      combined += text.slice(start, end) + '\n';
    }
    return combined;
  }

  function extractUsernameFromMeritPage(html) {
    // Try the <title> tag first: typically something like
    // "Bitcoin Forum - Merit for <username>" or similar phrasing.
    let m = html.match(/<title>([^<]*)<\/title>/i);
    if (m) {
      const t = m[1];
      const nm = t.match(/merit\s+for\s+(.+?)\s*$/i);
      if (nm) return nm[1].trim();
    }
    // Fallback: the phrase "Merit for X" appearing anywhere on the page,
    // e.g. as a heading above the Received/Sent sections.
    m = html.match(/merit\s+for\s+([^<\r\n]{1,60}?)(?:\s*<|\r|\n)/i);
    if (m) return m[1].trim();
    // Fallback: og:title meta tag.
    m = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"'<]+)["']/i);
    if (m) {
      const name = m[1].replace(/merit\s+for\s+/i, '').trim();
      if (name) return name;
    }
    return null;
  }

  function parseBitcointalkMeritPage(html) {
    const normalized = html
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<\/(p|div|li|tr)>/gi, '\n');
    const doc = new DOMParser().parseFromString(normalized, 'text/html');
    const text = doc.body ? doc.body.textContent : normalized.replace(/<[^>]+>/g, '');
    const scanText = extractReceivedSectionText(text);

    // Diagnostic: candidate lines (start with a date-looking prefix, either
    // absolute or the "Today"/"Yesterday" relative form) vs successfully
    // parsed ones.
    const candidateLines = (scanText.match(/(?:[A-Za-z]+\s+\d{1,2},\s+\d{4}|Today|Yesterday)(?:\s+at)?,?\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/g) || []).length;

    const records = [];
    let m;
    BT_LINE_RE.lastIndex = 0;
    while ((m = BT_LINE_RE.exec(scanText)) !== null) {
      const [, monthName, day, year, relativeWord, hh, mm, ss, ampm, amountStr, sender, subject] = m;
      const date = parseBtDate(monthName, day, year, relativeWord, hh, mm, ss, ampm);
      const merit = parseFloat(amountStr);
      if (!date || isNaN(merit)) continue;
      records.push({ date, merit, otherUser: sender.trim(), subject: subject.trim() });
    }
    records.candidateLines = candidateLines; // stowed on the array for convenience
    records.username = extractUsernameFromMeritPage(html);
    return records;
  }

  // ---------- Aggregation ----------

  const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

  function aggregateByDay(records) {
    const map = new Map(); // "YYYY-MM-DD" -> { date, total, count }
    for (const r of records) {
      const d = r.date;
      const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
      let entry = map.get(key);
      if (!entry) {
        entry = { date: new Date(d.getFullYear(), d.getMonth(), d.getDate()), total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => a.date - b.date);
  }

  function aggregateByMonth(records) {
    const map = new Map(); // "YYYY-MM" -> { date, total, count }
    for (const r of records) {
      const d = r.date;
      const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
      let entry = map.get(key);
      if (!entry) {
        entry = { date: new Date(d.getFullYear(), d.getMonth(), 1), total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => a.date - b.date);
  }

  function formatDay(d) {
    return String(d.getDate()).padStart(2, '0') + ' ' + MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear();
  }

  function formatMonth(d) {
    return MONTH_NAMES[d.getMonth()] + ' ' + d.getFullYear();
  }

  function aggregateByThread(records) {
    // Groups by subject text (with a leading "Re: " stripped), since neither
    // data source exposes a thread id or URL \u2014 the subject line is the
    // closest thing we have to a thread identity. Two distinct threads that
    // happen to share an identical title will be merged; that's a known
    // limitation, not a bug.
    const map = new Map(); // lowercased subject -> { subject (display form), total, count }
    for (const r of records) {
      let subject = (r.subject || '').trim().replace(/^Re:\s*/i, '').trim();
      if (!subject) subject = '(no subject)';
      const key = subject.toLowerCase();
      let entry = map.get(key);
      if (!entry) {
        entry = { subject, total: 0, count: 0 };
        map.set(key, entry);
      }
      entry.total += r.merit;
      entry.count += 1;
    }
    return Array.from(map.values()).sort((a, b) => b.total - a.total);
  }

  function toSeries(records, aggregateFn, formatFn) {
    const buckets = aggregateFn(records);
    return {
      labels: buckets.map(b => formatFn(b.date)),
      perEvent: buckets.map(b => b.total),
      tooltips: buckets.map(b => b.count + ' merit event' + (b.count > 1 ? 's' : ''))
    };
  }

  // ---------- Plot tab ----------

  function windowRecords(records, days) {
    if (days == null) return records; // no filtering
    const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
    return records.filter(r => r.date >= cutoff);
  }

  // Exactly six view modes, switched by their own button \u2014 all rendered
  // into the same single chart area rather than separate sections.
  const MODE_DEFS = [
    { key: 'daily', label: 'By day', days: null, aggregate: aggregateByDay, format: formatDay },
    { key: 'monthly', label: 'By Month', days: null, aggregate: aggregateByMonth, format: formatMonth },
    { key: 'last120', label: 'By last 120 days', days: 120, aggregate: aggregateByDay, format: formatDay },
    { key: 'last240', label: 'By last 240 days', days: 240, aggregate: aggregateByDay, format: formatDay },
    { key: 'last360', label: 'By last 360 days', days: 360, aggregate: aggregateByDay, format: formatDay }
  ];

  function buildModes(receivedRecords) {
    const modes = {};
    for (const m of MODE_DEFS) {
      const recs = windowRecords(receivedRecords, m.days);
      const series = toSeries(recs, m.aggregate, m.format);
      modes[m.key] = {
        label: m.label,
        labels: series.labels,
        perEvent: series.perEvent,
        tooltips: series.tooltips,
        total: recs.reduce((s, r) => s + r.merit, 0)
      };
    }

    // Sixth mode: all-time, grouped by thread instead of by date. Kept
    // outside the loop above since it doesn't fit the date-bucket shape the
    // other five share (no window filtering \u2014 always all-time, per your
    // call to keep it consistent with By day / By Month).
    const threads = aggregateByThread(receivedRecords);
    modes.thread = {
      label: 'By thread',
      threads,
      total: threads.reduce((s, t) => s + t.total, 0)
    };

    return modes;
  }

  function openPlotTab(userId, parsed) {
    const win = window.open('', '_blank');
    if (!win) {
      alert('Popup blocked \u2014 please allow popups for bitcointalk.org and click the button again.');
      return;
    }
    const modes = buildModes(parsed.received);
    const payload = {
      modes,
      receivedTotal: modes.daily.total, // all-time total, for the headline
      diagnostics: parsed.diagnostics,
      username: parsed.username || null
    };
    win.document.open();
    win.document.write(buildPlotHtml(userId, payload));
    win.document.close();
  }

  function buildPlotHtml(userId, payload) {
    const dataJson = JSON.stringify(payload).replace(/</g, '\\u003c'); // avoid premature </script>
    const rawDisplayName = 'user ' + userId + (payload.username ? ' (' + payload.username + ')' : '');
    // Defense in depth: userId is always numeric (validated on input) and the
    // username is regex-constrained not to contain "<" at extraction time,
    // but this is escaped again here too since it's about to be interpolated
    // as raw HTML text (in <title> and <h1>) \u2014 never trust a remote-sourced
    // string at the point it's written into markup, regardless of what
    // upstream validation already did.
    const displayName = escapeHtml(rawDisplayName);

    return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Merit history \u2014 ${displayName}</title>
<style>
  body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background:#111; color:#eee; margin:0; padding:24px; }
  h1 { font-size:18px; font-weight:600; margin:0 0 4px; }
  #wrap { max-width:1000px; margin:0 auto; }
  .meta { color:#999; font-size:13px; margin-bottom:16px; }
  details.diag { margin-bottom:16px; font-size:12px; color:#aaa; }
  details.diag summary { cursor:pointer; color:#f7931a; }
  details.diag table { border-collapse:collapse; margin-top:8px; }
  details.diag td { padding:2px 10px 2px 0; }
  details.diag .warn { color:#ff8080; font-weight:600; }
  details.diag .ok { color:#7dd87d; }
  .toggle { margin-bottom:16px; }
  .toggle button { background:#222; color:#ccc; border:1px solid #444; padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; margin-right:8px; }
  .toggle button.active { background:#f7931a; color:#111; border-color:#f7931a; font-weight:600; }
  .chart-box { background:#1b1b1b; border-radius:8px; padding:16px 16px 8px; margin-bottom:28px; overflow-x:auto; position:relative; }
  .total-badge { position:sticky; left:8px; display:inline-block; background:#1b1b1b; border:2px solid #f7931a; border-radius:6px; padding:6px 12px; font-size:13px; font-weight:700; color:#f7931a; margin-bottom:10px; }
  svg { display:block; }
  .line-path { fill:none; stroke:#4dabf7; stroke-width:2; }
  .line-area { fill:rgba(77,171,247,0.15); stroke:none; }
  .dot { fill:#4dabf7; }
  .dot:hover { fill:#8ecbff; }
  .value-label-bg { fill:#222; stroke:#4dabf7; stroke-width:1; }
  .value-label-text { fill:#eee; font-size:10px; font-weight:600; }
  .axis-label { fill:#999; font-size:10px; }
  .grid-line { stroke:#333; stroke-width:1; }
  .thread-row { display:flex; align-items:center; gap:12px; padding:10px 4px; border-bottom:1px solid #2a2a2a; }
  .thread-rank { font-size:13px; color:#777; width:20px; flex-shrink:0; }
  .thread-info { flex:1; min-width:0; }
  .thread-title { font-size:14px; margin:0 0 5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .thread-bar-bg { background:#111; border-radius:3px; height:5px; overflow:hidden; }
  .thread-bar-fill { background:#4dabf7; height:100%; }
  .thread-events { font-size:12px; color:#777; white-space:nowrap; flex-shrink:0; }
  .thread-total { font-size:15px; font-weight:700; color:#f7931a; width:44px; text-align:right; flex-shrink:0; }
  .thread-showmore { display:block; width:100%; margin-top:10px; background:#222; color:#ccc; border:1px solid #444; padding:8px; font-size:13px; cursor:pointer; border-radius:6px; }
  a { color:#f7931a; }
  #errorBox { display:none; white-space:pre-wrap; background:#3a1414; color:#ff8080; border:1px solid #661f1f; border-radius:6px; padding:12px; margin-bottom:20px; font-family:monospace; font-size:12px; }
</style>
</head>
<body>
<div id="wrap">
  <div id="errorBox"></div>
  <h1 id="title">Merit received over time \u2014 ${displayName}</h1>
  <div class="meta">
    Total received: <span id="totalReceived"></span>
    &nbsp;\u2014 sources: <a href="https://loyce.club/Merit/history/${userId}.html" target="_blank" rel="noopener">loyce.club</a>
    + <a href="https://bitcointalk.org/index.php?action=merit;u=${userId}" target="_blank" rel="noopener">bitcointalk.org merit page</a>, merged &amp; de-duplicated
  </div>
  <details class="diag" id="diagBox"><summary>Diagnostics (click to expand)</summary><div id="diagContent"></div></details>
  <div class="toggle" id="modeToggle"></div>
  <div class="chart-box">
    <div class="total-badge" id="totalBadge"></div>
    <div id="perEvent"></div>
  </div>
</div>
<script>
  window.onerror = function (msg, src, line, col, err) {
    const box = document.getElementById('errorBox');
    box.style.display = 'block';
    box.textContent = 'Script error: ' + msg + ' (line ' + line + ')' + (err && err.stack ? '\\n' + err.stack : '');
    return false;
  };

  // Local copy: this inline script runs in a separate document from the
  // outer Tampermonkey script, so it needs its own escaping helper. Used on
  // every diagnostics value below (even ones this script itself computed)
  // since it's cheap and one less thing to have to re-verify later if the
  // diagnostics table grows.
  function escapeHtml(str) {
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  try {
  const data = ${dataJson};
  document.getElementById('totalReceived').textContent = data.receivedTotal;

  if (data.diagnostics) {
    const d = data.diagnostics;
    const loyceMismatch = d.loyceClaimedTotal > 0 && d.loyceReceivedSum !== d.loyceClaimedTotal;
    const rows = [
      ['loyce.club \u2014 candidate lines found', d.loyceCandidateLines],
      ['loyce.club \u2014 lines successfully parsed (sent + received)', d.loyceParsedLines],
      ['loyce.club \u2014 received entries parsed', d.loyceReceivedCount],
      ['loyce.club \u2014 our sum of received amounts', d.loyceReceivedSum],
      ['loyce.club\\'s OWN running total (\u03a3, independent check)', d.loyceClaimedTotal],
      ['loyce.club \u2014 last date it has for this user', d.loyceLastDate],
      ['bitcointalk.org merit page \u2014 candidate lines found (Received section)', d.btCandidateLines],
      ['bitcointalk.org merit page \u2014 received entries parsed', d.btReceivedCount],
      ['bitcointalk.org merit page \u2014 sum of those entries', d.btReceivedSum],
      ['cutoff \u2014 only bitcointalk.org entries strictly after this date are kept', d.cutoffDate],
      ['merge \u2014 new entries appended (after cutoff)', d.appended],
      ['merge \u2014 entries discarded (on/before loyce.club\\'s last date)', d.discarded],
      ['FINAL \u2014 computed total across merged data', d.computedSum]
    ];
    let html = '<table>' + rows.map(r => '<tr><td>' + escapeHtml(r[0]) + '</td><td>' + escapeHtml(r[1]) + '</td></tr>').join('') + '</table>';
    if (loyceMismatch) {
      html += '<p class="warn">MISMATCH: our sum of loyce.club received entries (' + d.loyceReceivedSum +
        ') does not match loyce.club\\'s own running total (' + d.loyceClaimedTotal +
        '). That points to a parsing bug on lines loyce.club has but this script is dropping \u2014 ' +
        'please report this.</p>';
    } else if (d.loyceClaimedTotal > 0) {
      html += '<p class="ok">Our parsing of loyce.club matches loyce.club\\'s own running total exactly.</p>';
    }
    if (d.btCandidateLines > d.btReceivedCount) {
      html += '<p class="warn">' + (d.btCandidateLines - d.btReceivedCount) + ' line(s) on the bitcointalk.org ' +
        'merit page looked like entries but failed to fully parse \u2014 check the console (F12).</p>';
    }
    document.getElementById('diagContent').innerHTML = html;
  } else {
    document.getElementById('diagBox').style.display = 'none';
  }

  const SVG_NS = 'http://www.w3.org/2000/svg';
  const MARGIN = { top: 24, right: 10, bottom: 60, left: 40 };
  const CHART_H = 420;
  const POINT_GAP = 36;

  function el(tag, attrs) {
    const e = document.createElementNS(SVG_NS, tag);
    for (const k in attrs) e.setAttribute(k, attrs[k]);
    return e;
  }

  function clearAndAppend(container, svg) {
    container.innerHTML = '';
    container.appendChild(svg);
  }

  function niceMax(v) {
    if (v <= 0) return 1;
    const mag = Math.pow(10, Math.floor(Math.log10(v)));
    const norm = v / mag;
    let step;
    if (norm <= 1) step = 1;
    else if (norm <= 2) step = 2;
    else if (norm <= 5) step = 5;
    else step = 10;
    return step * mag;
  }

  function renderLineChart(container, labels, values, tooltips) {
    const n = Math.max(values.length, 1);
    const width = Math.max(n * POINT_GAP + MARGIN.left + MARGIN.right, 300);
    const height = CHART_H;
    const innerW = width - MARGIN.left - MARGIN.right;
    const innerH = height - MARGIN.top - MARGIN.bottom;
    const maxVal = niceMax(Math.max(1, ...values));

    const svg = el('svg', { width, height, viewBox: '0 0 ' + width + ' ' + height });
    const g = el('g', { transform: 'translate(' + MARGIN.left + ',' + MARGIN.top + ')' });
    svg.appendChild(g);

    const gridCount = 5;
    for (let i = 0; i <= gridCount; i++) {
      const y = innerH - (innerH * i) / gridCount;
      const val = Math.round((maxVal * i) / gridCount);
      g.appendChild(el('line', { class: 'grid-line', x1: 0, x2: innerW, y1: y, y2: y }));
      const t = el('text', { class: 'axis-label', x: -6, y: y + 3, 'text-anchor': 'end' });
      t.textContent = val;
      g.appendChild(t);
    }

    const points = values.map((v, i) => {
      const x = i * POINT_GAP + POINT_GAP / 2;
      const y = innerH - (v / maxVal) * innerH;
      return [x, y];
    });

    if (points.length) {
      const linePath = 'M ' + points.map(p => p[0] + ' ' + p[1]).join(' L ');
      const areaPath = linePath + ' L ' + points[points.length - 1][0] + ' ' + innerH + ' L ' + points[0][0] + ' ' + innerH + ' Z';
      g.appendChild(el('path', { class: 'line-area', d: areaPath }));
      g.appendChild(el('path', { class: 'line-path', d: linePath }));
    }

    points.forEach(([x, y], i) => {
      const dot = el('circle', { class: 'dot', cx: x, cy: y, r: 3 });
      const title = el('title', {});
      title.textContent = labels[i] + ': ' + values[i] + ' merit' + (values[i] === 1 ? '' : 's') + (tooltips && tooltips[i] ? ' (' + tooltips[i] + ')' : '');
      dot.appendChild(title);
      g.appendChild(dot);
    });

    values.forEach((v, i) => {
      const [x, y] = points[i];
      const text = String(v);
      const labelW = Math.max(text.length * 7 + 10, 18);
      const labelH = 16;
      let labelY = y - 10 - labelH;
      if (labelY < 0) labelY = y + 10;
      const labelX = x - labelW / 2;
      g.appendChild(el('rect', { class: 'value-label-bg', x: labelX, y: labelY, width: labelW, height: labelH, rx: 3 }));
      const t = el('text', { class: 'value-label-text', x, y: labelY + labelH - 4, 'text-anchor': 'middle' });
      t.textContent = text;
      g.appendChild(t);
    });

    appendXLabels(g, labels, innerH, n);
    clearAndAppend(container, svg);
  }

  function appendXLabels(g, labels, innerH, n) {
    const step = Math.max(1, Math.ceil(n / 25));
    labels.forEach((lab, i) => {
      if (i % step !== 0) return;
      const x = i * POINT_GAP + POINT_GAP / 2;
      const t = el('text', {
        class: 'axis-label', x, y: innerH + 14,
        'text-anchor': 'end', transform: 'rotate(-60 ' + x + ' ' + (innerH + 14) + ')'
      });
      t.textContent = lab;
      g.appendChild(t);
    });
  }

  function renderThreadList(container, threads) {
    container.innerHTML = '';
    const SHOW_LIMIT = 15;
    const maxTotal = threads.length ? threads[0].total : 0; // already sorted descending
    let expanded = false;

    const list = document.createElement('div');

    function draw() {
      list.innerHTML = '';
      const toShow = expanded ? threads : threads.slice(0, SHOW_LIMIT);
      toShow.forEach((t, i) => {
        const row = document.createElement('div');
        row.className = 'thread-row';
        const pct = maxTotal ? Math.round((t.total / maxTotal) * 100) : 0;

        const rank = document.createElement('span');
        rank.className = 'thread-rank';
        rank.textContent = String(i + 1);

        const info = document.createElement('div');
        info.className = 'thread-info';
        const titleEl = document.createElement('p');
        titleEl.className = 'thread-title';
        titleEl.textContent = t.subject; // textContent only \u2014 subject is remote-sourced text
        titleEl.title = t.subject; // full text on hover, since it's truncated visually
        const barBg = document.createElement('div');
        barBg.className = 'thread-bar-bg';
        const barFill = document.createElement('div');
        barFill.className = 'thread-bar-fill';
        barFill.style.width = pct + '%';
        barBg.appendChild(barFill);
        info.appendChild(titleEl);
        info.appendChild(barBg);

        const events = document.createElement('span');
        events.className = 'thread-events';
        events.textContent = t.count + ' event' + (t.count === 1 ? '' : 's');

        const total = document.createElement('span');
        total.className = 'thread-total';
        total.textContent = String(t.total);

        row.appendChild(rank);
        row.appendChild(info);
        row.appendChild(events);
        row.appendChild(total);
        list.appendChild(row);
      });

      if (threads.length > SHOW_LIMIT) {
        const btn = document.createElement('button');
        btn.className = 'thread-showmore';
        btn.textContent = expanded ? 'Show fewer' : 'Show all ' + threads.length + ' threads';
        btn.addEventListener('click', () => { expanded = !expanded; draw(); });
        list.appendChild(btn);
      }
    }

    draw();
    container.appendChild(list);
  }

  const MODE_KEYS = ['daily', 'monthly', 'last120', 'last240', 'last360', 'thread'];
  const modeToggle = document.getElementById('modeToggle');
  const totalBadge = document.getElementById('totalBadge');
  const chartDiv = document.getElementById('perEvent');
  const buttons = {};

  MODE_KEYS.forEach(key => {
    const btn = document.createElement('button');
    btn.textContent = data.modes[key].label;
    btn.addEventListener('click', () => renderMode(key));
    modeToggle.appendChild(btn);
    buttons[key] = btn;
  });

  function renderMode(key) {
    const mode = data.modes[key];
    MODE_KEYS.forEach(k => buttons[k].classList.toggle('active', k === key));
    document.getElementById('totalReceived').textContent = mode.total;
    totalBadge.textContent = 'Total received: ' + mode.total + ' merit' + (mode.total === 1 ? '' : 's');

    if (mode.threads) {
      if (!mode.threads.length) {
        chartDiv.innerHTML = '<p style="color:#777">No received-merit records found.</p>';
        return;
      }
      renderThreadList(chartDiv, mode.threads);
      return;
    }

    if (!mode.labels.length) {
      chartDiv.innerHTML = '<p style="color:#777">No received-merit records in this view.</p>';
      return;
    }
    renderLineChart(chartDiv, mode.labels, mode.perEvent, mode.tooltips);
  }

  renderMode('daily');
  } catch (e) {
    const box = document.getElementById('errorBox');
    box.style.display = 'block';
    box.textContent = 'Render error: ' + e.message + '\\n' + (e.stack || '');
  }
<\/script>
</body>
</html>`;
  }

  // ---------- init ----------

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Plot Merit History\u2026', onButtonClick);
  }
  createButton();
})();


Quote from: satscraper

▄▄███████████████████▄▄
▄███████████████████████▄
███████████████████▄█████
█████████████████████████
███████████▀█████████████
█████████▀███▀██████▀████
██████████████████▄██████
█████████▄▄█▄▄███████████
██████████▄▄▄████████████
███████████████████▀█████
████████████████▀▀███████
▀███████████████████████▀
▀▀███████████████████▀▀
 
 EARNBET 
██
██
██
██
██
██
██
██
██
██
██
██
██
███████▄▄███████████
████▄██████████████████
██▄▀▀███████████████▀▀███
█▄████████████████████████
▄▄████████▀▀▀▀▀████████▄▄██
███████████████████████████
█████████▌████▀████████████
███████████████████████████
▀▀███████▄▄▄▄▄█████████▀▀██
█▀█████████████████████▀██
██▀▄▄███████████████▄▄███
████▀██████████████████
███████▀▀███████████
██
██
██
██
██
██
██
██
██
██
██
██
██


▄▄▄
▄▄▄███████▐███▌███████▄▄▄
█████████████████████████
▀████▄▄▄███████▄▄▄████▀
█████████████████████
▐███████████████████▌
███████████████████
███████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

 King of The Castle 
 $200,000 in prizes
██
██
██
██
██
██
██
██
██
██
██
██
██

 62.5% 

 
RAKEBACK
BONUS
Pages: [1]
  Print  
 
Jump to:  

Powered by MySQL Powered by PHP Powered by SMF 1.1.19 | SMF © 2006-2009, Simple Machines Valid XHTML 1.0! Valid CSS!