HackerNews: Sort by Comments/Points/Time

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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