mathly / mathly2 /static /developer.js
wzender's picture
Claude
Prompter on the developer screen (add-prompter) (#8)
bc052e5 unverified
Raw History Blame Contribute Delete
9.17 kB
// The developer's home: a status card (is each part working, which version runs, when it was last updated), an activity chart
// (how many people used the app on each of the last 7 or 30 days) and every permission (the other roles' chips open their screens).
// The other tools (prompter, lab, AI cost) come later.
import { api } from "./api.js";
import { esc } from "./math.js";
const STATES = { ok: { icon: "โœ“", word: "ืชืงื™ืŸ" }, warn: { icon: "!", word: "ืฉื™ืžื• ืœื‘" }, error: { icon: "โœ•", word: "ืชืงืœื”" }, off: { icon: "โ€“", word: "ื›ื‘ื•ื™" } };
const OVERALL = { ok: "ื”ืžืขืจื›ืช ืชืงื™ื ื”", warn: "ื”ืžืขืจื›ืช ืขื•ื‘ื“ืช, ืขื ื”ืขืจื•ืช", error: "ื™ืฉ ืชืงืœื” ื‘ืžืขืจื›ืช" };
const when = iso => iso ? new Intl.DateTimeFormat("he-IL", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "Asia/Jerusalem" }).format(new Date(iso)) : "ืœื ื™ื“ื•ืข";
function since(seconds) {
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60);
return d ? `ืœืคื ื™ ${d} ื™ืžื™ื ื•ึพ${h} ืฉืขื•ืช` : h ? `ืœืคื ื™ ${h} ืฉืขื•ืช ื•ึพ${m} ื“ืงื•ืช` : m ? `ืœืคื ื™ ${m} ื“ืงื•ืช` : "ืœืคื ื™ ืคื—ื•ืช ืžื“ืงื”";
}
function card(s) {
const v = s.version;
return `<div class="st-head st-${esc(s.overall)}" id="st-overall" data-state="${esc(s.overall)}">
<span class="st-dot" aria-hidden="true">${STATES[s.overall].icon}</span><b>${OVERALL[s.overall]}</b></div>
<dl class="st-facts">
<div><dt>ื’ืจืกื”</dt><dd id="st-version" dir="ltr">${v.short ? `<span title="${esc(v.commit)}">${esc(v.short)}</span>` : "ืœื ื™ื“ื•ืขื”"}</dd>
${v.title ? `<dd class="st-title" id="st-title-change" dir="auto">${esc(v.title)}</dd>` : ""}</div>
<div><dt>ืขื“ื›ื•ืŸ ืื—ืจื•ืŸ</dt><dd id="st-updated">${esc(when(v.date))}</dd></div>
<div><dt>ื”ืฉืจืช ืขืœื”</dt><dd id="st-started">${esc(when(s.started_at))} <span class="muted">(${since(s.uptime_seconds)})</span></dd></div>
</dl>
<ul class="plain st-checks" id="st-checks">${s.checks.map(c => `<li data-key="${esc(c.key)}" data-state="${esc(c.state)}">
<span><span class="st-dot st-${esc(c.state)}" aria-hidden="true">${STATES[c.state].icon}</span> <b>${esc(c.name)}</b>
<span class="muted">${esc(c.detail)}</span></span><span class="st-word st-${esc(c.state)}">${STATES[c.state].word}</span></li>`).join("")}</ul>
<p class="muted st-foot">ื ื‘ื“ืง ื‘ึพ${esc(when(s.now))} ยท Python ${esc(s.python)}</p>`;
}
let asked = 0; // only the latest check is drawn, if two overlap
async function load() {
const mine = ++asked;
let html;
try {
html = card(await api("/api/dev/status"));
} catch (error) {
html = `<p class="error" role="alert">ืœื ื”ืฆืœื—ื ื• ืœืงืจื•ื ืืช ืžืฆื‘ ื”ืžืขืจื›ืช: ${esc(error.message || "")}</p>`;
}
const box = document.getElementById("st-body");
if (box && mine === asked) box.innerHTML = html;
}
// ---- activity: one column per Israel day, oldest on the left; the height is how many different people were active ----
const DAY = new Intl.DateTimeFormat("he-IL", { weekday: "short", timeZone: "UTC" });
const DATE = new Intl.DateTimeFormat("he-IL", { day: "numeric", month: "numeric", timeZone: "UTC" });
const LONG = new Intl.DateTimeFormat("he-IL", { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" });
const at = iso => new Date(`${iso}T00:00:00Z`); // a calendar day, drawn as that day whatever the device's zone
const people = n => n === 1 ? "ืžืฉืชืžืฉ ืื—ื“" : `${n.toLocaleString("he-IL")} ืžืฉืชืžืฉื™ื`;
/** A clean top for the axis (1, 2 or 5 times a power of ten) and its step, about four gridlines. */
function scale(max) {
if (max <= 4) return { top: Math.max(max, 1), step: 1 };
const rough = max / 4, power = 10 ** Math.floor(Math.log10(rough));
const step = [1, 2, 5, 10].map(m => m * power).find(s => s >= rough);
return { top: Math.ceil(max / step) * step, step };
}
function chart(a) {
const days = a.days, n = days.length, { top, step } = scale(Math.max(...days.map(d => d.users)));
const peak = days.reduce((best, d, i) => d.users > days[best].users ? i : best, 0);
const every = n <= 7 ? 1 : 5; // which days get a label under the axis
const ticks = []; for (let v = 0; v <= top; v += step) ticks.push(v);
return `<div class="ac-plot" dir="ltr" style="--n:${n}">
<div class="ac-grid" aria-hidden="true">${ticks.map(v => `<div class="ac-tick" style="bottom:${v / top * 100}%"><span>${v}</span></div>`).join("")}</div>
<ol class="ac-cols" aria-label="ืžืฉืชืžืฉื™ื ืคืขื™ืœื™ื ื‘ื›ืœ ื™ื•ื">${days.map((d, i) => {
const label = `${LONG.format(at(d.day))}: ${people(d.users)}, ${d.answers} ืชืฉื•ื‘ื•ืช ื‘ืชืจื’ื•ืœ, ${d.attempts} ื‘ื—ื ื™ื ื•ืžื‘ื—ื ื™ื`;
const shown = d.users > 0 && (i === n - 1 || i === peak); // label the latest day and the busiest, not every column
return `<li class="ac-col" tabindex="0" data-day="${d.day}" data-users="${d.users}" aria-label="${esc(label)}">
<span class="ac-bar" style="height:${d.users / top * 100}%">${shown ? `<b class="ac-val">${d.users}</b>` : ""}</span>
<span class="ac-tip" role="tooltip"><b>${esc(LONG.format(at(d.day)))}</b><br>${people(d.users)}<br>${d.answers} ืชืฉื•ื‘ื•ืช ื‘ืชืจื’ื•ืœ<br>${d.attempts} ื‘ื—ื ื™ื ื•ืžื‘ื—ื ื™ื</span>
<span class="ac-x" aria-hidden="true">${i % every === (n - 1) % every ? (n <= 7 ? `${esc(DAY.format(at(d.day)))}<br>${esc(DATE.format(at(d.day)))}` : esc(DATE.format(at(d.day)))) : ""}</span>
</li>`;
}).join("")}</ol></div>`;
}
function activityCard(a) {
const today = a.days[a.days.length - 1], n = a.days.length;
return `<dl class="st-facts ac-tiles">
<div><dt>ื”ื™ื•ื</dt><dd id="ac-today">${people(today.users)}</dd></div>
<div><dt>ื‘ึพ${n} ื”ื™ืžื™ื ื”ืื—ืจื•ื ื™ื</dt><dd id="ac-range">${people(a.users)}</dd></div>
<div><dt>ืชืฉื•ื‘ื•ืช ื‘ืชืจื’ื•ืœ</dt><dd>${a.answers.toLocaleString("he-IL")}</dd></div>
<div><dt>ื‘ื—ื ื™ื ื•ืžื‘ื—ื ื™ื ืฉื”ืชื—ื™ืœื•</dt><dd>${a.attempts.toLocaleString("he-IL")}</dd></div>
</dl>${chart(a)}
<details class="ac-table"><summary>ื”ื ืชื•ื ื™ื ื‘ื˜ื‘ืœื”</summary><table><thead><tr><th>ื™ื•ื</th><th>ืžืฉืชืžืฉื™ื</th><th>ืชืฉื•ื‘ื•ืช</th><th>ื‘ื—ื ื™ื ื•ืžื‘ื—ื ื™ื</th></tr></thead>
<tbody>${a.days.slice().reverse().map(d => `<tr><td>${esc(LONG.format(at(d.day)))}</td><td>${d.users}</td><td>${d.answers}</td><td>${d.attempts}</td></tr>`).join("")}</tbody></table></details>
<p class="muted st-foot">ืžืฉืชืžืฉ ืคืขื™ืœ: ืขื ื” ืขืœ ืฉืืœื” ื‘ืชืจื’ื•ืœ ืื• ื”ืชื—ื™ืœ ื‘ื•ื—ืŸ ืื• ืžื‘ื—ืŸ ื‘ืื•ืชื• ื™ื•ื (ืœืคื™ ืฉืขื•ืŸ ื™ืฉืจืืœ).</p>`;
}
let range = 7, askedActivity = 0;
async function loadActivity() {
const mine = ++askedActivity;
let html;
try {
html = activityCard(await api(`/api/dev/activity?days=${range}`));
} catch (error) {
html = `<p class="error" role="alert">ืœื ื”ืฆืœื—ื ื• ืœืงืจื•ื ืืช ื”ืคืขื™ืœื•ืช: ${esc(error.message || "")}</p>`;
}
const box = document.getElementById("ac-body");
if (box && mine === askedActivity) box.innerHTML = html;
}
export async function show(me, view) {
view(`<section class="card" id="status" aria-labelledby="st-title">
<div class="st-top"><h2 id="st-title">ืžืฆื‘ ื”ืžืขืจื›ืช</h2><button type="button" id="st-refresh">ื‘ื“ื™ืงื” ืžื—ื“ืฉ</button></div>
<div id="st-body" aria-live="polite"><p class="muted">ื‘ื•ื“ืงื™ืโ€ฆ</p></div></section>
<section class="card" id="activity" aria-labelledby="ac-title">
<div class="st-top"><h2 id="ac-title">ืคืขื™ืœื•ืช</h2>
<div class="ac-range" role="group" aria-label="ื˜ื•ื•ื—">${[7, 30].map(d =>
`<button type="button" data-range="${d}" aria-pressed="${d === range}">${d} ื™ืžื™ื</button>`).join("")}</div></div>
<div id="ac-body" aria-live="polite"><p class="muted">ื˜ื•ืขื ื™ืโ€ฆ</p></div></section>
<section class="card" id="prompter" aria-label="ืคืจื•ืžืคื˜ืจ"></section>
<section class="card" id="developer"><h2>ืžืคืชื—</h2>
<p>ื™ืฉ ืœืš ืืช ื›ืœ ื”ื”ืจืฉืื•ืช: ืืคืฉืจ ืœืคืชื•ื— ืืช ื”ืžืกื›ื™ื ืฉืœ ื›ืœ ื”ืชืคืงื™ื“ื™ื ืžื”ื›ืคืชื•ืจื™ื ืœืžืขืœื”.</p>
<p class="muted">ืขื•ื“ ื›ืœื™ ืžืคืชื— (ืžืขื‘ื“ื”, ืขืœื•ืช ื”ึพAI) ื™ืชื•ื•ืกืคื• ื‘ืฉืœื‘ื™ื ื”ื‘ืื™ื.</p>
<p class="muted" dir="ltr">${esc(me.email)}</p></section>`);
document.getElementById("st-refresh").addEventListener("click", () => { load(); loadActivity(); });
document.querySelector("#activity .ac-range").addEventListener("click", event => {
const button = event.target.closest("[data-range]");
if (!button) return;
range = Number(button.dataset.range);
document.querySelectorAll("#activity [data-range]").forEach(b => b.setAttribute("aria-pressed", String(b === button)));
loadActivity();
});
await Promise.all([load(), loadActivity(), import("./prompter_card.js").then(m => m.mount(document.getElementById("prompter")))]);
}