HackerNews: Sort by Comments/Points/Time

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

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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);
})();