Every matched name links out to its BGG (or RPGGeek) page

The one outbound link in the whole app was the detail-page crumb, and
it sent RPG items to a /boardgame/ URL — the wrong home for things
that live on RPGGeek. A shared bggUrl(id, type) helper now routes by
type, and the link grew into the places where opening the real page is
the point: every matched name on the Titles catalog (verifying a match
IS opening its page), each Review ballot candidate (a "view ↗" that
stops propagation so checking a candidate doesn't vote for it), and
the version ballot's title links to BGG's own /versions list.

Verified against live data: RPGs route to rpggeek.com, board games to
boardgamegeek.com, 127 outbound links on the Titles page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-06 01:15:43 -04:00
co-authored by Claude Fable 5
parent e9168044f3
commit 2542560d57
3 changed files with 26 additions and 5 deletions
+14 -1
View File
@@ -125,9 +125,22 @@ setInterval(() => refreshBadges().catch(() => {}), 5000);
/* One-line match summary for a titles/photo table row. Empty when the /* One-line match summary for a titles/photo table row. Empty when the
* row has no BGG data yet, so the mobile stacker can hide the cell. */ * row has no BGG data yet, so the mobile stacker can hide the cell. */
/* Canonical outbound URL for a matched thing. RPG items live on RPGGeek
* (same database, different site) — a /boardgame/ URL is the wrong home
* for them. */
function bggUrl(id, type) {
return type === "rpgitem"
? `https://rpggeek.com/rpgitem/${encodeURIComponent(id)}`
: `https://boardgamegeek.com/boardgame/${encodeURIComponent(id)}`;
}
function metaLine(c) { function metaLine(c) {
const name = !c.bgg_name ? "" : c.bgg_id
? `<a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
title="open on ${c.type === "rpgitem" ? "RPGGeek" : "BGG"}">${esc(c.bgg_name)} ↗</a>`
: esc(c.bgg_name);
return [ return [
c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "", name,
c.version_name ? esc(c.version_name) : "", c.version_name ? esc(c.version_name) : "",
c.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : "", c.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : "",
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
+2 -2
View File
@@ -108,8 +108,8 @@ function render(g) {
local ? `<span class="chip open">not on BGG · local</span>` : "", local ? `<span class="chip open">not on BGG · local</span>` : "",
rpg ? `<span class="chip open">RPG · local only</span>` : "", rpg ? `<span class="chip open">RPG · local only</span>` : "",
g.bgg_id g.bgg_id
? `<a href="https://boardgamegeek.com/boardgame/${encodeURIComponent(g.bgg_id)}" ? `<a href="${bggUrl(g.bgg_id, g.type)}" target="_blank" rel="noopener">
target="_blank" rel="noopener">view on BGG ↗</a>` view on ${rpg ? "RPGGeek" : "BGG"} ↗</a>`
: "", : "",
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
+10 -2
View File
@@ -61,7 +61,9 @@ function matchCard(row, idx) {
<kbd>${i + 1}</kbd> ${thumbHtml(c)} <kbd>${i + 1}</kbd> ${thumbHtml(c)}
<span><span class="cname">${esc(c.name)}</span> <span><span class="cname">${esc(c.name)}</span>
<span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")} <span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
· rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span> · rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}
· <a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
title="check this candidate before picking">view ↗</a></span></span>
</li>`).join(""); </li>`).join("");
return ` return `
<section class="card actionable" data-kind="match" data-rowix="${row.row_ix}" <section class="card actionable" data-kind="match" data-rowix="${row.row_ix}"
@@ -104,7 +106,10 @@ function versionCard(row, idx) {
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}"> data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
${shots(row.photos || [])} ${shots(row.photos || [])}
<div class="body"> <div class="body">
<p class="title">${esc(row.bgg_name || row.title_raw)}</p> <p class="title">${esc(row.bgg_name || row.title_raw)}
${row.bgg_id ? `<a class="cmeta" href="${bggUrl(row.bgg_id, row.type)}/versions"
target="_blank" rel="noopener"
title="BGG's own list of every printing">versions on BGG ↗</a>` : ""}</p>
<p class="status">which edition${row.photos && row.photos.length <p class="status">which edition${row.photos && row.photos.length
? ` is the copy in ${esc(row.photos.join(", "))}` ? ` is the copy in ${esc(row.photos.join(", "))}`
: ""}? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p> : ""}? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
@@ -192,6 +197,9 @@ function render() {
if (ACTIVE >= cards.length) ACTIVE = Math.max(0, cards.length - 1); if (ACTIVE >= cards.length) ACTIVE = Math.max(0, cards.length - 1);
highlight(); highlight();
// an outbound "view ↗" inside a pick row must not also cast the vote
m.querySelectorAll("[data-pick] a, [data-pickver] a").forEach(a =>
a.onclick = e => e.stopPropagation());
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => { m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
const card = li.closest(".card"); const card = li.closest(".card");
decide(card, "pick", Number(li.dataset.pick)); decide(card, "pick", Number(li.dataset.pick));