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:
commit
fd480fa874
10 files changed
+1335
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# secrets
|
||||
.env
|
||||
# patient data (PHI) - never commit
|
||||
processing/
|
||||
output/
|
||||
logs/
|
||||
# browser profiles / caches
|
||||
.chrome_profile*/
|
||||
__pycache__/
|
||||
.ruff_cache/
|
||||
.git/
|
||||
.claude/
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# secrets
|
||||
.env
|
||||
# patient data (PHI) - never commit
|
||||
processing/
|
||||
output/
|
||||
logs/
|
||||
# browser profiles / caches
|
||||
.chrome_profile*/
|
||||
__pycache__/
|
||||
.ruff_cache/
|
||||
.claude/
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Playwright image ships Chromium + its OS deps for this exact Playwright version
|
||||
FROM mcr.microsoft.com/playwright/python:v1.49.0-noble
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# headless, bundled Chromium (no Google Chrome in the image)
|
||||
ENV HEADLESS=1 CHROME_CHANNEL= PYTHONUNBUFFERED=1 TZ=America/New_York
|
||||
EXPOSE 8000
|
||||
# one worker: run state is kept in memory
|
||||
CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:8000", "--timeout", "120", "app:app"]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Web front end: sign in -> Run -> download per-location reports or a ZIP of all.
|
||||
|
||||
Env: APP_PASSWORD (sign-in password), SECRET_KEY (session signing; random if unset),
|
||||
COOKIE_SECURE=1 behind HTTPS, plus the Waystar/Aquilaehr logins mek_recon.py reads.
|
||||
Run locally: python app.py -> http://127.0.0.1:8000
|
||||
"""
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, jsonify, redirect, render_template, request, send_file, session
|
||||
|
||||
import consolidate
|
||||
import mek_recon
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
OUT = ROOT / "output"
|
||||
LOG = ROOT / "logs" / "run.log"
|
||||
|
||||
mek_recon.load_env() # local .env; on the server the values come from the environment
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE") == "1",
|
||||
PERMANENT_SESSION_LIFETIME=12 * 3600,
|
||||
)
|
||||
|
||||
# ponytail: one run at a time, state in memory - fine for one gunicorn worker; restart forgets the last run
|
||||
run = {"proc": None, "started": None, "finished": None, "code": None}
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def signed_in(view):
|
||||
@wraps(view)
|
||||
def wrapper(*a, **kw):
|
||||
if not session.get("ok"):
|
||||
return (jsonify(error="sign in"), 401) if request.path != "/" else render_template("login.html")
|
||||
return view(*a, **kw)
|
||||
return wrapper
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login():
|
||||
expected = os.environ.get("APP_PASSWORD", "")
|
||||
given = request.form.get("password", "")
|
||||
if not expected or not hmac.compare_digest(given.encode(), expected.encode()):
|
||||
time.sleep(1) # slow down guessing
|
||||
return jsonify(error="wrong password"), 401
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["ok"] = True
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect("/")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@signed_in
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
def reports():
|
||||
return sorted(OUT.glob("*.xlsx"), key=lambda f: (f.name != consolidate.ALL_FILE, f.name.lower()))
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
@signed_in
|
||||
def start_run():
|
||||
with lock:
|
||||
if run["proc"] and run["proc"].poll() is None:
|
||||
return jsonify(error="already running"), 409
|
||||
# start clean: a location that fails must not leave last run's file looking current
|
||||
for f in [*ROOT.glob("processing/*/*.csv"), *OUT.glob("*.xlsx")]:
|
||||
f.unlink()
|
||||
LOG.parent.mkdir(exist_ok=True)
|
||||
env = {**os.environ, "HEADLESS": os.environ.get("HEADLESS", "1"),
|
||||
"PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
|
||||
with LOG.open("w", encoding="utf-8") as log:
|
||||
run["proc"] = subprocess.Popen([sys.executable, "mek_recon.py"], cwd=ROOT, env=env,
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
run.update(started=time.time(), finished=None, code=None)
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
@signed_in
|
||||
def status():
|
||||
proc = run["proc"]
|
||||
running = bool(proc and proc.poll() is None)
|
||||
if proc and not running and run["finished"] is None:
|
||||
run.update(finished=time.time(), code=proc.returncode)
|
||||
log = LOG.read_text(encoding="utf-8", errors="replace") if LOG.exists() else ""
|
||||
return jsonify(
|
||||
running=running, started=run["started"], finished=run["finished"], code=run["code"],
|
||||
log="\n".join(log.splitlines()[-300:]),
|
||||
files=[{"name": f.name, "size": f.stat().st_size, "mtime": f.stat().st_mtime} for f in reports()],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/download/<name>")
|
||||
@signed_in
|
||||
def download(name):
|
||||
match = next((f for f in reports() if f.name == name), None) # only files we list
|
||||
if not match:
|
||||
abort(404)
|
||||
return send_file(match, as_attachment=True, download_name=match.name)
|
||||
|
||||
|
||||
@app.get("/download-all")
|
||||
@signed_in
|
||||
def download_all():
|
||||
files = reports()
|
||||
if not files:
|
||||
abort(404)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for f in files:
|
||||
z.write(f, f.name)
|
||||
buf.seek(0)
|
||||
stamp = time.strftime("%Y-%m-%d_%H%M", time.localtime(max(f.stat().st_mtime for f in files)))
|
||||
return send_file(buf, as_attachment=True, download_name=f"MEK_Charge_Reconciliation_{stamp}.zip",
|
||||
mimetype="application/zip")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 8000)))
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
"""Per-location reconciliation: Aquilaehr visits vs Waystar claims, matched on
|
||||
Patient Name + Date of Service. Writes output/<Location>.xlsx.
|
||||
|
||||
Aquilaehr: Patient ("Mary Lopez Garcia") + Visit Date
|
||||
Waystar: Patient Name ("LOPEZ, MARY") + From Date
|
||||
Match = same date, same first name, and at least one last-name word in common.
|
||||
|
||||
Run: python consolidate.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
WAYSTAR_DIR = ROOT / "processing" / "Waystar"
|
||||
AQUILAEHR_DIR = ROOT / "processing" / "Aquilaehr"
|
||||
OUT_DIR = ROOT / "output"
|
||||
SUFFIXES = {"JR", "SR", "II", "III", "IV"}
|
||||
|
||||
|
||||
def words(s):
|
||||
return [w for w in str(s).upper().replace(",", " ").replace(".", " ").split() if w not in SUFFIXES]
|
||||
|
||||
|
||||
def join(values):
|
||||
return ", ".join(dict.fromkeys(v for v in values if isinstance(v, str) and v.strip()))
|
||||
|
||||
|
||||
def load_aquilaehr(path):
|
||||
a = pd.read_csv(path, dtype=str).fillna("")
|
||||
a["DOS"] = pd.to_datetime(a["Visit Date"], errors="coerce").dt.date
|
||||
a["Name"] = a["Patient"].str.split().str.join(" ")
|
||||
# one row per note/template -> one row per patient visit
|
||||
v = a.groupby(["Account #", "Name", "DOS"], as_index=False).agg(
|
||||
**{"Aquilaehr Claim ID": ("Claim ID", join), "Note Status": ("Note Status", join),
|
||||
"Claim Status": ("Claim Status", join), "Provider": ("Provider", lambda s: join(" ".join(x.split()) for x in s))}
|
||||
)
|
||||
t = v["Name"].map(words)
|
||||
v["first"] = t.str[0].fillna("")
|
||||
v["last"] = t.str[1:].map(set)
|
||||
return v
|
||||
|
||||
|
||||
def load_waystar(path):
|
||||
w = pd.read_csv(path, skiprows=7, dtype=str, index_col=False).fillna("")
|
||||
for c in w.columns: # Excel-friendly CSV wraps values like ="..."
|
||||
w[c] = w[c].str.replace(r'^="(.*)"$', r"\1", regex=True)
|
||||
w["DOS"] = pd.to_datetime(w["From Date"], errors="coerce").dt.date
|
||||
w["Charges"] = pd.to_numeric(w["Charges"].str.replace(r"[$,]", "", regex=True), errors="coerce").fillna(0)
|
||||
w["Claim ID ref"] = w["Claim Number"].str.split().str[0]
|
||||
v = w.groupby(["Patient Name", "DOS"], as_index=False).agg(
|
||||
**{"Waystar Claim Number": ("Claim Number", join), "Charges": ("Charges", "sum"),
|
||||
"Waystar Status": ("Status", join), "Claim ID ref": ("Claim ID ref", join)}
|
||||
)
|
||||
parts = v["Patient Name"].str.partition(",") # "LAST, FIRST MI"
|
||||
v["first"] = [(words(f) or [""])[0] for f in parts[2]]
|
||||
v["last"] = [set(words(n)) for n in parts[0]]
|
||||
return v
|
||||
|
||||
|
||||
def reconcile(a, w):
|
||||
rows, used = [], set()
|
||||
a_by_key = {}
|
||||
for i, r in a.iterrows():
|
||||
a_by_key.setdefault((r["first"], r["DOS"]), []).append(i)
|
||||
a_claim_dates = {}
|
||||
for _, r in a.iterrows():
|
||||
for cid in r["Aquilaehr Claim ID"].split(", "):
|
||||
if cid:
|
||||
a_claim_dates.setdefault(cid, set()).add(r["DOS"])
|
||||
w_claim_dates = {}
|
||||
for _, r in w.iterrows():
|
||||
for cid in r["Claim ID ref"].split(", "):
|
||||
if cid:
|
||||
w_claim_dates.setdefault(cid, set()).add(r["DOS"])
|
||||
|
||||
def fmt(dates):
|
||||
return ", ".join(d.strftime("%m/%d/%Y") for d in sorted(dates) if pd.notna(d))
|
||||
|
||||
for _, r in w.iterrows():
|
||||
same = [i for i in a_by_key.get((r["first"], r["DOS"]), []) if a.at[i, "last"] & r["last"]]
|
||||
hit = next((i for i in same if i not in used), None)
|
||||
row = {"Patient Name": r["Patient Name"], "DOS": r["DOS"],
|
||||
"Waystar Claim Number": r["Waystar Claim Number"], "Charges": r["Charges"],
|
||||
"Waystar Status": r["Waystar Status"]}
|
||||
if hit is not None:
|
||||
used.add(hit)
|
||||
row.update(Result="Matched", **a.loc[hit, ["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]])
|
||||
row["Aquilaehr Patient"] = a.at[hit, "Name"]
|
||||
elif same: # visit already matched to another Waystar claim
|
||||
row.update(Result="Duplicate in Waystar", **a.loc[same[0], ["Aquilaehr Claim ID", "Claim Status"]])
|
||||
row["Aquilaehr Patient"] = a.at[same[0], "Name"]
|
||||
row["Note"] = "Same patient + DOS already matched to another Waystar claim"
|
||||
else:
|
||||
row["Result"] = "Missing in Aquilaehr"
|
||||
other = set().union(*(a_claim_dates.get(c, set()) for c in r["Claim ID ref"].split(", ")))
|
||||
if other:
|
||||
row["Note"] = f"Claim ID {r['Claim ID ref']} is in Aquilaehr with Visit Date {fmt(other)}"
|
||||
rows.append(row)
|
||||
|
||||
for i, r in a.iterrows():
|
||||
if i in used:
|
||||
continue
|
||||
row = {"Patient Name": r["Name"], "DOS": r["DOS"], "Result": "Missing in Waystar",
|
||||
"Aquilaehr Patient": r["Name"], **r[["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]]}
|
||||
other = set().union(*(w_claim_dates.get(c, set()) for c in r["Aquilaehr Claim ID"].split(", ") if c))
|
||||
if other:
|
||||
row["Note"] = f"Claim ID {r['Aquilaehr Claim ID']} is in Waystar with DOS {fmt(other)}"
|
||||
rows.append(row)
|
||||
|
||||
cols = ["Result", "Patient Name", "DOS", "Aquilaehr Patient", "Aquilaehr Claim ID", "Note Status",
|
||||
"Claim Status", "Provider", "Waystar Claim Number", "Charges", "Waystar Status", "Note"]
|
||||
order = {"Missing in Waystar": 0, "Missing in Aquilaehr": 1, "Duplicate in Waystar": 2, "Matched": 3}
|
||||
df = pd.DataFrame(rows).reindex(columns=cols)
|
||||
return df.sort_values(["Result", "Patient Name", "DOS"], key=lambda s: s.map(order) if s.name == "Result" else s)
|
||||
|
||||
|
||||
def write_report(df, location, out):
|
||||
counts = df["Result"].value_counts()
|
||||
summary = pd.DataFrame({
|
||||
"Item": ["Location", "Aquilaehr visits", "Waystar visits", "Matched",
|
||||
"Missing in Waystar", "Missing in Aquilaehr", "Duplicate in Waystar"],
|
||||
"Value": [location, int(counts.get("Matched", 0) + counts.get("Missing in Waystar", 0)),
|
||||
int((df["Result"] != "Missing in Waystar").sum()), int(counts.get("Matched", 0)),
|
||||
int(counts.get("Missing in Waystar", 0)), int(counts.get("Missing in Aquilaehr", 0)),
|
||||
int(counts.get("Duplicate in Waystar", 0))],
|
||||
})
|
||||
write_xlsx(out, summary, df)
|
||||
return summary
|
||||
|
||||
|
||||
def write_xlsx(out, summary, df):
|
||||
with pd.ExcelWriter(out, engine="openpyxl") as xw:
|
||||
summary.to_excel(xw, sheet_name="Summary", index=False)
|
||||
df.to_excel(xw, sheet_name="Reconciliation", index=False)
|
||||
for ws in xw.book.worksheets:
|
||||
ws.freeze_panes = "A2"
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
for col in ws.columns:
|
||||
width = max(len(str(c.value or "")) for c in col[:200])
|
||||
ws.column_dimensions[col[0].column_letter].width = min(max(width + 2, 10), 60)
|
||||
ws = xw.book["Reconciliation"]
|
||||
for col in ws.iter_cols(min_row=1, max_row=ws.max_row):
|
||||
fmt = {"DOS": "mm/dd/yyyy", "Charges": '"$"#,##0.00'}.get(col[0].value)
|
||||
for c in col[1:] if fmt else ():
|
||||
c.number_format = fmt
|
||||
|
||||
|
||||
ALL_FILE = "All Locations.xlsx"
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
summaries, frames = [], []
|
||||
for wf in sorted(WAYSTAR_DIR.glob("*.csv")):
|
||||
af = AQUILAEHR_DIR / wf.name
|
||||
if not af.exists():
|
||||
print(f"SKIP {wf.stem}: no Aquilaehr file")
|
||||
continue
|
||||
df = reconcile(load_aquilaehr(af), load_waystar(wf))
|
||||
summary = write_report(df, wf.stem, OUT_DIR / f"{wf.stem}.xlsx")
|
||||
s = summary["Value"].tolist()
|
||||
print(f"{wf.stem:40} Aquilaehr={s[1]:5} Waystar={s[2]:5} Matched={s[3]:5} "
|
||||
f"Missing in Waystar={s[4]:4} Missing in Aquilaehr={s[5]:4} Duplicate in Waystar={s[6]:3}")
|
||||
if s[1] and s[2] and s[3] < 0.5 * min(s[1], s[2]):
|
||||
print(f" WARNING {wf.stem}: under half the visits match - wrong practice data downloaded?")
|
||||
summaries.append(summary.set_index("Item")["Value"])
|
||||
frames.append(df.assign(Location=wf.stem))
|
||||
|
||||
if frames: # one overall workbook: a row per location + every location's rows
|
||||
overall = pd.DataFrame(summaries).reset_index(drop=True)
|
||||
total = overall.drop(columns="Location").sum().to_dict()
|
||||
overall = pd.concat([overall, pd.DataFrame([{"Location": "TOTAL", **total}])], ignore_index=True)
|
||||
rows = pd.concat(frames, ignore_index=True)
|
||||
write_xlsx(OUT_DIR / ALL_FILE, overall, rows[["Location", *rows.columns[:-1]]])
|
||||
print(f"{'ALL LOCATIONS':40} -> output/{ALL_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
"""MEK Charge reconciliation process.
|
||||
|
||||
Waystar (ZirMed): for each location -> switch account -> Claims -> Status All,
|
||||
Transaction Date All -> Search -> Download CSV -> processing/Waystar/<location>.csv
|
||||
Aquilaehr: Reports -> Encounter & Claim Monitor -> pick practice -> Export CSV
|
||||
-> processing/Aquilaehr/<location>.csv (same file name as Waystar so the pair lines up)
|
||||
Then consolidate.py matches Patient Name + DOS per location -> output/<location>.xlsx
|
||||
|
||||
Run: python mek_recon.py both sites at the same time, all locations
|
||||
python mek_recon.py Aquilaehr only Aquilaehr
|
||||
python mek_recon.py Waystar "Healing Bridge" only Waystar, only that location
|
||||
Login is automatic from .env (WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR,
|
||||
WAYSTAR_ANSWER_JOB, AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD). If something is
|
||||
missing/unexpected, finish the login by hand in Chrome; the script waits.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from multiprocessing import Process
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
import consolidate
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
WAYSTAR, AQUILAEHR = "Waystar", "Aquilaehr"
|
||||
OUT = {site: ROOT / "processing" / site for site in (WAYSTAR, AQUILAEHR)}
|
||||
|
||||
# Waystar account name -> Aquilaehr practice dropdown names.
|
||||
# Several Aquilaehr practices are exported and merged into one file.
|
||||
# Adelman Health is skipped on both sites (no Aquilaehr practice).
|
||||
LOCATIONS = {
|
||||
"Alpha Well-Being LLC": ["ALPHA WELL-BEING LLC"],
|
||||
"ARC Wound Care": ["ARC Woundcare LLC"],
|
||||
"Cynthia DM Concierge NP Services LLC": ["Moona Concierge Nurse Practitioner Services LLC"],
|
||||
"Healing Bridge": ["HEALING BRIDGE LLC"],
|
||||
"Joy Solutions LLC": ["Joy Solutions CORP"],
|
||||
"Lunas Home NP LLC": ["LUNAS HOME NP LLC"],
|
||||
"Stem Regen NJ": ["Stem Regen Medical"],
|
||||
"Sunwave Woundcare": ["Sunwave Woundcare"],
|
||||
"WoundDocs LLC": ["WOUNDOCS LLC", "WOUNDOCS LLC - 2"],
|
||||
"Wound Pros of FL": ["Wound Professionals of Florida LLC"],
|
||||
}
|
||||
|
||||
WAYSTAR_LOGIN = "https://login.zirmed.com/ui"
|
||||
WAYSTAR_CLAIMS = "https://claims.zirmed.com/Claims/Listing/Index?appid=1"
|
||||
AQUILAEHR_REPORTS = "https://platform.aquilaehr.com/#/reports/clinical"
|
||||
# time to finish a login by hand; headless (server) runs have no hand, so fail sooner
|
||||
LOGIN_WAIT_MS = (3 if os.environ.get("HEADLESS") == "1" else 10) * 60 * 1000
|
||||
|
||||
|
||||
def load_env():
|
||||
env = ROOT / ".env"
|
||||
if env.exists():
|
||||
for line in env.read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line and not line.lstrip().startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip().strip('"'))
|
||||
|
||||
|
||||
def log(site, msg):
|
||||
print(f"{site:9} {msg}", flush=True)
|
||||
|
||||
|
||||
# ---------------- Waystar ----------------
|
||||
|
||||
def waystar_login(page):
|
||||
page.goto(WAYSTAR_LOGIN)
|
||||
account_box = page.locator("input.header-account-search-text")
|
||||
if os.environ.get("WAYSTAR_USERNAME") and page.locator("#loginName").is_visible():
|
||||
page.fill("#loginName", os.environ["WAYSTAR_USERNAME"])
|
||||
page.fill("#password", os.environ.get("WAYSTAR_PASSWORD", ""))
|
||||
page.click("#loginButton")
|
||||
log(WAYSTAR, "login submitted")
|
||||
|
||||
# pages Waystar may show after "Log in" (ids taken from the G2 ChargeBilledAutomation bot)
|
||||
answers = {"first car": os.environ.get("WAYSTAR_ANSWER_CAR"), "first job": os.environ.get("WAYSTAR_ANSWER_JOB")}
|
||||
question = page.locator("#authDiv strong").first
|
||||
alert = page.locator("#continueButton") # "Proactive Alert" page
|
||||
sec_info = page.locator("h2", has_text="Security Information")
|
||||
end = time.time() + LOGIN_WAIT_MS / 1000
|
||||
while time.time() < end:
|
||||
if account_box.is_visible():
|
||||
log(WAYSTAR, "logged in")
|
||||
return
|
||||
if alert.is_visible():
|
||||
alert.click()
|
||||
log(WAYSTAR, "proactive alert continued")
|
||||
elif question.is_visible():
|
||||
text = question.inner_text().lower()
|
||||
key = next((k for k in answers if k in text), None)
|
||||
if not key or not answers[key]:
|
||||
raise RuntimeError(f"unknown Waystar security question: {text}")
|
||||
page.fill("#verifyAnswer", answers[key])
|
||||
page.click("#VerifyButton")
|
||||
log(WAYSTAR, f"security question ({key}) answered")
|
||||
elif sec_info.is_visible():
|
||||
page.click("#NextButton")
|
||||
page.get_by_text("Skip This Step", exact=True).click()
|
||||
log(WAYSTAR, "security information page skipped")
|
||||
page.wait_for_timeout(1000)
|
||||
raise RuntimeError("Waystar login did not finish in time")
|
||||
|
||||
|
||||
def waystar_switch_account(page, location):
|
||||
box = page.locator("input.header-account-search-text")
|
||||
box.click()
|
||||
box.fill("")
|
||||
box.press_sequentially(location, delay=50)
|
||||
items = page.locator("ul.ui-autocomplete li:visible")
|
||||
# dropdown text looks like "Healing Bridge (313858)"; it shows the first 25
|
||||
# accounts before the typed filter kicks in, so wait for the actual match
|
||||
match = items.filter(has_text=location).first
|
||||
try:
|
||||
match.wait_for(timeout=15000)
|
||||
except Exception:
|
||||
raise RuntimeError(f"no account match in dropdown: {items.all_inner_texts()}")
|
||||
match.click()
|
||||
page.wait_for_url("**/SuccessfulAccountChange/**", timeout=30000)
|
||||
|
||||
|
||||
def waystar_download(page, location):
|
||||
waystar_switch_account(page, location)
|
||||
page.goto(WAYSTAR_CLAIMS)
|
||||
page.select_option("#SearchCriteria_Status", label="All")
|
||||
page.select_option("#SearchCriteria_TransDate", label="All")
|
||||
page.click("#ClaimListingSearchButtonTop")
|
||||
page.get_by_text("Transaction Date: All").first.wait_for(timeout=60000)
|
||||
page.wait_for_load_state("networkidle")
|
||||
with page.expect_download(timeout=120000) as dl:
|
||||
page.click("#downloadCsvBtn")
|
||||
out = OUT[WAYSTAR] / f"{location}.csv"
|
||||
dl.value.save_as(out)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- Aquilaehr ----------------
|
||||
|
||||
AQUILAEHR_PRACTICE = ".ant-select[style*='200px']" # practice dropdown at the top of the page
|
||||
AQUILAEHR_EXPORT = "a[download]" # <a> inside the "Export CSV" button (blob URL)
|
||||
|
||||
|
||||
def aquilaehr_login(page):
|
||||
page.goto(AQUILAEHR_REPORTS)
|
||||
email = page.locator("#normal_login_email")
|
||||
logged_in = page.get_by_text("Reports", exact=True).first
|
||||
submitted = False
|
||||
end = time.time() + LOGIN_WAIT_MS / 1000
|
||||
# SPA: either the login form or the app shows up after a few seconds
|
||||
while time.time() < end:
|
||||
if logged_in.is_visible():
|
||||
log(AQUILAEHR, "logged in")
|
||||
return
|
||||
if not submitted and os.environ.get("AQUILAEHR_USERNAME") and email.is_visible():
|
||||
email.fill(os.environ["AQUILAEHR_USERNAME"])
|
||||
page.fill("#normal_login_password", os.environ.get("AQUILAEHR_PASSWORD", ""))
|
||||
page.click("button[type=submit]")
|
||||
submitted = True
|
||||
log(AQUILAEHR, "login submitted")
|
||||
page.wait_for_timeout(1000)
|
||||
raise RuntimeError("Aquilaehr login did not finish in time")
|
||||
|
||||
|
||||
def aquilaehr_download(page, location):
|
||||
"""Export every practice for this location and merge into one CSV (header once)."""
|
||||
out = OUT[AQUILAEHR] / f"{location}.csv"
|
||||
lines = []
|
||||
for practice in LOCATIONS[location]:
|
||||
part = aquilaehr_export(page, practice, OUT[AQUILAEHR] / f"{location}.part.csv")
|
||||
rows = part.read_text(encoding="utf-8-sig").splitlines()
|
||||
part.unlink()
|
||||
lines += rows if not lines else rows[1:]
|
||||
log(AQUILAEHR, f" {practice}: {len(rows) - 1} rows")
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
|
||||
return out
|
||||
|
||||
|
||||
def norm(s):
|
||||
return " ".join(s.split()).lower()
|
||||
|
||||
|
||||
def aquilaehr_select_practice(page, practice):
|
||||
# practice dropdown is a virtual list: scroll the open one until the option renders
|
||||
page.locator(AQUILAEHR_PRACTICE).click()
|
||||
page.locator(".ant-select-dropdown:not(.ant-select-dropdown-hidden)").wait_for()
|
||||
found = page.evaluate(
|
||||
"""async (title) => {
|
||||
const norm = s => (s || '').split(/\\s+/).join(' ').trim().toLowerCase();
|
||||
const dd = document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
|
||||
const h = dd.querySelector('.rc-virtual-list-holder');
|
||||
for (let y = 0; y <= h.scrollHeight + 100; y += 80) {
|
||||
h.scrollTop = y;
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
const o = [...dd.querySelectorAll('.ant-select-item-option')]
|
||||
.find(o => norm(o.getAttribute('title')) === norm(title));
|
||||
if (o) { o.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
}""",
|
||||
practice,
|
||||
)
|
||||
if not found:
|
||||
raise RuntimeError(f"practice '{practice}' not in dropdown")
|
||||
|
||||
|
||||
def aquilaehr_export(page, practice, out):
|
||||
# practice ids whose report data has arrived (GET .../reports/clinical_notes?practice_id=<id>)
|
||||
loaded = []
|
||||
|
||||
def on_response(r):
|
||||
m = re.search(r"/reports/clinical_notes\?practice_id=([\w-]+)", r.url)
|
||||
if m and r.ok:
|
||||
loaded.append(m.group(1))
|
||||
|
||||
page.on("response", on_response)
|
||||
try:
|
||||
# Reports -> Encounter & Claim Monitor tab
|
||||
page.goto(AQUILAEHR_REPORTS)
|
||||
page.get_by_text("Encounter & Claim Monitor", exact=True).first.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
current = page.locator(f"{AQUILAEHR_PRACTICE} .ant-select-selection-item")
|
||||
if norm(current.inner_text()) != norm(practice):
|
||||
before = page.url
|
||||
aquilaehr_select_practice(page, practice)
|
||||
page.wait_for_url(lambda u: u != before and "practiceId=" in u, timeout=30000)
|
||||
if norm(current.inner_text()) != norm(practice):
|
||||
raise RuntimeError(f"dropdown shows '{current.inner_text()}', expected '{practice}'")
|
||||
|
||||
# wait for THIS practice's data; data for the previous practice can still be arriving
|
||||
pid = re.search(r"practiceId=([\w-]+)", page.url)
|
||||
end = time.time() + 120
|
||||
while not (pid.group(1) in loaded if pid else loaded):
|
||||
if time.time() > end:
|
||||
raise RuntimeError(f"report data for '{practice}' did not load")
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(2000) # let the table + export link re-render
|
||||
|
||||
with page.expect_download(timeout=120000) as dl:
|
||||
page.locator(AQUILAEHR_EXPORT).click()
|
||||
dl.value.save_as(out)
|
||||
finally:
|
||||
page.remove_listener("response", on_response)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- runner ----------------
|
||||
|
||||
SITES = {
|
||||
WAYSTAR: (waystar_login, waystar_download),
|
||||
AQUILAEHR: (aquilaehr_login, aquilaehr_download),
|
||||
}
|
||||
|
||||
|
||||
def run_site(site, locations):
|
||||
"""One site in its own Chrome (own profile), so both sites can run at the same time."""
|
||||
load_env()
|
||||
OUT[site].mkdir(parents=True, exist_ok=True)
|
||||
login, download = SITES[site]
|
||||
failed = []
|
||||
seen = {} # file content -> location; two locations with identical exports = stale data
|
||||
with sync_playwright() as p:
|
||||
# server/Docker: HEADLESS=1 and CHROME_CHANNEL="" (Playwright's bundled Chromium)
|
||||
headless = os.environ.get("HEADLESS") == "1"
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
str(ROOT / f".chrome_profile_{site.lower()}"),
|
||||
channel=os.environ.get("CHROME_CHANNEL", "chrome") or None, headless=headless,
|
||||
accept_downloads=True, no_viewport=not headless,
|
||||
viewport={"width": 1440, "height": 900} if headless else None,
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
login(page)
|
||||
for loc in locations:
|
||||
try:
|
||||
out = download(page, loc)
|
||||
content = out.read_bytes()
|
||||
if content in seen:
|
||||
log(site, f"WARNING {loc}: same data as {seen[content]} - stale export?")
|
||||
seen[content] = loc
|
||||
log(site, f"OK {loc} -> {out.name} ({content.count(b'\n')} lines)")
|
||||
except Exception as e:
|
||||
failed.append(loc)
|
||||
log(site, f"FAIL {loc}: {e}")
|
||||
ctx.close()
|
||||
log(site, f"failed: {failed or 'none'}")
|
||||
|
||||
|
||||
def locked_files():
|
||||
"""Files open in Excel can't be overwritten -> the run would silently keep old data."""
|
||||
locked = []
|
||||
for f in [*ROOT.glob("processing/*/*.csv"), *ROOT.glob("output/*.xlsx")]:
|
||||
try:
|
||||
with open(f, "a"):
|
||||
pass
|
||||
except PermissionError:
|
||||
locked.append(f.relative_to(ROOT))
|
||||
return locked
|
||||
|
||||
|
||||
def main():
|
||||
locked = locked_files()
|
||||
if locked:
|
||||
print("Close these files in Excel first, then run again:")
|
||||
for f in locked:
|
||||
print(f" {f}")
|
||||
sys.exit(1)
|
||||
args = sys.argv[1:]
|
||||
sites = [args.pop(0)] if args and args[0] in SITES else list(SITES)
|
||||
locations = [loc for loc in LOCATIONS if not args or loc in args]
|
||||
if len(sites) == 1:
|
||||
run_site(sites[0], locations)
|
||||
else:
|
||||
procs = [Process(target=run_site, args=(site, locations)) for site in sites]
|
||||
for pr in procs:
|
||||
pr.start()
|
||||
for pr in procs:
|
||||
pr.join()
|
||||
print("Consolidating -> output/<Location>.xlsx", flush=True)
|
||||
consolidate.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
# MEK Charge reconciliation process
|
||||
|
||||
Sites:
|
||||
- Waystar (ZirMed) -> https://login.zirmed.com/ui
|
||||
- Aquilaehr -> https://platform.aquilaehr.com/
|
||||
|
||||
Run: `python mek_recon.py` -> both sites at the same time, each in its own Chrome window
|
||||
(own profile: .chrome_profile_waystar / .chrome_profile_aquilaehr).
|
||||
One site only: `python mek_recon.py Waystar` or `python mek_recon.py Aquilaehr`.
|
||||
Close processing/ and output/ files in Excel/WPS before running (the script stops and lists them otherwise).
|
||||
Full run verified 10/27/2026: both sites 10/10 locations, reports in output/ (~1 min).
|
||||
|
||||
Logins come from .env (values never stored in code):
|
||||
- Waystar: WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR, WAYSTAR_ANSWER_JOB
|
||||
-> fills #loginName / #password -> "Log in" -> security question page: answer by keyword
|
||||
("first car" / "first job") -> click "Verify"
|
||||
- Aquilaehr: AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD
|
||||
|
||||
## Locations (run the whole process for each, on BOTH sites)
|
||||
- ~~Adelman Health~~ (skipped)
|
||||
- Alpha Well-Being LLC
|
||||
- ARC Wound Care
|
||||
- Cynthia DM Concierge NP Services LLC
|
||||
- Healing Bridge
|
||||
- Joy Solutions LLC
|
||||
- Lunas Home NP LLC
|
||||
- Stem Regen NJ
|
||||
- Sunwave Woundcare
|
||||
- WoundDocs LLC
|
||||
- Wound Pros of FL
|
||||
|
||||
## Waystar steps
|
||||
1. Open https://login.zirmed.com/ui and log in
|
||||
2. Select location: top "Account" box -> type location name
|
||||
-> pick from dropdown (e.g. "Healing Bridge (313858)")
|
||||
-> lands on "Success - You have switched to the following account" page
|
||||
3. Professional Claims > click "Claims" tab
|
||||
-> https://claims.zirmed.com/Claims/Listing/Index?appid=1
|
||||
4. Left panel filters:
|
||||
- Status (select#SearchCriteria_Status) -> "All" (default was "All Rejected")
|
||||
- Transaction Date (select#SearchCriteria_TransDate) -> "All" (default "Last 90 Days"; "All" = last 2 years)
|
||||
5. Click "Search"
|
||||
6. Click "Download CSV" (#downloadCsvBtn, "Excel-Friendly CSV" ticked)
|
||||
-> save as processing/Waystar/<Location>.csv
|
||||
CSV layout: 7 preamble lines (Generated by / Account: / Filters...), then header row:
|
||||
Trans Date, Patient Name, Claim Number, Claim ID, Instance ID, Payer Name, Payer ID, From Date, To Date,
|
||||
Rendering Provider, Charges, Status, Sequence, Source, Hidden, Remit #s, Total Remit Amount, Last Note, Last Event Message
|
||||
7. Repeat 2-6 for every location.
|
||||
|
||||
## Aquilaehr steps
|
||||
1. Open https://platform.aquilaehr.com/ (login page: #normal_login_email, #normal_login_password, "Login")
|
||||
2. Sidebar "Reports" -> https://platform.aquilaehr.com/#/reports/clinical
|
||||
3. Click "Encounter & Claim Monitor" tab
|
||||
4. Top practice dropdown (antd select, virtual list - scroll to find) -> pick location
|
||||
-> URL gets ?practiceId=<guid>; page reloads data. Date range left empty = all dates.
|
||||
5. Wait until the data call for the NEW practice finishes
|
||||
(GET aquila-api-prod-xv7xr.ondigitalocean.app/api/v1/reports/clinical_notes?practice_id=<new id>),
|
||||
and the dropdown shows the chosen practice. (Exporting earlier gives the PREVIOUS practice's data.)
|
||||
Then click "Export CSV" (it's <a download="e&c-monitor.csv" href=blob:...>, contains ALL rows, not just page 1)
|
||||
-> save as processing/Aquilaehr/<Waystar location name>.csv
|
||||
Columns: Account #, Patient, DOB, Visit Date, Provider, Location, Visit Reason, Room, Template,
|
||||
Plan Name, Claim ID, Note Status, Claim Status
|
||||
6. Repeat 4-5 for every location.
|
||||
|
||||
Waystar name -> Aquilaehr dropdown name: see LOCATIONS in mek_recon.py
|
||||
- Adelman Health: SKIPPED on both sites (no Aquilaehr practice)
|
||||
- Cynthia DM Concierge NP Services LLC -> "Moona Concierge Nurse Practitioner Services LLC"
|
||||
- WoundDocs LLC -> "WOUNDOCS LLC" + "WOUNDOCS LLC - 2", both exported and merged into one file
|
||||
|
||||
## Consolidation
|
||||
Match key (verified 10/27/2026, 3938 of 4027 Waystar claims found in Aquilaehr = 97.8%):
|
||||
Waystar "Claim Number" = ="<Aquilaehr Claim ID> <Aquilaehr Account #>" e.g. ="42756 35055"
|
||||
-> first token of Waystar Claim Number == Aquilaehr "Claim ID"
|
||||
Parsing notes: Waystar CSV = skip 7 preamble lines, rows end with a trailing comma (read with index_col=False),
|
||||
values wrapped like ="..." (strip them). Aquilaehr CSV: one row per note/template, so a Claim ID can repeat.
|
||||
|
||||
Report (consolidate.py, runs automatically at the end of mek_recon.py, or alone: `python consolidate.py`):
|
||||
- Match on Patient Name + Date of Service, per location:
|
||||
Aquilaehr Patient + Visit Date vs Waystar Patient Name + From Date
|
||||
= same DOS, same first name, at least one last-name word in common
|
||||
(handles "Mary Lopez Garcia" vs "LOPEZ, MARY", extra spaces, Jr/Sr)
|
||||
- Aquilaehr rows grouped to one row per visit (Account # + Patient + Visit Date);
|
||||
Waystar rows grouped per Patient Name + From Date (Charges summed)
|
||||
- Output: output/<Location>.xlsx with 2 sheets
|
||||
- Summary: Aquilaehr visits, Waystar visits, Matched, Missing in Waystar, Missing in Aquilaehr, Duplicate in Waystar
|
||||
- Reconciliation: one row per visit, Result column:
|
||||
Missing in Waystar = visit in Aquilaehr, no Waystar claim for that patient + DOS
|
||||
Missing in Aquilaehr = Waystar claim, no Aquilaehr visit for that patient + DOS
|
||||
Duplicate in Waystar = 2nd Waystar claim for an already-matched patient + DOS (e.g. two name spellings)
|
||||
Matched
|
||||
- Note column: when the same Claim ID exists on the other side under a different date
|
||||
@@ -0,0 +1,5 @@
|
||||
playwright==1.49.0
|
||||
pandas==3.0.3
|
||||
openpyxl==3.1.5
|
||||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user