File size: 9,171 Bytes
247f100 806e511 a7ffb22 806e511 fd87110 806e511 247f100 a7ffb22 806e511 247f100 bc052e5 806e511 bc052e5 806e511 247f100 bc052e5 a7ffb22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | // 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")))]);
}
|