Skip to content

Commit 23e1f0e

Browse files
fix(tag-breakdown): cache on stages
1 parent 51cefca commit 23e1f0e

7 files changed

Lines changed: 214 additions & 50 deletions

File tree

frontend/app.js

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ const state = {
5050
osmAvatars: new Map(),
5151
editorStats: null,
5252
};
53-
state.userEditors = new Map();
5453

5554
function fetchOsmAvatar(uid) {
5655
if (uid == null) return Promise.resolve(null);
@@ -179,7 +178,7 @@ function sumTagKey(ts, k) {
179178

180179
function transform(row) {
181180
const ts = row.tag_stats || {};
182-
const n = (v) => v || 0; // coalesce so a field the API omits never renders as NaN
181+
const n = (v) => v || 0;
183182
const b = sumTagKey(ts, "building"),
184183
h = sumTagKey(ts, "highway");
185184
const lu = sumTagKey(ts, "landuse"),
@@ -232,7 +231,7 @@ function renderChips() {
232231
chipsEl.innerHTML = state.hashtags
233232
.map(
234233
(h, i) =>
235-
`<span class="chip">#${escapeHtml(h)}<button type="button" data-i="${i}" aria-label="Remove ${escapeHtml(h)}"><i data-lucide="x"></i></button></span>`
234+
`<span class="chip"><span class="chip-label">#${escapeHtml(h)}</span><button type="button" data-i="${i}" aria-label="Remove ${escapeHtml(h)}"><i data-lucide="x"></i></button></span>`
236235
)
237236
.join("");
238237
chipsEl.querySelectorAll("button").forEach(
@@ -590,7 +589,7 @@ async function runQuery() {
590589
setOverviewLoading();
591590
showLoading(); // skeleton the table now; the leaderboard fetch only starts after summary resolves
592591
$("#podium")?.closest("section")?.style.setProperty("display", "");
593-
$("#podium").innerHTML = "";
592+
setPodiumLoading();
594593
if (typeof setChartsLoading === "function") setChartsLoading();
595594
const base = windowParams();
596595
const alive = () => state.query === ctrl;
@@ -600,8 +599,7 @@ async function runQuery() {
600599
return p;
601600
};
602601
setBusy(true);
603-
// Clear the button spinner once the primary content (summary + leaderboard) is up; secondary sections
604-
// keep loading behind their own inline status.
602+
// The spinner and its stage label stay up across the whole fetch sequence; released in finally.
605603
let _released = false;
606604
const releasePrimary = () => { if (!_released) { _released = true; setBusy(false); } };
607605
try {
@@ -614,7 +612,6 @@ async function runQuery() {
614612

615613
setStatus("Fetching leaderboard…");
616614
await loadLeaderboardPage(true);
617-
releasePrimary();
618615
if (!alive()) return;
619616

620617
// Each secondary section is isolated so one failure can't blank the others or the primary content.
@@ -727,7 +724,6 @@ function onSectionError(section, err, ctrl) {
727724
console.warn(`OSMSG ${section} fetch failed:`, err);
728725
}
729726

730-
// Run one secondary section in isolation so its failure can't abort the query or blank another section.
731727
async function runSection(name, ctrl, fn) {
732728
try {
733729
await fn();
@@ -820,7 +816,7 @@ const OV_CELLS_TOTALS = [
820816
["Created", "created", "plus-square", "ov-add", "Elements created (nodes + ways + relations)"],
821817
["Modified", "modified", "edit-3", "ov-mod", "Elements modified"],
822818
["Deleted", "deleted", "trash-2", "ov-del", "Elements deleted"],
823-
["Mappers", "mappers", "users", "", "Distinct contributors"],
819+
["Contributors", "mappers", "users", "", "Distinct contributors"],
824820
["Changesets", "changesets", "git-commit-horizontal", "", "Number of changesets"],
825821
];
826822
const OV_CELLS = [
@@ -963,6 +959,18 @@ function renderOverviewDetails() {
963959
refreshIcons($("#ov-details"));
964960
}
965961

962+
function setPodiumLoading() {
963+
const el = $("#podium");
964+
if (!el) return;
965+
el.innerHTML = Array.from({ length: 3 }, (_, i) => `
966+
<div class="pod pod-${i + 1}">
967+
<span class="pod-rank">${i + 1}</span>
968+
<span class="pod-avatar skeleton" style="border:0"></span>
969+
<span class="pod-name"><span class="skeleton" style="display:inline-block;width:82px;height:12px"></span></span>
970+
<span class="pod-score-wrap"><span class="skeleton" style="display:inline-block;width:60px;height:18px;margin-top:4px"></span></span>
971+
</div>`).join("");
972+
}
973+
966974
function renderPodium() {
967975
const top3 = state.podium.slice(0, 3);
968976
const el = $("#podium");
@@ -1012,6 +1020,9 @@ function renderPodium() {
10121020
</div>`;
10131021

10141022
applyAvatar(div.querySelector(".pod-avatar"), r.uid, initials(r.username));
1023+
div.tabIndex = 0;
1024+
div.setAttribute("role", "button");
1025+
div.setAttribute("aria-label", `${r.username}, rank ${place}`);
10151026
div.addEventListener("click", () => openUserModal(r.username));
10161027
div.addEventListener("keydown", (e) => {
10171028
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openUserModal(r.username); }
@@ -1133,6 +1144,26 @@ const cellsHtml = (cells, r) =>
11331144
})
11341145
.join("");
11351146

1147+
let _modalReturnFocus = null;
1148+
// Keep Tab inside an open modal and hand focus back to the trigger on close.
1149+
function trapModalFocus(modal) {
1150+
_modalReturnFocus = document.activeElement;
1151+
modal._trap = (e) => {
1152+
if (e.key !== "Tab") return;
1153+
const f = modal.querySelectorAll('a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])');
1154+
if (!f.length) return;
1155+
const first = f[0], last = f[f.length - 1];
1156+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
1157+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
1158+
};
1159+
modal.addEventListener("keydown", modal._trap);
1160+
}
1161+
function releaseModalFocus(modal) {
1162+
if (modal._trap) { modal.removeEventListener("keydown", modal._trap); modal._trap = null; }
1163+
_modalReturnFocus?.focus?.();
1164+
_modalReturnFocus = null;
1165+
}
1166+
11361167
function openUserModal(username) {
11371168
// Podium holds the global top 3, which may not be on the current leaderboard page: search both.
11381169
const r =
@@ -1201,13 +1232,15 @@ function openUserModal(username) {
12011232
document.body.style.overflow = "hidden";
12021233
refreshIcons(modal);
12031234
$("#user-modal-close").focus();
1235+
trapModalFocus(modal);
12041236
}
12051237

12061238
function closeUserModal() {
12071239
const m = $("#user-modal");
12081240
m.hidden = true;
12091241
m.classList.remove("open");
12101242
document.body.style.overflow = "";
1243+
releaseModalFocus(m);
12111244
}
12121245

12131246
function renderTable() {
@@ -1307,19 +1340,24 @@ $("#search").addEventListener("input", (e) => {
13071340
// Search is server-side across all users -> refetch a fresh batch.
13081341
searchTimer = setTimeout(() => loadLeaderboardPage(false, true), 350);
13091342
});
1310-
$$("th.sortable").forEach(
1311-
(th) => (th.onclick = () => {
1343+
$$("th.sortable").forEach((th) => {
1344+
const doSort = () => {
13121345
if (!state.hashtags.length) return;
13131346
const k = th.dataset.sort;
13141347
if (state.sort.key === k)
13151348
state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
13161349
else { state.sort.key = k; state.sort.dir = k === "username" ? "asc" : "desc"; }
13171350
state.page = 1;
13181351
writeURL();
1319-
// Sorting is server-side across all users -> refetch a fresh batch.
1320-
loadLeaderboardPage(false, true);
1321-
})
1322-
);
1352+
loadLeaderboardPage(false, true); // server-side sort across all users -> refetch a fresh batch
1353+
};
1354+
th.tabIndex = 0;
1355+
th.setAttribute("role", "button");
1356+
th.onclick = doSort;
1357+
th.addEventListener("keydown", (e) => {
1358+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); doSort(); }
1359+
});
1360+
});
13231361

13241362
// Overview tiles read compact (2.3M) by default; clicking a tile flips its numbers to the exact value.
13251363
$("#overview").addEventListener("click", (e) => {
@@ -1581,7 +1619,7 @@ function readURL() {
15811619
if (tags.length)
15821620
state.hashtags = [...new Set(tags.map((t) => t.replace(/^#/, "").toLowerCase()))];
15831621
const ps = parseInt(p.get("size") || "", 10);
1584-
if ([10, 25, 50, 100].includes(ps)) {
1622+
if ([10, 20, 50].includes(ps)) {
15851623
state.pageSize = ps;
15861624
$("#pg-size").value = String(ps);
15871625
}
@@ -1594,7 +1632,7 @@ function writeURL() {
15941632
p.set("end", isoUTC(state.customEnd));
15951633
}
15961634
state.hashtags.forEach((h) => p.append("hashtag", h));
1597-
if (state.pageSize !== 25) p.set("size", String(state.pageSize));
1635+
if (state.pageSize !== 10) p.set("size", String(state.pageSize));
15981636
history.replaceState(null, "", `${location.pathname}?${p}`);
15991637
}
16001638

@@ -1663,13 +1701,15 @@ function openMethodology() {
16631701
document.body.style.overflow = "hidden";
16641702
refreshIcons(mthModal);
16651703
$("#methodology-close")?.focus();
1704+
trapModalFocus(mthModal);
16661705
if (location.hash !== "#methodology") history.replaceState(null, "", "#methodology");
16671706
}
16681707
function closeMethodology() {
16691708
if (!mthModal) return;
16701709
mthModal.hidden = true;
16711710
mthModal.classList.remove("open");
16721711
document.body.style.overflow = "";
1712+
releaseModalFocus(mthModal);
16731713
if (location.hash === "#methodology") history.replaceState(null, "", location.pathname + location.search);
16741714
}
16751715
$("#methodology-link")?.addEventListener("click", (e) => { e.preventDefault(); openMethodology(); });
@@ -1680,15 +1720,8 @@ window.addEventListener("hashchange", () => { if (location.hash === "#methodolog
16801720
if (location.hash === "#methodology") openMethodology();
16811721

16821722
function boot() {
1683-
const swaggerURL = `${API_BASE}/docs/swagger`;
1684-
const apiLink = $("#api-link");
1685-
if (apiLink) {
1686-
apiLink.href = swaggerURL;
1687-
const host = $("#api-host");
1688-
if (host) host.textContent = new URL(API_BASE).host;
1689-
}
16901723
const apiDocsLink = $("#api-docs-link");
1691-
if (apiDocsLink) apiDocsLink.href = swaggerURL;
1724+
if (apiDocsLink) apiDocsLink.href = `${API_BASE}/docs/swagger`;
16921725
readURL();
16931726
renderChips();
16941727
renderRecentSearches();

frontend/charts.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,20 @@ let _htPage = 0, _htLen = -1, _htMetric = "users"; // "users" (primary) | "edits
161161
// A spinner shown in both chart cards while their data loads.
162162
function setChartsLoading() {
163163
_ensureChartsSection();
164-
const spinner = `<div class="loading-spin"><span class="spin"></span> Loading…</div>`;
164+
const rows = Array.from({ length: 5 }, (_, i) => `
165+
<div class="ht-row">
166+
<div class="ht-head"><span class="skeleton" style="width:${90 - i * 9}px;height:11px"></span><span class="skeleton" style="width:32px;height:11px"></span></div>
167+
<div class="ht-bar"><div class="skeleton" style="width:${72 - i * 12}%;height:100%;border-radius:inherit"></div></div>
168+
</div>`).join("");
165169
const ec = document.getElementById("editor-chart-card");
166170
const ew = ec && ec.querySelector(".osmsg-chart-canvas-wrap");
167171
const el = document.getElementById("editor-bar-legend");
168-
if (ec && ew) { ec.hidden = false; if (el) el.innerHTML = ""; ew.style.height = "auto"; ew.innerHTML = spinner; }
172+
if (ec && ew) { ec.hidden = false; if (el) el.innerHTML = ""; ew.style.height = "auto"; ew.innerHTML = rows; }
169173
const hc = document.getElementById("hashtag-chart-card");
170174
const hw = document.getElementById("hashtag-canvas-wrap");
171175
const ht = document.getElementById("hashtag-stat-total");
172176
const hn = document.getElementById("hashtag-stat-count");
173-
if (hc && hw) { hc.hidden = false; if (ht) ht.textContent = ""; if (hn) hn.textContent = ""; hw.style.height = "auto"; hw.innerHTML = spinner; }
177+
if (hc && hw) { hc.hidden = false; if (ht) ht.textContent = ""; if (hn) hn.textContent = ""; hw.style.height = "auto"; hw.innerHTML = rows; }
174178
}
175179

176180
// Render a paginated list (rows + a prev/next footer) into `wrap`; wires the footer buttons to onPage.

frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
<div class="windowbar">
9999
<div class="wb-item" id="wb-window"><i data-lucide="calendar-range" class="ico-sm"></i><span
100100
class="k">Window</span><span class="v" id="wb-window-text"></span></div>
101-
<div class="wb-item" id="last-updated" aria-live="polite" role="button" tabindex="0"
101+
<div class="wb-item" id="last-updated" role="button" tabindex="0"
102102
title="Click to refresh" style="cursor:pointer"><i data-lucide="refresh-cw" class="ico-sm"></i><span
103103
class="k">Updated</span><span class="v" id="last-updated-text">never</span></div>
104104
<div class="wb-right">

0 commit comments

Comments
 (0)