feat(web): score heat scale, drop release row expand
ci / web (push) Successful in 1m28s
ci / rust (push) Successful in 2m33s
e2e / e2e (push) Successful in 3m13s

Score cells interpolate red-to-green relative to the scores in the
current deck; the click-to-expand panel only repeated row content.
This commit is contained in:
Miguel Palhas
2026-08-23 11:13:11 +01:00
parent d6345c5305
commit 9f0ccb0416
3 changed files with 79 additions and 88 deletions
+40 -45
View File
@@ -18,7 +18,6 @@ import {
bucketOf,
formatAudio,
formatHdr,
formatPublishDate,
formatResolution,
formatScore,
formatSeeders,
@@ -879,6 +878,28 @@ function releasesMain(): ReleasesView {
}
setStatus(null);
// score colour is relative to this list (issue 111): highest green,
// lowest red, seven interpolated stops between
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const release of releases) {
if (release.score !== null) {
if (release.score < min) {
min = release.score;
}
if (release.score > max) {
max = release.score;
}
}
}
const scoreStop = (score: number | null): number | null => {
if (score === null || min > max) {
return null;
}
// all-equal scores are jointly the best the list offers
return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
};
// the API pre-sorts by bucket then score; splitting preserves order
const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
@@ -890,7 +911,7 @@ function releasesMain(): ReleasesView {
eligible.count.textContent = String(buckets.eligible.length);
if (buckets.eligible.length > 0) {
for (const release of buckets.eligible) {
eligible.rows.append(releaseRow(release, movie, "eligible", actions));
eligible.rows.append(releaseRow(release, movie, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
@@ -907,7 +928,7 @@ function releasesMain(): ReleasesView {
const bucket = collapsed[name];
bucket.section.hidden = false;
for (const release of rows) {
bucket.rows.append(releaseRow(release, movie, name, actions));
bucket.rows.append(releaseRow(release, movie, name, scoreStop, actions));
}
setToggle(bucket, rows.length);
}
@@ -1240,21 +1261,30 @@ interface ReleaseActions {
notify: (text: string, tone?: "fault") => void;
}
/** One classified release: chips lead, the name is secondary (§9.3). */
/** Stops in the score heat scale — keep in step with the --score-* tokens. */
const SCORE_STOPS = 6;
type ScoreStopper = (score: number | null) => number | null;
/**
* One classified release: chips lead, the name is secondary (§9.3). The
* score chip is coloured on the list-relative heat scale (issue 111);
* nothing on the line is clickable — grab is its own control.
*/
function releaseRow(
release: MovieRelease,
movie: LibraryMovie,
bucket: "eligible" | "waived" | "rejected",
scoreStop: ScoreStopper,
actions: ReleaseActions,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("button");
line.type = "button";
const line = document.createElement("div");
line.className = "rel-line";
line.setAttribute("aria-expanded", "false");
const stop = scoreStop(release.score);
const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)],
@@ -1272,9 +1302,9 @@ function releaseRow(
if (value === "—") {
span.classList.add("dim");
}
// the world's non-colour eligible mark rides the leading chip
if (bucket === "eligible" && width === "cw-score") {
span.dataset.verdict = "eligible";
// the heat scale replaces the flat eligible green on this cell
if (width === "cw-score" && stop !== null) {
span.dataset.score = String(stop);
}
}),
);
@@ -1342,41 +1372,6 @@ function releaseRow(
}
item.append(note);
const full = document.createElement("div");
full.className = "rel-full";
full.hidden = true;
line.addEventListener("click", () => {
if (full.childElementCount === 0) {
const raw = document.createElement("p");
raw.className = "rel-raw readout";
raw.textContent = release.name;
full.append(raw);
const meta = document.createElement("p");
meta.className = "rel-meta readout dim";
const published = formatPublishDate(release.publish_date);
// hdr and audio leave the chip line under 40rem; the expand carries them
meta.textContent = [
`indexer ${release.indexer_id}`,
published === null ? null : `published ${published}`,
`hdr ${formatHdr(release.parsed)}`,
`audio ${formatAudio(release.parsed)}`,
`${release.size} bytes`,
]
.filter((part) => part !== null)
.join(" · ");
full.append(meta);
if (bucket === "waived" && waiverOverride(release.rejected_rule) !== null) {
const waiver = document.createElement("p");
waiver.className = "rel-meta readout dim";
waiver.textContent = "waive + grab writes this title's override, then grabs";
full.append(waiver);
}
}
full.hidden = !full.hidden;
line.setAttribute("aria-expanded", String(!full.hidden));
});
item.append(full);
return item;
}