HackerNews: Sort by Comments/Points/Time

Adds button to sort by comments, points, or time for HackerNews

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         HackerNews: Sort by Comments/Points/Time
// @namespace    hn-client-sort
// @version      1.4.0
// @description  Adds button to sort by comments, points, or time for HackerNews
// @match        https://news.ycombinator.com/
// @match        https://news.ycombinator.com/news
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const qs = (sel, root = document) => root.querySelector(sel);
  const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  
  // Idempotency: prevent duplicate bars if script were to rerun
  document.querySelectorAll("#hn-sort-bar").forEach((el) => el.remove());

  // Find first listing row and infer its table (note: HN markup may vary)
  const firstAthing = qs("tr.athing");
  if (!firstAthing) return;

  // Listing pages have a subtext row right after athing containing td.subtext
  const maybeMeta = firstAthing.nextElementSibling;
  if (!maybeMeta || !qs("td.subtext", maybeMeta)) return;

  const itemTable = firstAthing.closest("table");
  if (!itemTable) return;

  const tbody = itemTable.tBodies?.[0] || itemTable;

  // Keep the "More" row at bottom.
  const moreRow = qs("a.morelink", itemTable)?.closest("tr") || null;

  const intFromText = (txt) => {
    const m = String(txt || "").match(/-?\d+/);
    return m ? parseInt(m[0], 10) : 0;
  };

  function parsePoints(subtextTd) {
    const score = qs("span.score", subtextTd); // "53 points"
    return score ? intFromText(score.textContent) : 0;
  }

  function parseComments(subtextTd) {
    // Usually the last item?id=... link is the comments/discuss link:
    // e.g. ... | <a href="item?id=47127986">6 comments</a>
    const links = qsa('a[href^="item?id="]', subtextTd);
    const last = links.length ? links[links.length - 1] : null;
    if (!last) return 0;

    const t = last.textContent.replace(/\u00a0/g, " ").trim().toLowerCase();
    if (t.includes("discuss")) return 0;
    return intFromText(t);
  }

  function parseUnixTime(subtextTd) {
    // e.g. <span class="age" title="2026-02-23T20:05:15 1771877115">
    const age = qs("span.age", subtextTd);
    const title = age?.getAttribute("title") || "";
    const parts = title.trim().split(/\s+/);

    if (parts.length >= 2) {
      const unix = parseInt(parts[1], 10);
      if (Number.isFinite(unix)) return unix;
    }

    // Fallback to ISO
    if (parts.length >= 1 && parts[0]) {
      const iso = parts[0];
      const hasTZ = /([zZ]|[+-]\d\d:\d\d)$/.test(iso);
      const ms = Date.parse(hasTZ ? iso : iso + "Z");
      if (!Number.isNaN(ms)) return Math.floor(ms / 1000);
    }

    return 0;
  }

  function collectItems() {
    const athingRows = qsa("tr.athing", itemTable);

    return athingRows.map((athing, idx) => {
      const metaRow = athing.nextElementSibling;      // row containing td.subtext
      const spacerRow = metaRow?.nextElementSibling;  // tr.spacer (optional)
      const subtextTd = metaRow ? qs("td.subtext", metaRow) : null;

      return {
        idx,
        athing,
        metaRow,
        spacerRow: spacerRow && spacerRow.classList.contains("spacer") ? spacerRow : null,
        points: subtextTd ? parsePoints(subtextTd) : 0,
        comments: subtextTd ? parseComments(subtextTd) : 0,
        time: subtextTd ? parseUnixTime(subtextTd) : 0,
      };
    });
  }

  function removeGroup(g) {
    [g.athing, g.metaRow, g.spacerRow].forEach((row) => {
      if (row && row.parentNode === tbody) tbody.removeChild(row);
    });
  }

  function insertGroup(g) {
    const rows = [g.athing, g.metaRow, g.spacerRow].filter(Boolean);
    for (const row of rows) {
      if (moreRow && moreRow.parentNode === tbody) tbody.insertBefore(row, moreRow);
      else tbody.appendChild(row);
    }
  }

  function renumberRanks(groups) {
    groups.forEach((g, i) => {
      const rank = qs("span.rank", g.athing);
      if (rank) rank.textContent = `${i + 1}.`;
    });
  }

  // --- Sort state / toggling ---
  const DIR = { DESC: -1, ASC: 1 };
  const dirByKey = { comments: DIR.DESC, points: DIR.DESC, time: DIR.DESC }; // default arrows ↓
  let activeKey = null;

  function sortBy(key) {
    // Toggle if same key; otherwise reset to DESC on first click for that key.
    if (activeKey === key) {
      dirByKey[key] = (dirByKey[key] === DIR.DESC) ? DIR.ASC : DIR.DESC;
    } else {
      activeKey = key;
      dirByKey[key] = DIR.DESC;
    }

    updateButtonLabels();

    const groups = collectItems();
    const dir = dirByKey[key];

    // Numeric sort; stable tie-break on original order.
    groups.sort((a, b) => {
      const diff = (a[key] - b[key]) * dir; // ASC: a-b, DESC: b-a (because dir=-1)
      return diff !== 0 ? diff : (a.idx - b.idx);
    });

    groups.forEach(removeGroup);
    groups.forEach(insertGroup);
    renumberRanks(groups);
  }

  // --- UI ---
  const buttons = {}; // key -> button element
  const baseLabel = { comments: "Comments", points: "Points", time: "Time" };

  function arrowFor(key) {
    return dirByKey[key] === DIR.DESC ? "↓" : "↑";
  }

  function updateButtonLabels() {
    for (const key of Object.keys(buttons)) {
      const isActive = (key === activeKey);
      // Always show direction arrow; highlight active one subtly.
      buttons[key].textContent = `${baseLabel[key]} ${arrowFor(key)}`;
      buttons[key].style.fontWeight = isActive ? "bold" : "normal";
      buttons[key].style.textDecoration = isActive ? "underline" : "none";
    }
  }

  function makeBtn(key) {
    const b = document.createElement("button");
    b.type = "button";
    b.addEventListener("click", () => sortBy(key));

    // HN-ish styling
    b.style.cssText = [
      "font: inherit",
      "font-size: 10px",
      "line-height: 10px",
      "padding: 1px 6px",
      "margin: 0 4px",
      "border: 1px solid #cfcfcf",
      "background: #f6f6ef",
      "color: #000",
      "border-radius: 2px",
      "cursor: pointer",
    ].join(";");

    return b;
  }

  const bar = document.createElement("div");
  bar.id = "hn-sort-bar";
  bar.style.cssText = [
    "margin: 6px 0 8px 0",
    "text-align: right",
    "font-size: 10px",
    "line-height: 10px",
    "color: #828282", // matches HN subtext-ish tone
  ].join(";");

  const label = document.createElement("span");
  label.textContent = "Sort: ";
  label.style.cssText = "margin-right: 4px;";
  bar.appendChild(label);

  buttons.comments = makeBtn("comments");
  buttons.points = makeBtn("points");
  buttons.time = makeBtn("time");

  bar.appendChild(buttons.comments);
  bar.appendChild(buttons.points);
  bar.appendChild(buttons.time);

  updateButtonLabels();

  // Insert centered bar above the listing table
  itemTable.parentNode.insertBefore(bar, itemTable);
})();