Branding, Excel watermark, live run progress, and uv

- 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>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-28 14:43:31 +05:30
1 parent 2e1f28967e
commit 6616c91b7f
17 files changed
+816 -31

No files matched your search

+277 -4
View File
@@ -4,6 +4,7 @@
<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 {
@@ -25,6 +26,82 @@
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); }
@@ -49,6 +126,33 @@
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; }
@@ -61,7 +165,10 @@
<body>
<main>
<header>
<h1>MEK Charge Reconciliation</h1>
<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>
@@ -69,11 +176,53 @@
<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>
<pre id="log" aria-label="Run log"></pre>
<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">
@@ -96,13 +245,134 @@ 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… started ${fmtTime(st.started)}`
statusEl.textContent = st.running ? "Running…"
: st.code === 0 ? `Finished ${fmtTime(st.finished)}`
: st.code == null ? "" : `Failed (exit ${st.code}) ${fmtTime(st.finished)}. See the log.`;
: 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;
@@ -140,6 +410,9 @@ async function refresh() {
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();
+31 -1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · MEK Charge Reconciliation</title>
<link rel="icon" href="/static/ecare-logo.png">
<style>
:root {
--night: #040b26;
@@ -45,6 +46,14 @@
.sky .tw { animation: twinkle 4s ease-in-out infinite; animation-delay: var(--d); }
@keyframes twinkle { 50% { opacity: .15; } }
.brand {
position: absolute;
top: 20px;
left: 24px;
z-index: 1;
}
.brand img { display: block; width: 72px; height: auto; background: #000; border-radius: 16px; box-shadow: 0 6px 16px rgba(0, 3, 15, .45); }
.stage {
position: relative;
min-height: 100%;
@@ -87,6 +96,18 @@
overflow: visible;
}
.sources {
display: flex;
align-items: center;
gap: 20px;
margin-top: 28px;
}
.sources img { display: block; height: auto; }
.sources .waystar { width: 132px; }
.sources .sep { width: 1px; height: 26px; background: var(--panel-line); }
.aquila { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 16px; color: #dfe6f7; letter-spacing: .01em; }
.aquila img { width: 30px; }
label { display: block; font-weight: 600; margin-bottom: 8px; }
.field { position: relative; }
input {
@@ -195,6 +216,8 @@
@media (max-width: 420px) {
:root { --owl-w: 160px; }
.brand { top: 16px; left: 16px; }
.brand img { width: 52px; border-radius: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.sky .tw, .owl, .owl * { animation: none !important; transition: none !important; }
@@ -204,6 +227,7 @@
</head>
<body>
<div class="sky" id="sky" aria-hidden="true"></div>
<header class="brand"><img src="/static/ecare-logo.png" alt="eCare Services" width="206" height="199"></header>
<main class="stage">
<section class="card">
@@ -272,6 +296,12 @@
</form>
</section>
<footer class="sources">
<img src="/static/waystar-logo-light.png" alt="Waystar" width="362" height="61" class="waystar">
<span class="sep" aria-hidden="true"></span>
<span class="aquila"><img src="/static/aquilaehr-logo-light.png" alt="" width="86" height="75">Aquilaehr</span>
</footer>
</main>
<script>
@@ -293,7 +323,7 @@ function drawSky() {
let seed = 7;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const colors = ["#6ea8ff", "#4fd1c5", "#f29c4b", "#dfe7ff"];
const keepOut = [...document.querySelectorAll(".card, .owl")].map(e => e.getBoundingClientRect());
const keepOut = [...document.querySelectorAll(".card, .owl, .brand, .sources")].map(e => e.getBoundingClientRect());
let html = "";
for (let i = 0; i < 60; i++) {
const tw = rand() < .35, x = rand() * innerWidth, y = rand() * innerHeight;