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")))]);
}