c.stupid.cat/js/dev-info.js

323 lines
11 KiB
JavaScript

(function () {
"use strict";
/* Paste your embeddable JSON share URLs here. */
var WAKATIME = {
codingActivity: "https://wakatime.com/share/@doughmination/9dcc5b5c-ed3d-4896-bfa3-87737fa70930.json",
languages: "https://wakatime.com/share/@doughmination/8354e3f8-b458-452b-aa06-839f303d4904.json",
categories: "https://wakatime.com/share/@doughmination/c54fcd4e-91b3-46ab-8e7e-82226491ec0f.json",
editors: "https://wakatime.com/share/@doughmination/38dba24b-d2de-4d50-9b09-83642c01c33e.json",
operatingSystems: "https://wakatime.com/share/@doughmination/a69f00cb-e38e-4de1-aa42-eec71dc6d658.json"
};
/* How many rows to show in each ranked list. */
var MAX_ROWS = 8;
/* ---- JSONP loader ---- */
var seq = 0;
function jsonp(url, timeoutMs) {
return new Promise(function (resolve, reject) {
var cb = "__wakatime_cb_" + (++seq);
var script = document.createElement("script");
var timer = setTimeout(function () { cleanup(); reject(new Error("timed out")); }, timeoutMs || 12000);
function cleanup() {
clearTimeout(timer);
try { delete window[cb]; } catch (e) { window[cb] = undefined; }
if (script.parentNode) script.parentNode.removeChild(script);
}
window[cb] = function (data) { cleanup(); resolve(data); };
script.onerror = function () { cleanup(); reject(new Error("failed to load")); };
var sep = url.indexOf("?") === -1 ? "?" : "&";
script.src = url + sep + "callback=" + cb;
document.head.appendChild(script);
});
}
/* ---- helpers ---- */
function fmt(seconds) {
seconds = Math.max(0, Math.round(seconds || 0));
var h = Math.floor(seconds / 3600);
var m = Math.round((seconds % 3600) / 60);
if (h && m) return h + " hr" + (h > 1 ? "s" : "") + " " + m + " min" + (m > 1 ? "s" : "");
if (h) return h + " hr" + (h > 1 ? "s" : "");
return m + " min" + (m === 1 ? "" : "s");
}
function pctLabel(p) {
return (p < 10 ? Math.round(p * 10) / 10 : Math.round(p)) + "%";
}
/* Value shown at the end of a bar: real time when the embed provides it,
* otherwise the percentage share. */
function valueLabel(d, hasSeconds) {
if (hasSeconds && (d.total_seconds || 0) > 0) return d.text || fmt(d.total_seconds);
if (typeof d.percent === "number") return pctLabel(d.percent);
if (d.text) return d.text;
return fmt(d.total_seconds);
}
function el(id) { return document.getElementById(id); }
function setMeta(text) {
var m = el("waka-meta");
if (m && text) { m.textContent = text; m.hidden = false; }
}
/* Render a ranked list of horizontal bars into a container. */
function renderBars(containerId, items) {
var box = el(containerId);
if (!box) return;
if (!items || !items.length) { failSection(box, "No data yet."); return; }
/* Share embeds for languages/categories/editors/OS often return only
* {name, percent, color} with no seconds, so we fall back to percent
* for both the bar width and the value label when time isn't given. */
var hasSeconds = items.some(function (d) { return d && (d.total_seconds || 0) > 0; });
var rows = items
.filter(function (d) { return d && ((d.total_seconds || 0) > 0 || (d.percent || 0) > 0); })
.sort(function (a, b) {
return hasSeconds
? (b.total_seconds || 0) - (a.total_seconds || 0)
: (b.percent || 0) - (a.percent || 0);
})
.slice(0, MAX_ROWS);
var max = rows.reduce(function (acc, d) {
return Math.max(acc, hasSeconds ? (d.total_seconds || 0) : (d.percent || 0));
}, 0) || 1;
box.innerHTML = "";
rows.forEach(function (d) {
var basis = hasSeconds ? (d.total_seconds || 0) : (d.percent || 0);
var pct = Math.max(2, Math.round((basis / max) * 100));
var row = document.createElement("div");
row.className = "waka-bar-row";
var name = document.createElement("span");
name.className = "waka-bar-name";
name.textContent = d.name || "Unknown";
name.title = d.name || "";
var track = document.createElement("span");
track.className = "waka-bar-track";
var fill = document.createElement("span");
fill.className = "waka-bar-fill";
fill.style.width = pct + "%";
if (d.color) fill.style.background = d.color;
track.appendChild(fill);
var val = document.createElement("span");
val.className = "waka-bar-val";
val.textContent = valueLabel(d, hasSeconds);
row.appendChild(name);
row.appendChild(track);
row.appendChild(val);
box.appendChild(row);
});
showSection(box);
}
/* Render the 7 day vertical bar chart and the headline total. */
function renderWeek(days) {
var box = el("waka-week");
if (!box) return;
if (!days || !days.length) { failSection(box, "No activity data yet."); return; }
var max = days.reduce(function (acc, d) { return Math.max(acc, d.total); }, 0) || 1;
var total = days.reduce(function (acc, d) { return acc + d.total; }, 0);
var headline = el("waka-total-val");
if (headline) headline.textContent = fmt(total);
var sub = el("waka-total-sub");
if (sub) sub.textContent = "across the last " + days.length + " days";
box.innerHTML = "";
days.forEach(function (d) {
var h = Math.max(3, Math.round((d.total / max) * 100));
var col = document.createElement("div");
col.className = "waka-day";
var barWrap = document.createElement("div");
barWrap.className = "waka-day-track";
var bar = document.createElement("div");
bar.className = "waka-day-fill";
bar.style.height = h + "%";
bar.title = d.label + ": " + fmt(d.total);
barWrap.appendChild(bar);
var lbl = document.createElement("span");
lbl.className = "waka-day-label";
lbl.textContent = d.short;
col.appendChild(barWrap);
col.appendChild(lbl);
box.appendChild(col);
});
showSection(box);
return total;
}
function showSection(box) {
var sec = box.closest(".waka-section");
if (sec) sec.hidden = false;
}
function failSection(box, msg) {
box.innerHTML = '<p class="waka-empty">' + msg + "</p>";
showSection(box);
}
/* ---- shape parsers ----
* Defensive, because WakaTime's embed shapes vary between endpoints. */
/* Categorical embeds (languages/categories/editors/OS) ->
* data: [{name, total_seconds, percent, color, text}] */
function asCategorical(json) {
var data = json && json.data;
if (!Array.isArray(data)) return [];
/* Some embeds nest under data.<key>, so flatten the first array we find. */
if (data.length && data[0] && data[0].name === undefined && Array.isArray(data[0])) {
data = data[0];
}
return data.map(function (d) {
return {
name: d.name,
total_seconds: typeof d.total_seconds === "number" ? d.total_seconds : (d.seconds || 0),
percent: d.percent,
color: d.color,
text: d.text
};
});
}
/* Coding activity embed -> array of {label, short, total} */
function asDays(json) {
var data = json && json.data;
if (!Array.isArray(data)) return [];
var out = [];
data.forEach(function (d) {
var seconds = 0, dateStr = "";
if (d.grand_total && typeof d.grand_total.total_seconds === "number") {
seconds = d.grand_total.total_seconds; /* daily-summaries shape */
} else if (typeof d.total_seconds === "number") {
seconds = d.total_seconds; /* flat shape */
}
if (d.range && (d.range.date || d.range.text)) {
dateStr = d.range.date || d.range.text;
} else if (d.date) {
dateStr = d.date;
}
/* Anchor a bare YYYY-MM-DD to local noon so the weekday label doesn't
* slip a day in timezones west of UTC, where it would otherwise parse
* as UTC midnight. */
var dateForParse = /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? dateStr + "T12:00:00" : dateStr;
var dt = dateStr ? new Date(dateForParse) : null;
var label = dt && !isNaN(dt) ? dt.toDateString() : (dateStr || "");
var short = dt && !isNaN(dt)
? dt.toLocaleDateString(undefined, { weekday: "short" })
: (label.slice(0, 3) || "?");
out.push({ label: label, short: short, total: seconds });
});
return out;
}
/* ---- orchestration ---- */
function load(url, onData, fallbackBoxId) {
if (!url) return Promise.resolve(null);
return jsonp(url).then(function (json) {
if (json && json.human_readable_range) setMeta("Range: " + json.human_readable_range);
else if (json && json.range && json.range.text) setMeta("Range: " + json.range.text);
onData(json);
return json;
}).catch(function (err) {
console.warn("[wakatime] failed to load", url, err);
if (fallbackBoxId) { var b = el(fallbackBoxId); if (b) failSection(b, "Couldn't load this chart."); }
return null;
});
}
function init() {
var configured = Object.keys(WAKATIME).some(function (k) { return !!WAKATIME[k]; });
var setup = el("waka-setup");
var content = el("waka-content");
if (!configured) {
if (setup) setup.hidden = false;
if (content) content.hidden = true;
return;
}
if (setup) setup.hidden = true;
if (content) content.hidden = false;
var jobs = [];
if (WAKATIME.codingActivity) {
jobs.push(load(WAKATIME.codingActivity, function (json) {
renderWeek(asDays(json));
}, "waka-week"));
}
if (WAKATIME.languages) {
jobs.push(load(WAKATIME.languages, function (json) {
renderBars("waka-languages", asCategorical(json));
}, "waka-languages"));
}
if (WAKATIME.categories) {
jobs.push(load(WAKATIME.categories, function (json) {
renderBars("waka-categories", asCategorical(json));
}, "waka-categories"));
}
if (WAKATIME.editors) {
jobs.push(load(WAKATIME.editors, function (json) {
renderBars("waka-editors", asCategorical(json));
}, "waka-editors"));
}
if (WAKATIME.operatingSystems) {
jobs.push(load(WAKATIME.operatingSystems, function (json) {
renderBars("waka-os", asCategorical(json));
}, "waka-os"));
}
/* If no coding-activity embed was set, hide the headline total card. */
if (!WAKATIME.codingActivity) {
var totEl = el("waka-total");
if (totEl) totEl.hidden = true;
}
Promise.all(jobs);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
/* ---- anchor links ----
* Open the targeted <details> and scroll to it. Sections are collapsible
* (and some get populated/unhidden async), so a plain #hash won't reliably
* reveal them. We open and scroll on load and on hashchange, retrying
* briefly while late content settles in. */
function openFromHash() {
var id = (location.hash || "").slice(1);
if (!id) return;
var attempts = 0;
(function tryOpen() {
var target = document.getElementById(id);
if (target && !target.hidden) {
if (target.tagName === "DETAILS") target.open = true;
target.scrollIntoView();
return;
}
if (attempts++ < 10) setTimeout(tryOpen, 200);
})();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", openFromHash);
} else {
openFromHash();
}
window.addEventListener("hashchange", openFromHash);
})();