MEK Charge Reconciliation: Waystar + Aquilaehr download, per-location reconciliation, web app

- mek_recon.py: Playwright download for both sites in parallel (headless on server)
- consolidate.py: Patient Name + DOS match, per-location and All Locations Excel
- app.py: sign-in (server-side password), Run button, per-file and ZIP downloads
- Dockerfile for Dokploy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-27 17:38:27 +05:30
commit fd480fa874
10 files changed
+1335

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
<!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>
<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; }
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; }
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>
<h1>MEK Charge Reconciliation</h1>
<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="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>
</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() : "";
function render(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)}`
: st.code === 0 ? `Finished ${fmtTime(st.finished)}`
: st.code == null ? "" : `Failed (exit ${st.code}) ${fmtTime(st.finished)}. See the 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 res = await fetch("/run", { method: "POST" });
if (res.status === 409) statusEl.textContent = "A run is already in progress.";
refresh();
});
refresh();
</script>
</body>
</html>
+406
View File
@@ -0,0 +1,406 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · MEK Charge Reconciliation</title>
<style>
:root {
--night: #040b26;
--panel: #0c1638;
--panel-line: #26335e;
--field: #050c28;
--field-line: #56689c;
--pill-line: #c9d5f2;
--ink: #eef2ff;
--ink-2: #a9b6d8;
--owl: #ec7d33;
--owl-soft: #f5a25a;
--error: #ff9a9a;
--owl-w: 190px;
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
color-scheme: dark;
scrollbar-color: var(--panel-line) var(--night);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--night);
color: var(--ink);
font: 15px/1.5 var(--ui);
caret-color: var(--owl);
}
::selection { background: var(--owl); color: var(--night); }
.sky { position: fixed; inset: 0; overflow: hidden; pointer-events: none; }
.sky span {
position: absolute;
width: var(--s);
height: var(--s);
border-radius: 50%;
background: var(--c);
opacity: var(--o);
}
.sky .tw { animation: twinkle 4s ease-in-out infinite; animation-delay: var(--d); }
@keyframes twinkle { 50% { opacity: .15; } }
.stage {
position: relative;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
/* Anchored near the middle rather than centered, so an error line grows the card downward instead of moving the owl. */
padding: max(32px, calc(50vh - 230px)) 16px 48px;
text-align: center;
}
h1 {
margin: 0;
font-size: clamp(20px, 5vw, 23px);
font-weight: 650;
line-height: 1.2;
letter-spacing: -.01em;
text-wrap: balance;
text-align: center;
}
.sub { margin: 4px 0 22px; color: var(--ink-2); text-align: center; }
.card {
position: relative;
width: min(360px, 100%);
margin-top: calc(var(--owl-w) * 50 / 48 + 12px);
padding: 28px 24px 24px;
background: var(--panel);
border: 1px solid var(--panel-line);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(0, 3, 15, .5);
text-align: left;
}
.owl {
position: absolute;
left: 50%;
bottom: calc(100% - 7px);
width: var(--owl-w);
height: auto;
margin-left: calc(var(--owl-w) / -2);
overflow: visible;
}
label { display: block; font-weight: 600; margin-bottom: 8px; }
.field { position: relative; }
input {
width: 100%;
height: 46px;
padding: 0 52px 0 20px;
background: var(--field);
color: var(--ink);
border: 1px solid var(--field-line);
border-radius: 999px;
font: 16px var(--ui);
letter-spacing: .04em;
}
input:focus-visible { outline: 2px solid var(--owl); outline-offset: 2px; border-color: var(--owl); }
input[aria-invalid="true"] { border-color: var(--error); outline-color: var(--error); }
.reveal {
position: absolute;
top: 4px;
right: 4px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
background: none;
border: 0;
border-radius: 50%;
color: var(--ink-2);
cursor: pointer;
}
.reveal:hover { color: var(--ink); background: rgba(201, 213, 242, .1); }
.reveal:focus-visible { outline: 2px solid var(--owl); outline-offset: 0; color: var(--ink); }
.reveal svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.note, .error { margin: 8px 0 0; font-size: 14px; }
.note { color: var(--owl-soft); }
.error { color: var(--error); }
.error:empty { margin: 0; }
.pill {
width: 100%;
height: 46px;
margin-top: 20px;
background: #0a1435;
color: #fff;
border: 1.5px solid var(--pill-line);
border-radius: 999px;
font: 600 15px var(--ui);
letter-spacing: .02em;
cursor: pointer;
transition: border-color .15s ease-out, background-color .15s ease-out;
}
.pill:hover { border-color: var(--owl); background: #101d45; }
.pill:active { transform: translateY(1px); }
.pill:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
.done h2 { margin: 0; font-size: 20px; }
.done h2:focus { outline: none; }
.done p { margin: 6px 0 0; color: var(--ink-2); }
/* Owl. Origins are in the SVG's own units (viewBox 0 0 48 50). */
#bob, #head, #glasses, #wing-l, #wing-r { transform-box: view-box; }
#bob { transform-origin: 24px 30px; }
#head { transform-origin: 24px 30px; }
#glasses { transform-origin: 24px 24px; }
#wing-l { transform-origin: 11px 25px; }
#wing-r { transform-origin: 37px 25px; }
.iris { transition: transform .14s ease-out; }
.lid { transform-box: fill-box; transform-origin: 50% 0; transform: scaleY(0); transition: transform .08s ease-out; }
.owl.blink .lid { transform: scaleY(1); }
.smile { transform-box: fill-box; transform-origin: 50% 100%; transform: scaleY(0); transition: transform .12s ease-out; }
.owl.happy .smile { transform: none; }
.owl.idle #bob { animation: breathe 3.2s ease-in-out infinite; }
@keyframes breathe { 50% { transform: translateY(.4px); } }
.owl.sad #head { animation: droop 1.4s ease-in-out; }
@keyframes droop { 20%, 75% { transform: translateY(1.2px) rotate(-6deg); } }
.owl.sad .lid { animation: slow-blink 1.4s ease-in-out; }
@keyframes slow-blink { 30%, 55% { transform: scaleY(1); } }
.eye-rim { transition: stroke .15s ease-out, filter .15s ease-out; }
.owl.peek .eye-rim { stroke: #ffc400; stroke-width: 1.5; filter: drop-shadow(0 0 2px #ffd84d) drop-shadow(0 0 5px #ffb300); }
#glasses rect { transition: fill .15s ease-out, fill-opacity .15s ease-out, filter .15s ease-out; }
.owl.peek #glasses rect { fill: #ffe066; fill-opacity: .45; filter: drop-shadow(0 0 3px #ffc400); }
.owl.tilt #head { animation: tilt .8s ease-in-out; }
@keyframes tilt { 25%, 70% { transform: rotate(-10deg); } }
.owl { cursor: pointer; }
.owl.happy, .owl.jump { animation: hop .75s cubic-bezier(.3, .6, .35, 1); }
@keyframes hop { 40% { transform: translateY(-14px); } 70% { transform: none; } 82% { transform: translateY(-3px); } }
.owl.happy #wing-l, .owl.jump #wing-l { animation: flap-l .75s ease-in-out; }
.owl.happy #wing-r, .owl.jump #wing-r { animation: flap-r .75s ease-in-out; }
@keyframes flap-l { 20%, 60% { transform: rotate(32deg); } 40%, 80% { transform: rotate(8deg); } }
@keyframes flap-r { 20%, 60% { transform: rotate(-32deg); } 40%, 80% { transform: rotate(-8deg); } }
@media (max-width: 420px) {
:root { --owl-w: 160px; }
}
@media (prefers-reduced-motion: reduce) {
.sky .tw, .owl, .owl * { animation: none !important; transition: none !important; }
.pill { transition: none; }
}
</style>
</head>
<body>
<div class="sky" id="sky" aria-hidden="true"></div>
<main class="stage">
<section class="card">
<svg class="owl idle" id="owl" viewBox="0 0 48 50" aria-hidden="true">
<defs>
<radialGradient id="coat" cx="42%" cy="28%" r="78%">
<stop stop-color="#ff990e"/><stop offset=".6" stop-color="#f58200"/><stop offset="1" stop-color="#bd4b00"/>
</radialGradient>
<linearGradient id="wing" x2=".8" y2="1">
<stop stop-color="#ed7b00"/><stop offset="1" stop-color="#a73e00"/>
</linearGradient>
<radialGradient id="glow">
<stop offset="0" stop-color="#ffaa5a" stop-opacity=".35"/><stop offset="1" stop-color="#ffaa5a" stop-opacity="0"/>
</radialGradient>
<clipPath id="eye-l"><ellipse cx="17" cy="20" rx="4" ry="5"/></clipPath>
<clipPath id="eye-r"><ellipse cx="31" cy="20" rx="4" ry="5"/></clipPath>
</defs>
<ellipse cx="24" cy="48.5" rx="17" ry="2.6" fill="url(#glow)"/>
<g stroke="#38200b" stroke-width="1.2" stroke-linejoin="round">
<path id="wing-l" fill="url(#wing)" d="M10 24 Q5 24 4 32 Q3 38 8 39 L12 34Z"/>
<path id="wing-r" fill="url(#wing)" d="M38 24 Q43 24 44 32 Q45 38 40 39 L36 34Z"/>
<path fill="url(#coat)" d="M8 27 C8 18 7 11 13 6 Q17 2 24 2 Q31 2 35 6 C41 11 40 18 40 27 Q42 38 35 43 Q30 47 24 47 Q17 47 13 43 Q6 38 8 27Z"/>
<path fill="none" stroke="#ffbe42" stroke-width="1.25" stroke-linecap="round"
d="M13 33 l1.5 2 l1.5 -2 M19 33 l1.5 2 l1.5 -2 M25 33 l1.5 2 l1.5 -2 M31 33 l1.5 2 l1.5 -2 M16 38 l1.5 2 l1.5 -2 M22 38 l1.5 2 l1.5 -2 M28 38 l1.5 2 l1.5 -2 M20 43 l1.5 1 l1.5 -1 M26 43 l1.5 1 l1.5 -1"/>
<path fill="none" stroke="#ac4904" stroke-width="1" d="M12 8 Q18 6 24 14 Q21 6 18 4 M24 13 Q25 6 30 4 M26 13 Q31 7 36 9"/>
<g id="bob"><g id="head">
<path fill="#ffe8b3" stroke-width="1" d="M24 14 C19 8 11 11 11 18 V26 Q11 30 17 30 Q21 30 24 28 Q27 30 31 30 Q37 30 37 26 V18 C37 11 29 8 24 14Z"/>
<g clip-path="url(#eye-l)">
<ellipse cx="17" cy="20" rx="4" ry="5" fill="#fffdf4" stroke="none"/>
<g class="iris"><ellipse cx="17" cy="21" rx="2.7" ry="3.3" fill="#6b3518" stroke="none"/><ellipse cx="17" cy="21.5" rx="1.8" ry="2.5" fill="#170e08" stroke="none"/><circle cx="16.3" cy="19.5" r="1" fill="#fff" stroke="none"/></g>
<rect class="lid" x="13" y="15" width="8" height="10" fill="#ffe8b3" stroke="none"/>
<g class="smile"><path fill="#ffe8b3" stroke="none" d="M13 21Q17 16.5 21 21L21 25L13 25Z"/><path fill="none" stroke="#6b3518" stroke-width=".7" stroke-linecap="round" d="M13.6 20.6Q17 17 20.4 20.6"/></g>
</g>
<ellipse class="eye-rim" cx="17" cy="20" rx="4" ry="5" fill="none" stroke-width="1"/>
<g clip-path="url(#eye-r)">
<ellipse cx="31" cy="20" rx="4" ry="5" fill="#fffdf4" stroke="none"/>
<g class="iris"><ellipse cx="31" cy="21" rx="2.7" ry="3.3" fill="#6b3518" stroke="none"/><ellipse cx="31" cy="21.5" rx="1.8" ry="2.5" fill="#170e08" stroke="none"/><circle cx="30.3" cy="19.5" r="1" fill="#fff" stroke="none"/></g>
<rect class="lid" x="27" y="15" width="8" height="10" fill="#ffe8b3" stroke="none"/>
<g class="smile"><path fill="#ffe8b3" stroke="none" d="M27 21Q31 16.5 35 21L35 25L27 25Z"/><path fill="none" stroke="#6b3518" stroke-width=".7" stroke-linecap="round" d="M27.6 20.6Q31 17 34.4 20.6"/></g>
</g>
<ellipse class="eye-rim" cx="31" cy="20" rx="4" ry="5" fill="none" stroke-width="1"/>
<g id="glasses" fill="none" stroke="#2a180c" stroke-width="1">
<rect x="12" y="22" width="10" height="6" rx="1.5"/>
<rect x="26" y="22" width="10" height="6" rx="1.5"/>
<path d="M22 23 Q24 21 26 23 M10 21 L12 23 M36 23 L38 21"/>
</g>
<path fill="#eb870a" stroke-width=".8" d="M22 26 Q24 24 26 26 L24 30Z"/>
</g></g>
<path fill="#ed900f" stroke-width="1" d="M16 45 L14 48 Q17 49 20 48 L19 46Z M29 46 L28 48 Q31 49 34 48 L32 45Z"/>
</g>
</svg>
<h1>MEK Charge Reconciliation</h1>
<p class="sub" id="sub">Sign in</p>
<form id="login" method="post" action="/login" novalidate>
<label for="password">Password</label>
<div class="field">
<input id="password" name="password" type="password" autocomplete="current-password" autofocus
aria-describedby="password-msg caps" required>
<button type="button" class="reveal" id="reveal" aria-label="Show password" aria-pressed="false"></button>
</div>
<p class="note" id="caps" aria-live="polite" hidden>Caps Lock is on</p>
<p class="error" id="password-msg" role="alert"></p>
<button type="submit" class="pill">Sign in</button>
</form>
</section>
</main>
<script>
// Password is checked on the server (APP_PASSWORD); nothing secret lives in this page.
const EYE = '<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>';
const EYE_OFF = '<path d="M4 4l16 16"/><path d="M9.9 5.7A10 10 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.7 7.2C4 9 2.5 12 2.5 12s3.5 6.5 9.5 6.5a9.6 9.6 0 0 0 4.7-1.2"/><path d="M10 10.1a2.8 2.8 0 0 0 3.9 3.9"/>';
const owl = document.getElementById("owl");
const form = document.getElementById("login");
const pw = document.getElementById("password");
const reveal = document.getElementById("reveal");
const caps = document.getElementById("caps");
const msg = document.getElementById("password-msg");
const irises = owl.querySelectorAll(".iris");
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
// Seeded so the stars stay put; none behind the text, owl or card.
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());
let html = "";
for (let i = 0; i < 60; i++) {
const tw = rand() < .35, x = rand() * innerWidth, y = rand() * innerHeight;
const size = (1.5 + rand() * 1.5).toFixed(1), color = colors[Math.floor(rand() * 4)];
const alpha = (.45 + rand() * .45).toFixed(2), delay = (rand() * 4).toFixed(2);
if (keepOut.some(r => x > r.left - 10 && x < r.right + 10 && y > r.top - 10 && y < r.bottom + 10)) continue;
html += `<span class="${tw ? "tw" : ""}" style="left:${x.toFixed(0)}px;top:${y.toFixed(0)}px;` +
`--s:${size}px;--c:${color};--o:${alpha};--d:${delay}s"></span>`;
}
document.getElementById("sky").innerHTML = html;
}
drawSky();
addEventListener("resize", drawSky);
function look(dx, dy) {
irises.forEach(g => { g.style.transform = `translate(${dx}px, ${dy}px)`; });
}
// Pupils slide from the left end of the field to the right as the typed text grows.
const measure = document.createElement("canvas").getContext("2d");
function followCaret() {
if (document.activeElement !== pw) return look(0, 0);
const cs = getComputedStyle(pw);
measure.font = cs.font;
measure.letterSpacing = cs.letterSpacing;
const caret = pw.selectionStart ?? pw.value.length;
const shown = pw.type === "password" ? "•".repeat(caret) : pw.value.slice(0, caret);
const room = pw.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const ratio = Math.min(measure.measureText(shown).width / room, 1);
look(-1.2 + 2.4 * ratio, 1.2);
}
const timers = {};
function play(name, ms, then) {
clearTimeout(timers[name]);
owl.classList.remove(name);
void owl.getBoundingClientRect();
owl.classList.add(name);
timers[name] = setTimeout(() => { owl.classList.remove(name); then?.(); }, ms);
}
// Cleared first so a repeated identical message is announced again.
function showError(text) {
msg.textContent = "";
pw.setAttribute("aria-invalid", text ? "true" : "false");
if (text) setTimeout(() => { msg.textContent = text; }, 50);
}
function setReveal(show) {
pw.type = show ? "text" : "password";
reveal.setAttribute("aria-pressed", String(show));
reveal.setAttribute("aria-label", show ? "Hide password" : "Show password");
owl.classList.toggle("peek", show);
reveal.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${show ? EYE_OFF : EYE}</svg>`;
}
setReveal(false);
for (const ev of ["focus", "blur", "input", "keyup", "click", "select"]) pw.addEventListener(ev, followCaret);
pw.addEventListener("input", () => { if (msg.textContent) showError(""); });
for (const ev of ["keydown", "keyup"]) {
pw.addEventListener(ev, e => { caps.hidden = !e.getModifierState?.("CapsLock"); });
}
pw.addEventListener("blur", () => { caps.hidden = true; });
// Keep focus (and the owl's gaze) in the field when the eye button is clicked.
reveal.addEventListener("mousedown", e => e.preventDefault());
reveal.addEventListener("click", () => { setReveal(pw.type === "password"); followCaret(); });
form.addEventListener("submit", async e => {
e.preventDefault();
if (!pw.value) {
showError("Enter your password.");
play("tilt", 800);
pw.focus();
return;
}
let res;
try {
res = await fetch("/login", { method: "POST", body: new FormData(form) });
} catch {
showError("Can't reach the server. Try again.");
return;
}
if (!res.ok) {
showError(res.status === 401 ? "That password isn't right. Try again." : "Sign-in failed. Try again.");
pw.focus();
pw.select();
play("sad", 1400, followCaret);
return;
}
showError("");
pw.value = "";
setReveal(false);
const enter = () => { location.href = "/"; };
if (reducedMotion.matches) enter();
else play("happy", 750, enter);
});
// Tap the owl and it jumps.
owl.addEventListener("click", () => { if (!reducedMotion.matches) play("jump", 750); });
(function blink() {
setTimeout(() => {
if (!reducedMotion.matches && !owl.classList.contains("happy") && !owl.classList.contains("sad")) play("blink", 160);
blink();
}, 2500 + Math.random() * 3500);
})();
</script>
</body>
</html>