AtCoder Problems Problem List Sort Marker

AtCoder Problems の Problem List の現在のソート列に ▲ または ▼ を表示します。

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         AtCoder Problems Problem List Sort Marker
// @namespace    http://tampermonkey.net/
// @version      2026.07.07
// @description  AtCoder Problems の Problem List の現在のソート列に ▲ または ▼ を表示します。
// @author       Not_Leonian
// @match        https://kenkoooo.com/atcoder*
// @icon         https://www.google.com/s2/favicons?domain=https://kenkoooo.com/atcoder
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(() => {
	"use strict";

	const ASC_MARK = "▲";
	const DESC_MARK = "▼";

	const MARKER_CLASS = "acp-sort-marker";
	const ACTIVE_TH_CLASS = "acp-sort-active";
	const STYLE_ID = "acp-sort-marker-style";

	const FIELD_TO_HEADER = new Map([
		["contestDate", "Date"],
		["title", "Problem"],
		["id", "Problem"],
		["mergedProblem", "Problem"],
		["contest", "Contest"],
		["contestTitle", "Contest"],
		["status", "Result"],
		["lastAcceptedDate", "Last AC Date"],
		["solverCount", "Solvers"],
		["point", "Point"],
		["problemModel", "Difficulty"],
		["solveProbability", "Solve Prob"],
		["timeEstimation", "Time"],
		["executionTime", "Fastest"],
		["fastestUserId", "Fastest"],
		["codeLength", "Shortest"],
		["shortestUserId", "Shortest"],
		["firstUserId", "First"],
	]);

	const VALID_SORT_FIELDS = new Set([
		...FIELD_TO_HEADER.keys(),
		"id",
		"contestTitle",
		"mergedProblem",
		"shortestUserId",
		"fastestUserId",
	]);

	const PROBLEM_LIST_SIGNATURE_FIELDS = [
		"contestDate",
		"title",
		"contest",
		"point",
		"problemModel",
	];
	const PROBLEM_LIST_SIGNATURE_HEADERS = [
		"Date",
		"Problem",
		"Contest",
		"Point",
		"Difficulty",
	];
	const MIN_SIGNATURE_MATCHES = 3;

	function injectStyle() {
		if (document.getElementById(STYLE_ID)) return;

		const styleParent = document.head || document.documentElement;
		if (!styleParent) return;

		const style = document.createElement("style");
		style.id = STYLE_ID;
		style.textContent = `
		th.${ACTIVE_TH_CLASS} {
			position: relative !important;
			padding-inline-end: 1.35em !important;
		}

		th.${ACTIVE_TH_CLASS} > .${MARKER_CLASS} {
			position: absolute;
			inset-inline-end: 0.35em;
			top: 50%;
			z-index: 1;
			transform: translateY(-50%);
			font-size: 0.85em;
			font-weight: 700;
			line-height: 1;
			pointer-events: none;
			user-select: none;
		}
	`;
		styleParent.appendChild(style);
	}

	function normalizePath(path) {
		const normalized = path.replace(/\/+$/g, "");
		return normalized || "/";
	}

	function removeBasePath(pathname) {
		if (pathname === "/atcoder") return "/";
		if (pathname.startsWith("/atcoder/"))
			return pathname.slice("/atcoder".length);
		return pathname;
	}

	function getRouteUrl() {
		const hash = location.hash.startsWith("#")
			? location.hash.slice(1)
			: location.hash;
		if (!hash) return null;

		try {
			return new URL(hash, location.origin);
		} catch {
			return null;
		}
	}

	function isListPath(path) {
		const normalized = normalizePath(path);
		return normalized === "/list" || normalized.startsWith("/list/");
	}

	function isProblemListPage() {
		const routeUrl = getRouteUrl();
		if (routeUrl) return isListPath(routeUrl.pathname);

		const browserPath = removeBasePath(location.pathname || "/");
		return isListPath(browserPath);
	}

	function getRouteSearchParams() {
		const routeUrl = getRouteUrl();
		if (routeUrl) return routeUrl.searchParams;

		const questionIndex = location.hash.indexOf("?");
		if (questionIndex >= 0) {
			return new URLSearchParams(location.hash.slice(questionIndex + 1));
		}

		return new URLSearchParams(location.search);
	}

	function getSortState() {
		const params = getRouteSearchParams();
		const rawField = params.get("sortBy");
		const field =
			rawField && VALID_SORT_FIELDS.has(rawField) ? rawField : "contestDate";
		const order = params.get("sortOrder") === "asc" ? "asc" : "desc";
		return { field, order };
	}

	function escapeCssAttributeValue(value) {
		if (window.CSS && typeof window.CSS.escape === "function") {
			return window.CSS.escape(value);
		}
		return value.replace(/["\\]/g, "\\$&");
	}

	function normalizeHeaderText(text) {
		return text
			.replace(/[▲▼△▽▴▾↑↓]/g, "")
			.replace(/\s+/g, " ")
			.trim();
	}

	function isVisibleElement(element) {
		const style = getComputedStyle(element);
		return style.display !== "none" && style.visibility !== "hidden";
	}

	function getHeaderScope(element) {
		return (
			element.closest(".react-bs-table-container") ||
			element.closest(".react-bs-container-header") ||
			element.closest("thead") ||
			document
		);
	}

	function getFirstHeaderCells(scope) {
		const row = scope.querySelector("thead tr") || scope.querySelector("tr");
		if (!row) return [];
		return Array.from(row.querySelectorAll("th")).filter(isVisibleElement);
	}

	function countMatches(values, candidates) {
		let count = 0;
		for (const candidate of candidates) {
			if (values.has(candidate)) count += 1;
		}
		return count;
	}

	function hasProblemListSignature(values, candidates, anchors) {
		const matches = countMatches(values, candidates);
		if (matches < MIN_SIGNATURE_MATCHES) return false;

		for (const anchor of anchors) {
			if (values.has(anchor)) return true;
		}

		return false;
	}

	function scopeLooksLikeProblemList(scope) {
		const dataFields = new Set(
			Array.from(scope.querySelectorAll("th[data-field]"))
				.filter(isVisibleElement)
				.map((th) => th.getAttribute("data-field") || ""),
		);

		if (
			hasProblemListSignature(dataFields, PROBLEM_LIST_SIGNATURE_FIELDS, [
				"contestDate",
				"title",
			])
		) {
			return true;
		}

		const headerTexts = new Set(
			getFirstHeaderCells(scope).map((th) =>
				normalizeHeaderText(th.textContent || ""),
			),
		);
		return hasProblemListSignature(
			headerTexts,
			PROBLEM_LIST_SIGNATURE_HEADERS,
			["Date", "Problem"],
		);
	}

	function getProblemListHeaderScopes() {
		const scopes = [];
		const seen = new Set();
		const headerCells = document.querySelectorAll(
			".react-bs-container-header th, thead th",
		);

		for (const th of headerCells) {
			const scope = getHeaderScope(th);
			if (seen.has(scope)) continue;
			seen.add(scope);
			if (scopeLooksLikeProblemList(scope)) scopes.push(scope);
		}

		return scopes;
	}

	function findHeaderCellByDataField(field) {
		const escapedField = escapeCssAttributeValue(field);
		const selector = `.react-bs-container-header th[data-field="${escapedField}"], thead th[data-field="${escapedField}"]`;
		const candidates = Array.from(document.querySelectorAll(selector)).filter(
			isVisibleElement,
		);

		for (const th of candidates) {
			if (scopeLooksLikeProblemList(getHeaderScope(th))) return th;
		}

		return candidates[0] || null;
	}

	function findHeaderCellByText(field) {
		const expectedHeader = FIELD_TO_HEADER.get(field);
		if (!expectedHeader) return null;

		for (const scope of getProblemListHeaderScopes()) {
			for (const th of getFirstHeaderCells(scope)) {
				if (normalizeHeaderText(th.textContent || "") === expectedHeader)
					return th;
			}
		}

		return null;
	}

	function findTargetHeaderCell(field) {
		return findHeaderCellByDataField(field) || findHeaderCellByText(field);
	}

	function clearMarkers() {
		for (const marker of document.querySelectorAll(`.${MARKER_CLASS}`)) {
			marker.remove();
		}

		for (const th of document.querySelectorAll(`th.${ACTIVE_TH_CLASS}`)) {
			th.classList.remove(ACTIVE_TH_CLASS);
		}
	}

	function isMarkerAlreadyCorrect(targetTh, mark) {
		const markers = Array.from(document.querySelectorAll(`.${MARKER_CLASS}`));
		const activeHeaders = Array.from(
			document.querySelectorAll(`th.${ACTIVE_TH_CLASS}`),
		);
		return (
			markers.length === 1 &&
			activeHeaders.length === 1 &&
			markers[0].parentElement === targetTh &&
			markers[0].textContent === mark &&
			activeHeaders[0] === targetTh
		);
	}

	let isApplying = false;

	function applyMarker() {
		if (isApplying) return;
		isApplying = true;

		try {
			injectStyle();

			if (!isProblemListPage()) {
				clearMarkers();
				return;
			}

			const { field, order } = getSortState();
			const targetTh = findTargetHeaderCell(field);
			if (!targetTh) {
				clearMarkers();
				return;
			}

			const mark = order === "asc" ? ASC_MARK : DESC_MARK;
			if (isMarkerAlreadyCorrect(targetTh, mark)) return;

			clearMarkers();

			const marker = document.createElement("span");
			marker.className = MARKER_CLASS;
			marker.textContent = mark;
			marker.setAttribute("aria-hidden", "true");
			marker.setAttribute("data-atcoder-problems-sort-marker", "true");

			targetTh.classList.add(ACTIVE_TH_CLASS);
			targetTh.appendChild(marker);
		} finally {
			setTimeout(() => {
				isApplying = false;
			}, 0);
		}
	}

	let scheduled = 0;

	function scheduleApply() {
		if (scheduled) cancelAnimationFrame(scheduled);
		scheduled = requestAnimationFrame(() => {
			scheduled = 0;
			applyMarker();
		});
	}

	function hookHistoryMethod(name) {
		const original = history[name];
		if (typeof original !== "function") return;

		try {
			history[name] = function (...args) {
				const result = original.apply(this, args);
				scheduleApply();
				return result;
			};
		} catch {}
	}

	function installStartupRetries() {
		let count = 0;
		const timerId = setInterval(() => {
			count += 1;
			scheduleApply();
			if (count >= 20) clearInterval(timerId);
		}, 500);
	}

	function start() {
		injectStyle();
		hookHistoryMethod("pushState");
		hookHistoryMethod("replaceState");

		window.addEventListener("hashchange", scheduleApply);
		window.addEventListener("popstate", scheduleApply);
		window.addEventListener("load", scheduleApply);

		const observer = new MutationObserver(() => {
			if (!isApplying) scheduleApply();
		});
		observer.observe(document.body, {
			attributes: true,
			attributeFilter: ["class", "data-field", "style"],
			childList: true,
			subtree: true,
		});

		installStartupRetries();
		scheduleApply();
	}

	if (document.body) {
		start();
	} else {
		window.addEventListener("DOMContentLoaded", start, { once: true });
	}
})();