- Login and Reports pages: eCare logo, Waystar and Aquilaehr logos (light versions for the dark pages), eCare favicon. - Excel reports: faded eCare/Waystar/Aquila watermark as the sheet background on both sheets. Report writer moves to xlsxwriter, the only engine that can set a sheet background. - Reports page: animated run progress (both download lanes piped into the Excel sheet) and a plain-language activity feed built from the run log; the raw log stays under "Technical log". /status now returns the location count. - Dependencies move from requirements.txt to pyproject.toml + uv.lock; Dockerfile installs uv from PyPI and runs uv sync --frozen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
425 lines
22 KiB
HTML
425 lines
22 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>MEK Charge Reconciliation</title>
|
|
<link rel="icon" href="/static/ecare-logo.png">
|
|
<style>
|
|
/* same night palette as the sign-in page */
|
|
:root {
|
|
--night: #040b26;
|
|
--panel: #0c1638;
|
|
--panel-line: #26335e;
|
|
--field: #050c28;
|
|
--pill-line: #c9d5f2;
|
|
--ink: #eef2ff;
|
|
--ink-2: #a9b6d8;
|
|
--owl: #ec7d33;
|
|
--ok: #7fe0a8;
|
|
--error: #ff9a9a;
|
|
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
color-scheme: dark;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--night); color: var(--ink); font: 15px/1.5 var(--ui); }
|
|
main { width: min(860px, 100%); margin: 0 auto; padding: 32px 16px 48px; }
|
|
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
|
|
h1 { margin: 0; font-size: clamp(20px, 5vw, 24px); font-weight: 650; letter-spacing: -.01em; }
|
|
.brand { display: flex; align-items: center; gap: 14px; }
|
|
.brand img { display: block; width: 48px; height: auto; background: #000; border-radius: 11px; box-shadow: 0 6px 16px rgba(0, 3, 15, .45); }
|
|
.aquila { display: flex; align-items: center; gap: 7px; font-weight: 600; font-size: 14px; color: #dfe6f7; }
|
|
.aquila img { display: block; width: 26px; height: auto; }
|
|
|
|
/* Run progress: two download lanes (--c = source colour) piped into the Excel sheet. */
|
|
.flow {
|
|
display: grid; grid-template-columns: minmax(0, 1fr) 72px auto; align-items: center;
|
|
margin: 0 0 18px; padding: 16px 18px; background: var(--field); border: 1px solid var(--panel-line); border-radius: 14px;
|
|
}
|
|
.lanes { display: grid; gap: 10px; }
|
|
.lane { display: grid; grid-template-columns: 120px minmax(0, 1fr) 140px; align-items: center; gap: 14px; height: 44px; }
|
|
.lane .waystar { display: block; width: 112px; height: auto; }
|
|
.track { position: relative; height: 6px; border-radius: 99px; background: #18234a; overflow: hidden; }
|
|
.fill {
|
|
position: absolute; inset: 0; border-radius: inherit; background: var(--c);
|
|
transform: scaleX(var(--p, 0)); transform-origin: left; transition: transform .6s cubic-bezier(.16, 1, .3, 1);
|
|
}
|
|
.track::after {
|
|
content: ""; position: absolute; inset: 0; opacity: 0; transform: translateX(-100%);
|
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .4), transparent);
|
|
}
|
|
.note { font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; text-align: right; }
|
|
[data-state="login"] .lane-logo { animation: breathe 1.6s ease-in-out infinite; }
|
|
[data-state="login"] .track::after, [data-state="active"] .track::after { opacity: 1; animation: sheen 1.6s ease-in-out infinite; }
|
|
[data-state="done"] .note { color: var(--ok); }
|
|
[data-state="warn"] .note { color: var(--owl); }
|
|
[data-state="fail"] .note { color: var(--error); }
|
|
[data-state="fail"] .fill { background: var(--error); }
|
|
@keyframes breathe { 50% { opacity: .4; } }
|
|
@keyframes sheen { to { transform: translateX(100%); } }
|
|
|
|
.pipes { width: 72px; height: 98px; overflow: visible; }
|
|
.pipes path { fill: none; stroke: var(--panel-line); stroke-width: 2.5; stroke-linecap: round; vector-effect: non-scaling-stroke; transition: stroke .3s, opacity .3s; }
|
|
.pipes path.on { stroke: var(--c); stroke-dasharray: 4 6; animation: march .7s linear infinite; }
|
|
.pipes path.done { stroke: var(--c); opacity: .5; }
|
|
@keyframes march { to { stroke-dashoffset: -10; } }
|
|
.drop { display: none; }
|
|
|
|
.sink { --c: #33c481; display: flex; align-items: center; gap: 12px; min-width: 196px; }
|
|
.sink-text { display: grid; gap: 3px; flex: 1; }
|
|
.sink-text strong { font-size: 14px; }
|
|
.sink .note { text-align: left; }
|
|
.sink .track { height: 4px; margin-top: 3px; }
|
|
.sheet { width: 48px; height: 54px; flex: none; overflow: visible; }
|
|
.sheet .paper { fill: #0d2a20; stroke: #2f9e68; stroke-width: 1.5; }
|
|
.sheet .fold { fill: none; stroke: #2f9e68; stroke-width: 1.5; stroke-linejoin: round; }
|
|
.sheet .cells rect { fill: var(--c); opacity: .16; rx: 1; transition: opacity .25s calc(var(--i) * 45ms); }
|
|
[data-state="active"] .sheet .cells rect { animation: cell 1.5s ease-in-out calc(var(--i) * 90ms) infinite; }
|
|
[data-state="done"] .sheet .cells rect { opacity: 1; }
|
|
@keyframes cell { 40% { opacity: 1; } }
|
|
.sheet .tick { transform-box: fill-box; transform-origin: center; transform: scale(0); transition: transform .5s cubic-bezier(.16, 1, .3, 1) .45s; }
|
|
.sheet .tick circle { fill: var(--ok); stroke: var(--field); stroke-width: 2; }
|
|
.sheet .tick path { fill: none; stroke: #06331f; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
[data-state="done"] .sheet .tick { transform: none; }
|
|
[data-state="fail"] .sheet .paper { stroke: var(--error); }
|
|
|
|
@media (max-width: 640px) {
|
|
.flow { grid-template-columns: 1fr; padding: 14px; }
|
|
.lanes { gap: 16px; }
|
|
.lane { grid-template-columns: 1fr auto; grid-template-areas: "logo note" "track track"; height: auto; row-gap: 8px; }
|
|
.lane .lane-logo { grid-area: logo; }
|
|
.lane .track { grid-area: track; }
|
|
.lane .note { grid-area: note; }
|
|
.pipes { display: none; }
|
|
.drop {
|
|
display: block; width: 2px; height: 26px; margin: 10px 0 10px 23px;
|
|
background: linear-gradient(var(--panel-line) 50%, transparent 0) 0 0 / 2px 10px;
|
|
}
|
|
.flow.flowing .drop { background-image: linear-gradient(var(--ink-2) 50%, transparent 0); animation: drip .7s linear infinite; }
|
|
@keyframes drip { to { background-position: 0 10px; } }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.flow *, .flow *::after { animation: none !important; }
|
|
.fill, .sheet .cells rect, .sheet .tick { transition: none; }
|
|
}
|
|
h2 { margin: 0 0 12px; font-size: 17px; font-weight: 650; }
|
|
.card { background: var(--panel); border: 1px solid var(--panel-line); border-radius: 18px; padding: 22px 22px 20px; margin-bottom: 16px; }
|
|
.muted { color: var(--ink-2); }
|
|
.pill {
|
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
height: 44px; padding: 0 22px; background: #0a1435; color: #fff; text-decoration: none;
|
|
border: 1.5px solid var(--pill-line); border-radius: 999px; font: 600 15px var(--ui); cursor: pointer;
|
|
}
|
|
.pill:hover { border-color: var(--owl); background: #101d45; }
|
|
.pill:focus-visible, a:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
|
|
.pill.primary { border-color: var(--owl); }
|
|
.pill[disabled] { opacity: .5; cursor: not-allowed; }
|
|
.pill.small { height: 34px; padding: 0 14px; font-size: 13px; }
|
|
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
|
.status { font-weight: 600; }
|
|
.status.running { color: var(--owl); }
|
|
.status.ok { color: var(--ok); }
|
|
.status.fail { color: var(--error); }
|
|
pre {
|
|
margin: 14px 0 0; max-height: 280px; overflow: auto; padding: 12px 14px;
|
|
background: var(--field); border: 1px solid var(--panel-line); border-radius: 12px;
|
|
font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word;
|
|
}
|
|
pre:empty { display: none; }
|
|
|
|
/* Plain-language activity feed built from the run log; the raw log sits under "Technical log". */
|
|
.feed {
|
|
list-style: none; margin: 16px 0 0; padding: 4px 14px; max-height: 300px; overflow: auto;
|
|
background: var(--field); border: 1px solid var(--panel-line); border-radius: 12px;
|
|
}
|
|
.feed:empty { display: none; }
|
|
.feed li { display: flex; gap: 10px; align-items: baseline; padding: 7px 0; font-size: 14px; }
|
|
.feed li + li { border-top: 1px solid #141f45; }
|
|
.feed .dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ink-2); transform: translateY(-1px); }
|
|
.feed .ok .dot { background: var(--ok); }
|
|
.feed .warn .dot { background: var(--owl); }
|
|
.feed .error .dot { background: var(--error); }
|
|
.feed .warn { color: #ffd2ad; }
|
|
.feed .error { color: var(--error); }
|
|
.feed .minor { color: var(--ink-2); font-size: 13px; }
|
|
.feed li.enter { animation: feed-in .45s cubic-bezier(.16, 1, .3, 1); }
|
|
.feed.live li.now .dot { animation: now 1.2s ease-in-out infinite; }
|
|
@keyframes feed-in { from { opacity: 0; transform: translateY(6px); } }
|
|
@keyframes now { 50% { opacity: .3; } }
|
|
.tech { margin-top: 10px; }
|
|
.tech summary { width: fit-content; color: var(--ink-2); font-size: 13px; cursor: pointer; }
|
|
.tech summary:hover { color: var(--ink); }
|
|
.tech summary:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; border-radius: 4px; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.feed li.enter, .feed.live li.now .dot { animation: none; }
|
|
}
|
|
table { width: 100%; border-collapse: collapse; }
|
|
td { padding: 9px 4px; border-top: 1px solid var(--panel-line); vertical-align: middle; }
|
|
td:last-child { text-align: right; white-space: nowrap; }
|
|
.file { font-weight: 600; }
|
|
.file small { display: block; font-weight: 400; color: var(--ink-2); }
|
|
.empty { color: var(--ink-2); margin: 0; }
|
|
form.inline { margin: 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<div class="brand">
|
|
<img src="/static/ecare-logo.png" alt="eCare Services" width="206" height="199">
|
|
<h1>MEK Charge Reconciliation</h1>
|
|
</div>
|
|
<form class="inline" method="post" action="/logout"><button class="pill small" type="submit">Sign out</button></form>
|
|
</header>
|
|
|
|
<section class="card" aria-labelledby="run-h">
|
|
<h2 id="run-h">Run</h2>
|
|
<p class="muted" style="margin:0 0 14px">Downloads Waystar claims and Aquilaehr encounters for every location at the same time,
|
|
then builds one reconciliation report per location (Patient Name + Date of Service).</p>
|
|
|
|
<div class="flow" id="flow" role="group" aria-label="Run progress">
|
|
<div class="lanes">
|
|
<div class="lane" id="lane-Waystar" data-state="idle" style="--c:#f07b3a">
|
|
<img class="lane-logo waystar" src="/static/waystar-logo-light.png" alt="Waystar" width="362" height="61">
|
|
<div class="track"><span class="fill"></span></div>
|
|
<span class="note">Ready</span>
|
|
</div>
|
|
<div class="lane" id="lane-Aquilaehr" data-state="idle" style="--c:#4a9be0">
|
|
<span class="lane-logo aquila"><img src="/static/aquilaehr-logo-light.png" alt="" width="86" height="75">Aquilaehr</span>
|
|
<div class="track"><span class="fill"></span></div>
|
|
<span class="note">Ready</span>
|
|
</div>
|
|
</div>
|
|
<svg class="pipes" viewBox="0 0 72 98" preserveAspectRatio="none" aria-hidden="true">
|
|
<path id="pipe-Waystar" style="--c:#f07b3a" d="M0 22C40 22 32 49 72 49"/>
|
|
<path id="pipe-Aquilaehr" style="--c:#4a9be0" d="M0 76C40 76 32 49 72 49"/>
|
|
</svg>
|
|
<span class="drop" aria-hidden="true"></span>
|
|
<div class="sink" id="sink" data-state="idle">
|
|
<svg class="sheet" viewBox="0 0 48 54" aria-hidden="true">
|
|
<path class="paper" d="M5 2h25l12 12v35a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3z"/>
|
|
<path class="fold" d="M30 2v12h12"/>
|
|
<g class="cells">
|
|
<rect x="8" y="19" width="8" height="5" style="--i:0"/><rect x="18" y="19" width="8" height="5" style="--i:1"/><rect x="28" y="19" width="8" height="5" style="--i:2"/>
|
|
<rect x="8" y="26" width="8" height="5" style="--i:3"/><rect x="18" y="26" width="8" height="5" style="--i:4"/><rect x="28" y="26" width="8" height="5" style="--i:5"/>
|
|
<rect x="8" y="33" width="8" height="5" style="--i:6"/><rect x="18" y="33" width="8" height="5" style="--i:7"/><rect x="28" y="33" width="8" height="5" style="--i:8"/>
|
|
<rect x="8" y="40" width="8" height="5" style="--i:9"/><rect x="18" y="40" width="8" height="5" style="--i:10"/><rect x="28" y="40" width="8" height="5" style="--i:11"/>
|
|
</g>
|
|
<g class="tick"><circle cx="38" cy="44" r="9"/><path d="M34 44.2l2.8 2.8 5.2-5.6"/></g>
|
|
</svg>
|
|
<div class="sink-text">
|
|
<strong>Excel reports</strong>
|
|
<span class="note">Ready</span>
|
|
<div class="track"><span class="fill"></span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<button class="pill primary" id="run" type="button">Run reconciliation</button>
|
|
<span class="status" id="status" aria-live="polite"></span>
|
|
</div>
|
|
<ol class="feed" id="feed" aria-label="What's happening"></ol>
|
|
<details class="tech" id="tech" hidden>
|
|
<summary>Technical log</summary>
|
|
<pre id="log" aria-label="Technical log"></pre>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="card" aria-labelledby="dl-h">
|
|
<div class="row" style="justify-content:space-between;margin-bottom:6px">
|
|
<h2 id="dl-h" style="margin:0">Reports</h2>
|
|
<a class="pill small" id="zip" href="/download-all">Download all (ZIP)</a>
|
|
</div>
|
|
<table><tbody id="files"></tbody></table>
|
|
<p class="empty" id="no-files" hidden>No reports yet. Run the reconciliation first.</p>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const runBtn = document.getElementById("run");
|
|
const statusEl = document.getElementById("status");
|
|
const logEl = document.getElementById("log");
|
|
const filesEl = document.getElementById("files");
|
|
const noFiles = document.getElementById("no-files");
|
|
const zip = document.getElementById("zip");
|
|
let timer;
|
|
|
|
const fmtTime = s => s ? new Date(s * 1000).toLocaleString() : "";
|
|
const flow = document.getElementById("flow");
|
|
const sink = document.getElementById("sink");
|
|
let locations = 0;
|
|
|
|
function setStage(el, state, progress, note) {
|
|
el.dataset.state = state;
|
|
el.querySelector(".fill").style.setProperty("--p", progress);
|
|
el.querySelector(".note").textContent = note;
|
|
}
|
|
|
|
// Progress comes from the run log: mek_recon prints "<Site> OK|FAIL <location>" per download and
|
|
// "<Site> failed: ..." when that site is done, then "Consolidating", then one line per report.
|
|
function renderFlow(st) {
|
|
const n = locations = st.locations || locations;
|
|
const lines = st.log ? st.log.split("\n") : [];
|
|
const started = st.running || st.code != null;
|
|
const flowing = [];
|
|
|
|
for (const site of ["Waystar", "Aquilaehr"]) {
|
|
const mine = lines.filter(l => l.startsWith(site + " "));
|
|
const ok = mine.filter(l => /^\S+\s+OK\s/.test(l)).length;
|
|
const fail = mine.filter(l => /^\S+\s+FAIL\s/.test(l)).length;
|
|
const done = mine.some(l => /^\S+\s+failed:/.test(l));
|
|
const lane = document.getElementById("lane-" + site);
|
|
const pipe = document.getElementById("pipe-" + site);
|
|
if (!started) setStage(lane, "idle", 0, "Ready");
|
|
else if (done) setStage(lane, fail ? "warn" : "done", 1, fail ? `${ok} of ${n} · ${fail} failed` : `All ${n} downloaded`);
|
|
else if (!st.running) setStage(lane, "fail", (ok + fail) / n, "Stopped");
|
|
else if (ok + fail) setStage(lane, "active", (ok + fail) / n, fail ? `${ok + fail} of ${n} · ${fail} failed` : `${ok} of ${n} locations`);
|
|
else setStage(lane, "login", 0, "Signing in…");
|
|
pipe.classList.toggle("on", lane.dataset.state === "active");
|
|
pipe.classList.toggle("done", ["done", "warn"].includes(lane.dataset.state));
|
|
if (lane.dataset.state === "active") flowing.push(site);
|
|
}
|
|
|
|
const from = lines.findIndex(l => l.startsWith("Consolidating"));
|
|
const total = n + 1; // one report per location + All Locations
|
|
const built = from < 0 ? 0 : lines.slice(from).filter(l => /Aquilaehr=\s*\d|^SKIP |^ALL LOCATIONS/.test(l)).length;
|
|
if (!started) setStage(sink, "idle", 0, "Ready");
|
|
else if (st.running && from < 0) setStage(sink, "waiting", 0, "Waiting for both downloads");
|
|
else if (st.running) setStage(sink, "active", built / total, `Building ${Math.min(built + 1, total)} of ${total}`);
|
|
else if (st.code === 0) setStage(sink, "done", 1, `${st.files.length} reports ready`);
|
|
else setStage(sink, "fail", built / total, "Stopped");
|
|
if (sink.dataset.state === "active") flowing.push("Excel");
|
|
flow.classList.toggle("flowing", flowing.length > 0);
|
|
}
|
|
|
|
// Why a download failed, in words the team can act on (the exact error stays in the technical log).
|
|
function reason(err, site) {
|
|
if (/not in dropdown|no account match/i.test(err)) return `This location wasn't found in ${site}.`;
|
|
if (/did not load|timeout|timed out/i.test(err)) return `${site} took too long to respond.`;
|
|
if (/expected/i.test(err)) return `${site} opened a different location than expected.`;
|
|
return "Try running again. If it keeps failing, open the technical log below.";
|
|
}
|
|
|
|
// One raw log line -> [kind, plain sentence], or null for lines only a developer needs.
|
|
function friendly(line) {
|
|
let m;
|
|
if ((m = line.match(/^(Waystar|Aquilaehr)\s+(.*)$/))) {
|
|
const [, site, msg] = m;
|
|
if (msg === "login submitted") return ["info", `Signing in to ${site}…`];
|
|
if (msg === "logged in") return ["ok", `Signed in to ${site}`];
|
|
if (msg.startsWith("security question")) return ["info", `Answered ${site}'s security question`];
|
|
if ((m = msg.match(/^OK\s+(.+?) -> /))) return ["ok", `Downloaded ${m[1]} from ${site}`];
|
|
if ((m = msg.match(/^FAIL\s+(.+?): (.*)$/))) return ["error", `Couldn't download ${m[1]} from ${site}. ${reason(m[2], site)}`];
|
|
if ((m = msg.match(/^WARNING\s+(.+?): same data as (.+?) -/)))
|
|
return ["warn", `${m[1]} has exactly the same ${site} data as ${m[2]}. Please check this location.`];
|
|
if ((m = msg.match(/^\s*(.+): (\d+) rows$/))) return ["minor", `Found ${m[2]} visits for ${m[1]}`];
|
|
if ((m = msg.match(/^failed: (.*)$/))) {
|
|
if (m[1] === "none") return ["ok", `All ${site} downloads finished`];
|
|
const names = m[1].replace(/[[\]']/g, "");
|
|
return ["warn", `${site} finished, but these locations couldn't be downloaded: ${names}`];
|
|
}
|
|
return null;
|
|
}
|
|
if (line.startsWith("Close these files in Excel")) return ["error", "Some report files are open in Excel. Close them, then run again."];
|
|
if ((m = line.match(/^\s{2}(?:processing|output)[\\/](?:.*[\\/])?(.+\.(?:csv|xlsx))$/))) return ["minor", `Still open: ${m[1]}`];
|
|
if (line.startsWith("Consolidating")) return ["info", "Both downloads are done. Building the Excel reports…"];
|
|
if ((m = line.match(/^(.+?)\s+Aquilaehr=\s*\d+\s+Waystar=\s*\d+\s+Matched=\s*(\d+)\s+Missing in Waystar=\s*(\d+)\s+Missing in Aquilaehr=\s*(\d+)/)))
|
|
return ["ok", `${m[1]} report ready: ${m[2]} matched, ${m[3]} missing in Waystar, ${m[4]} missing in Aquilaehr`];
|
|
if ((m = line.match(/^SKIP (.+?):/))) return ["warn", `Skipped ${m[1]}: nothing was downloaded from Aquilaehr`];
|
|
if ((m = line.match(/WARNING (.+?): under half/)))
|
|
return ["warn", `Check ${m[1]}: fewer than half of the visits matched. The wrong practice may have been downloaded.`];
|
|
if (line.startsWith("ALL LOCATIONS")) return ["ok", "All Locations summary report ready"];
|
|
if ((m = line.match(/^RuntimeError: (Waystar|Aquilaehr) login did not finish/)))
|
|
return ["error", `Couldn't sign in to ${m[1]} in time. Check the login details, then run again.`];
|
|
if (/^\w*(Error|Exception)\b/.test(line)) return ["error", "Something unexpected went wrong. The technical log below has the details."];
|
|
return null;
|
|
}
|
|
|
|
const feedEl = document.getElementById("feed");
|
|
const tech = document.getElementById("tech");
|
|
let shown = []; // sentences currently in the feed, so each poll only animates the new ones
|
|
|
|
function renderFeed(st) {
|
|
const items = (st.running || st.code != null) && st.log ? st.log.split("\n").map(friendly).filter(Boolean) : [];
|
|
if (st.running || st.code != null) items.unshift(["info", `Run started ${fmtTime(st.started)}`]);
|
|
if (!st.running && st.code === 0) items.push(["ok", "Finished. Your reports are ready to download below."]);
|
|
if (!st.running && st.code != null && st.code !== 0) items.push(["error", "The run stopped before it finished."]);
|
|
|
|
const key = items.map(i => i[1]);
|
|
const fresh = shown.length <= key.length && shown.every((s, i) => s === key[i]);
|
|
if (!fresh) feedEl.innerHTML = "";
|
|
const atBottom = feedEl.scrollTop + feedEl.clientHeight >= feedEl.scrollHeight - 8;
|
|
for (const [kind, text] of items.slice(fresh ? shown.length : 0)) {
|
|
const li = document.createElement("li");
|
|
li.className = kind + (fresh && shown.length ? " enter" : "");
|
|
const dot = document.createElement("span");
|
|
dot.className = "dot";
|
|
li.append(dot, text);
|
|
feedEl.append(li);
|
|
}
|
|
shown = key;
|
|
feedEl.querySelector(".now")?.classList.remove("now");
|
|
feedEl.lastElementChild?.classList.add("now");
|
|
feedEl.classList.toggle("live", st.running);
|
|
if (atBottom || !fresh) feedEl.scrollTop = feedEl.scrollHeight;
|
|
}
|
|
|
|
function render(st) {
|
|
renderFlow(st);
|
|
renderFeed(st);
|
|
runBtn.disabled = st.running;
|
|
statusEl.className = "status " + (st.running ? "running" : st.code === 0 ? "ok" : st.code == null ? "" : "fail");
|
|
statusEl.textContent = st.running ? "Running…"
|
|
: st.code === 0 ? `Finished ${fmtTime(st.finished)}`
|
|
: st.code == null ? "" : `Stopped ${fmtTime(st.finished)}`;
|
|
tech.hidden = !st.log;
|
|
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 8;
|
|
logEl.textContent = st.log;
|
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
|
|
|
filesEl.innerHTML = "";
|
|
for (const f of st.files) {
|
|
const tr = document.createElement("tr");
|
|
const name = document.createElement("td");
|
|
name.className = "file";
|
|
name.textContent = f.name.replace(/\.xlsx$/, "");
|
|
const meta = document.createElement("small");
|
|
meta.textContent = `Updated ${fmtTime(f.mtime)} · ${(f.size / 1024).toFixed(0)} KB`;
|
|
name.append(meta);
|
|
const act = document.createElement("td");
|
|
const a = document.createElement("a");
|
|
a.className = "pill small";
|
|
a.href = "/download/" + encodeURIComponent(f.name);
|
|
a.textContent = "Download";
|
|
act.append(a);
|
|
tr.append(name, act);
|
|
filesEl.append(tr);
|
|
}
|
|
noFiles.hidden = st.files.length > 0;
|
|
zip.hidden = st.files.length === 0;
|
|
|
|
clearTimeout(timer);
|
|
timer = setTimeout(refresh, st.running ? 2000 : 15000);
|
|
}
|
|
|
|
async function refresh() {
|
|
const res = await fetch("/status");
|
|
if (res.status === 401) return location.href = "/";
|
|
render(await res.json());
|
|
}
|
|
|
|
runBtn.addEventListener("click", async () => {
|
|
runBtn.disabled = true;
|
|
const starting = { running: true, code: null, log: "", files: [], started: Date.now() / 1000 };
|
|
renderFlow(starting); // answer the click before the first poll
|
|
renderFeed(starting);
|
|
const res = await fetch("/run", { method: "POST" });
|
|
if (res.status === 409) statusEl.textContent = "A run is already in progress.";
|
|
refresh();
|
|
});
|
|
|
|
refresh();
|
|
</script>
|
|
</body>
|
|
</html>
|