Adds button to sort by comments, points, or time for HackerNews
// ==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);
})();