HackerNews: Sort by Comments/Points/Time

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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