323 lines
11 KiB
JavaScript
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);
|
|
})(); |