Self-healing agent (supervisor, recovery, login fixes, operator mail) and Portal sage redesign

Snapshot of D:\Ecw Process a11c9ff (2026-10-30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-30 10:58:35 +05:30
1 parent fafa5285eb
commit d500f750da
26 files changed
+1657 -310

No files matched your search

+91 -1
View File
@@ -40,11 +40,50 @@ Repo: `D:\Ecw Process`, git branch `master`, **no remote** (never pushed).
```bash ```bash
python run.py # the bot (logs into LIVE eCW) python run.py # the bot (logs into LIVE eCW)
python agent.py # the agent: the whole day's queue, self-healing (below)
python tests/test_<name>.py # one test file, prints "N/M passed" python tests/test_<name>.py # one test file, prints "N/M passed"
run_web.bat # results portal, http://127.0.0.1:8000 run_web.bat # results portal, http://127.0.0.1:8000
start_ecw_chrome.bat # Chrome the bot attaches to (BROWSER_CDP_URL) start_ecw_chrome.bat # Chrome the bot attaches to (BROWSER_CDP_URL)
``` ```
**The agent (2026-10-30, operator: "we are an agent, not a bot — observe,
fix, continue, and mail me what happened").** `python agent.py`
(`flows/supervisor.py`) runs the bot as a child process, pass after pass,
until every claim in the day's Medcor Ready queue has a result. Pass 1
downloads the live queue; a later pass takes only the claims still
pending — the queue file minus every `CV_ChargeSummary` row since the
agent started, a "Bot could not ..." Review not counting — through
`RESUME_CLAIM_IDS_FILE` (live mode, live Charge Summary, no TEST label,
no second export). After each pass, by exit code (`next_action`):
login failure (1, 6) → retry after 10 then 20 min, the 3rd in a row stops
the agent (the client's account must not be restricted); restricted (4),
screen changed (7), config (2) → stop at once; stuck page (9), stall (8),
claims error (5) → fresh Chrome + login in 1 min; guard wait (3) → wait;
a clean pass leaving the same claims pending → stop ("left for a
person"); 15 passes a day at most. VERIFY only: refuses to start with
`SAVE_CORRECTIONS=1`; forces `QUIET_BOXES=1`, `SHOW_EACH_CLAIM=0`,
`CLAIM_PROCESS_LIMIT=0`. Every failure and the end are mailed
(`core/notify.py`, from the 2FA mailbox's account over SMTP — `SMTP_*`
default to `IMAP_*`, port 465; **`NOTIFY_EMAIL_TO` blank = no mail, and it
is blank on this PC until the operator gives the address**). A mail
carries no claim number (6+ digit runs masked) and only login / error
page PNGs (`notify._EMAILABLE`) — never a claim screen or page HTML.
Inside a pass, `flows/recovery.py`: a claim the bot could not search or
read → login page on screen = session over (exit 9); else Escape, close
Patient Information, clear the screen, reopen the claim search if still
blocked, and try the claim once more; 2 claims in a row still failing
after recovery → exit 9 (live 2026-10-29: a Dental claim's sliding fee
check left Patient Information open and 18 claims went "Bot could not
search" one after another). Login: the security image page's Continue is
clicked by the bot (`login.continue_security_image`, a person only when no
Continue/OK is found; the page is saved as `security_image_page` the first
time); a 2FA link approved but not picked up in `OTP_PICKUP_SECONDS` (60)
→ Resend OTP + approve the new mail, `OTP_PICKUP_RESENDS` (2) times
(live 2026-10-29 night: three logins lost to mail 6 to 8.5 min late with
Resend still on screen). The "Chrome open" and security image boxes are
quiet with `QUIET_BOXES=1` (a `[BOX]` log line); CAPTCHA and manual 2FA
boxes still show — they need a person. **None of this has run live yet.**
**Watching a run live (2026-10-22, `8af5c14`):** `start_ecw_chrome.bat` **Watching a run live (2026-10-22, `8af5c14`):** `start_ecw_chrome.bat`
opens Google Chrome with its own profile `D:\EcwChrome` (`CDP_PROFILE_DIR`), opens Google Chrome with its own profile `D:\EcwChrome` (`CDP_PROFILE_DIR`),
debug port 9222 on 127.0.0.1, the eCW proxy (claude.ai / anthropic.com go debug port 9222 on 127.0.0.1, the eCW proxy (claude.ai / anthropic.com go
@@ -305,6 +344,9 @@ that box and on the per-claim save box means Cancel.
| `core/page_utils.py` | `shot()` with a FAILURE tag (`is_failure_tag`: failed / missing / not_ / no_ / unconfirmed ...) also saves the page's DOM + frames beside the png as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE=1`, at most `SAVE_DOM_LIMIT=30` a run; 2026-10-21) — no more hand-copying DevTools into Dom.txt. `visible()` = instant check; `appears()` = real wait (Playwright 1.62's `is_visible(timeout)` never waits); `click_topmost()` clicks the copy on top when dialogs stack (never `.first`) | | `core/page_utils.py` | `shot()` with a FAILURE tag (`is_failure_tag`: failed / missing / not_ / no_ / unconfirmed ...) also saves the page's DOM + frames beside the png as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE=1`, at most `SAVE_DOM_LIMIT=30` a run; 2026-10-21) — no more hand-copying DevTools into Dom.txt. `visible()` = instant check; `appears()` = real wait (Playwright 1.62's `is_visible(timeout)` never waits); `click_topmost()` clicks the copy on top when dialogs stack (never `.first`) |
| `core/fallback.py` + `selectors/fallbacks.py` | Fallback selectors (2026-10-21): the run-critical selectors are CHAINS — primary first, then alternatives taken from the live DOM (ng-click / ng-model / type+value, plain CSS) — joined as a CSS list, so every call site falls back with no change: password field, Log In, Claim No, Lookup, UB Cancel (`closeUBClaimScreen()`, never the X or OK), Dental Cancel (`closeDntl()`), Prog. Notes (`#UBClaimProgressNoteBtn`, `claimProgressNoteBtn<ts>`). `report()` logs `[SELECTOR] fallback: <name> — <primary> not found, matched <alt>` once a run (pre-flight and login call it) | | `core/fallback.py` + `selectors/fallbacks.py` | Fallback selectors (2026-10-21): the run-critical selectors are CHAINS — primary first, then alternatives taken from the live DOM (ng-click / ng-model / type+value, plain CSS) — joined as a CSS list, so every call site falls back with no change: password field, Log In, Claim No, Lookup, UB Cancel (`closeUBClaimScreen()`, never the X or OK), Dental Cancel (`closeDntl()`), Prog. Notes (`#UBClaimProgressNoteBtn`, `claimProgressNoteBtn<ts>`). `report()` logs `[SELECTOR] fallback: <name> — <primary> not found, matched <alt>` once a run (pre-flight and login call it) |
| `pages/preflight.py` | Pre-flight selector check (2026-10-21): the claims search page before the first claim, the claim form on the first claim of each screen type (UB / HCFA / Dental: Cancel, CPT tab, rows with code / fee / POS / M1). Missing → `[PREFLIGHT] FAIL <names>`, page shot + DOM saved, run stops with **exit 7** (`PREFLIGHT_STOP=0` only warns). No CPT row on a claim → its row checks wait for the next; 3 in a row = rows missing | | `pages/preflight.py` | Pre-flight selector check (2026-10-21): the claims search page before the first claim, the claim form on the first claim of each screen type (UB / HCFA / Dental: Cancel, CPT tab, rows with code / fee / POS / M1). Missing → `[PREFLIGHT] FAIL <names>`, page shot + DOM saved, run stops with **exit 7** (`PREFLIGHT_STOP=0` only warns). No CPT row on a claim → its row checks wait for the next; 3 in a row = rows missing |
| `flows/supervisor.py` + root `agent.py` | The agent (2026-10-30, §2): passes of the bot as a child process until nothing is pending; `next_action(code, pending, state)` is the whole retry policy, `pending(queue, rows)` the resume list |
| `flows/recovery.py` | A stuck screen inside a pass (§2): `recover()` (logout check, Escape + Patient Information close + clear, reopen search) and `run_claim()` (one retry per claim, 2 in a row → `PageStuck`, exit 9) |
| `core/notify.py` | The agent's mail (§2): `incident()` / `summary()`; claim numbers masked, login / error PNGs only; never fatal |
| `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back | | `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back |
| `core/run_events.py` | A Run's timed events → `Logs\events_<run>.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username | | `core/run_events.py` | A Run's timed events → `Logs\events_<run>.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username |
| `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login | | `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login |
@@ -583,6 +625,22 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
header `X-Run-Token` must match the token in the page; else 403), header `X-Run-Token` must match the token in the page; else 403),
`GET /api/bot/status`. Spec / plan: `GET /api/bot/status`. Spec / plan:
`docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md`. `docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md`.
- **Sage redesign (2026-10-30, the operator-approved mockup, live on
Dokploy that day from `portal-20261030-ui.zip`).** `base.html`: the navy
header became a sage side menu (`<aside class="side">`: brand, mode pill,
links with CSS-mask icons, the user's name over their role, Sign out);
breadcrumb and footer in the page column; Outfit + Inter from Google
Fonts; the old CSS variable names kept with sage / lime / Pass green /
Fail orange / Review blue values, so every page follows. Dashboard:
"Good morning|afternoon|evening, <first name>" + claim search, 4 tiles
with %, the lime **Agent run** card (the old Bot run card, same ids and
script) beside **Results by day**, **Top reasons today** beside **Runs
today** (`app._runs_today`: today's rows grouped by `run_at`, newest
first — no extra query). Sign in: sage panel + "Welcome back" card.
Not built: Queue / Pending tiles (the Portal cannot see the bot PC's
queue file — the agent would have to write the queue size to the DB).
The mockup canvas: claude.ai artifact "Charge Validation Portal Mockups"
(private to the operator).
--- ---
@@ -647,6 +705,13 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
- 2026-10-14 #20 final (operator): ONLY the codes the operator gave — the 41 injection codes (IUD / implant, inhalation and oral J-codes included, as listed) and 96372 as the one admin code. No other code is added or counted; no client confirmation needed. - 2026-10-14 #20 final (operator): ONLY the codes the operator gave — the 41 injection codes (IUD / implant, inhalation and oral J-codes included, as listed) and 96372 as the one admin code. No other code is added or counted; no client confirmation needed.
- 2026-10-08 Results go to SQL Server `MECARE331\MECARE331`, DB `Charge_Validation`, in the Charge Summary format (incl. PATIENT, Account Number, Guarantor Name); FastAPI page for now, the full portal later. - 2026-10-08 Results go to SQL Server `MECARE331\MECARE331`, DB `Charge_Validation`, in the Charge Summary format (incl. PATIENT, Account Number, Guarantor Name); FastAPI page for now, the full portal later.
- 2026-10-30 The bot becomes an agent: technical failures (login, session, stuck screen, browser) are the agent's to recover from without a person; condition or process mistakes stay the operator's to point out. Mail the operator what happened, what the agent did and whether it worked.
- 2026-10-30 Agent mail: from the bot mailbox over SMTP; screenshots of login / error pages only, never a claim screen; no claim numbers.
- 2026-10-30 The security image page's Continue is clicked by the bot.
- 2026-10-30 Login failures: at most 3 in a row (10 / 20 min apart), then stop and mail — protect the client's eCW account.
- 2026-10-30 Bulk runs show no message boxes; the full day's queue (no `CLAIM_PROCESS_LIMIT`) must finish the same day.
- 2026-10-30 Portal look: the approved mockup — sage side menu, lime highlight, Outfit + Inter, Dashboard greeting / tiles / Agent run / Results by day / Top reasons / Runs today, two-panel Sign in (no Ecare/Portal toggle: the back end has none).
### Waiting on a ruling ### Waiting on a ruling
1. An admin code / G2025 that the Fee Schedule prices at **0.00** after being added (pinned in tests). 1. An admin code / G2025 that the Fee Schedule prices at **0.00** after being added (pinned in tests).
2. `medicare_telehealth` with G2025 **unconfirmed** leaves other rows; `priced_line_check` may then check an unchecked priced row (pinned in tests). 2. `medicare_telehealth` with G2025 **unconfirmed** leaves other rows; `priced_line_check` may then check an unchecked priced row (pinned in tests).
@@ -688,7 +753,11 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
History: `git log --oneline`. History: `git log --oneline`.
Last code commits: `0c2fca8` (2026-10-28, vaccine_z23_pointer #45 / Last code commits: `78409f0` (2026-10-30, Portal sage redesign — §5;
deployed to Dokploy from the working tree before this commit, same
files), `24c9ce4` (2026-10-30, the self-healing agent: supervisor,
recovery, security image Continue, Resend pickup, operator mail — §2,
§10), `0c2fca8` (2026-10-28, vaccine_z23_pointer #45 /
t1017_non_billable #46 — §4), `d1aa6da` (2026-10-28, login waits for a late t1017_non_billable #46 — §4), `d1aa6da` (2026-10-28, login waits for a late
security image page — §10), `97d4d74` (2026-10-28, Charge Summary notes a payer the security image page — §10), `97d4d74` (2026-10-28, Charge Summary notes a payer the
sheet and eCW disagree on — §5), `aaab1ff` (2026-10-28, lab_codes #3/#4, sheet and eCW disagree on — §5), `aaab1ff` (2026-10-28, lab_codes #3/#4,
@@ -825,6 +894,27 @@ test claim ids — new posts must not (§7).
## 10. Open items ## 10. Open items
- **Agent (2026-10-30), not yet run live.** To do: `NOTIFY_EMAIL_TO` in
`.env` (the operator's address) and one test mail; a first `python
agent.py` to see the security image Continue click on the real page
(its HTML is saved as `security_image_page` — tighten the selector from
it) and a late-mail Resend pickup. Not built: the agent's status in the
Portal (it runs on the bot PC; the Dokploy Portal sees only the
database — a status / queue-size row would carry it), Queue / Pending
tiles.
- **29/10 live queue (690 claims).** 208 got a result in two runs (17:18
126, 19:29 82); the rest were not reached: Ctrl-C would not stop a
run blocked in a Playwright call (the tab was closed instead), a stuck
Patient Information dialog cost 18 claims (now `recovery.py`), and three
logins that night failed on 2FA mail 6 to 8.5 min late (now the Resend
pickup). 482 claims of that day's queue were never checked.
- **Tests:** `test_cdp_browser.py` 2 of 11 fail on this PC since
2026-10-29 (the Chrome it opens has a command line PowerShell cannot
read — the same `[BROWSER] Could not read the command line` line live
runs print); environment, not code — look at it before trusting the
proxy check. `test_msgbox.py` now pins `QUIET_BOXES` off (this PC's
`.env` has it on).
- **Stability (review of 25 runs, 20–26/10, 2026-10-26).** After the eCW - **Stability (review of 25 runs, 20–26/10, 2026-10-26).** After the eCW
login the run is steady: the last five full runs read 50/50 with no login the run is steady: the last five full runs read 50/50 with no
bot error and the same results run to run. The weak part is getting bot error and the same results run to run. The weak part is getting
+17
View File
@@ -0,0 +1,17 @@
r"""
The eCW Charge Validation agent — runs the day's Medcor Ready queue to the
end on its own, restarting after technical failures and mailing what
happened (flows/supervisor.py).
python agent.py
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
from ecw_bot.flows.supervisor import main # noqa: E402
if __name__ == "__main__":
sys.exit(main())
+27 -2
View File
@@ -12,6 +12,7 @@ spreadsheet, one client instead of a loop, and the second factor is a mailed
approval link rather than an authenticator code — see `twofactor.py`. approval link rather than an authenticator code — see `twofactor.py`.
""" """
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -32,6 +33,25 @@ from .twofactor import handle_2fa
SECURITY_IMAGE = "text=VERIFY YOUR SECURITY IMAGE" SECURITY_IMAGE = "text=VERIFY YOUR SECURITY IMAGE"
# The page's own go-on control, by its words: its markup has never been
# saved (the first page the bot clicks through is, as security_image_page).
_CONTINUE = re.compile(r"^\s*(continue|ok)\s*$", re.I)
def continue_security_image(page: Page) -> bool:
"""
Click Continue on eCW's "VERIFY YOUR SECURITY IMAGE" page (operator,
2026-10-30: the run must not wait for a person). True once clicked;
False when no visible Continue/OK control is there, so the caller falls
back to waiting for a person.
"""
shot(page, "security_image_page")
for role in ("button", "link"):
btn = page.get_by_role(role, name=_CONTINUE).filter(visible=True)
if btn.count():
btn.first.click(timeout=10_000)
return True
return False
def wait_after_username(page: Page, timeout_ms: int, image: bool = True) -> str: def wait_after_username(page: Page, timeout_ms: int, image: bool = True) -> str:
@@ -125,14 +145,19 @@ def _do_login(page: Page, context, client_name: str):
# Some logins interpose a security-image confirmation page — sometimes # Some logins interpose a security-image confirmation page — sometimes
# seconds after Next (2026-10-28 17:59: missed by a one-look check). # seconds after Next (2026-10-28 17:59: missed by a one-look check).
# No box with QUIET_BOXES (2026-10-29, bulk runs): the wait below is
# what holds the run, the box only ever said so.
if wait_after_username(page, cfg.LOGIN_TIMEOUT) == "image": if wait_after_username(page, cfg.LOGIN_TIMEOUT) == "image":
print("[LOGIN] Security image page") if continue_security_image(page):
print("[LOGIN] Security image page — clicked Continue")
else:
print(f"[LOGIN] Security image page — no Continue found; click it "
f"in the browser (waiting up to {cfg.LOGIN_TIMEOUT // 1000}s)")
msgbox.info_box( msgbox.info_box(
f"eCW Bot — Security Image ({client_name})", f"eCW Bot — Security Image ({client_name})",
"The 'VERIFY YOUR SECURITY IMAGE' page came up.\n" "The 'VERIFY YOUR SECURITY IMAGE' page came up.\n"
"Click Continue/OK in the browser.\n\n" "Click Continue/OK in the browser.\n\n"
"Click OK once the password page appears.", "Click OK once the password page appears.",
needs_person=True,
) )
wait_after_username(page, cfg.LOGIN_TIMEOUT, image=False) wait_after_username(page, cfg.LOGIN_TIMEOUT, image=False)
# A renamed password field or Log In button still works through its # A renamed password field or Log In button still works through its
+56 -16
View File
@@ -185,6 +185,61 @@ def _challenge_with_resend(page: Page, client_name: str,
f"No eCW 2FA mail arrived (tried {cfg.OTP_RESEND_ATTEMPTS} resend(s)).") f"No eCW 2FA mail arrived (tried {cfg.OTP_RESEND_ATTEMPTS} resend(s)).")
def _picked_up(page: Page, seconds: int) -> bool:
"""The login page left the push screen (home, or the notice gone)."""
for _ in range(max(1, seconds)):
if visible(page, sel.SEL_HOME, timeout=500) or \
not visible(page, sel.SEL_PUSH_NOTICE, timeout=500):
return True
page.wait_for_timeout(500)
return False
def _approve_until_picked_up(page: Page, context, client_name: str,
ch) -> None:
"""
Approve the link; if the login page does not pick it up within
OTP_PICKUP_SECONDS, press Resend and approve the new mail, up to
OTP_PICKUP_RESENDS times (live 2026-10-29: three logins lost to late
mail with Resend still on screen). Returns once picked up, or when no
resend is left — the caller's wait for home then reports it.
"""
for resend in range(cfg.OTP_PICKUP_RESENDS + 1):
if not _approve_in_new_tab(context, ch.value):
raise RuntimeError("Approval link opened but could not be confirmed")
# eCW's login page polls for the approval; it moves on by itself. But
# it may already have stopped polling while the mail was in flight —
# the link is then spent and confirming it changed nothing, so say that
# rather than letting the caller wait out LOGIN_TIMEOUT for a home
# screen that is never coming.
page.wait_for_timeout(2_000)
why = dead_end_reason(page)
if why:
shot(page, "2fa_timed_out")
raise LoginTimedOut(
f"The approval was confirmed, but the login page had already "
f"given up: {why}.\n"
f" The mail arrived too late to be useful. Starting over.")
print("[2FA] Waiting for the login page to pick up the approval")
if _picked_up(page, cfg.OTP_PICKUP_SECONDS):
return
if resend == cfg.OTP_PICKUP_RESENDS:
return
print(f"[2FA] Not picked up in {cfg.OTP_PICKUP_SECONDS}s — resending")
shot(page, "2fa_not_picked_up")
if not _resend_push(page):
return
since = datetime.now(timezone.utc)
run_events.login_step("twofa_resent")
ch = mailbox.get_challenge(since=since, client_name=client_name,
allow_manual=False)
if ch.kind != "link":
print("[2FA] No new approval mail after the resend")
return
print(f"[2FA] Resend {resend + 1}/{cfg.OTP_PICKUP_RESENDS}: "
f"approving the new mail")
def handle_2fa(page: Page, context, client_name: str, def handle_2fa(page: Page, context, client_name: str,
clicked_at: datetime) -> None: clicked_at: datetime) -> None:
"""Deal with whatever second factor eCW shows. No-op when there isn't one.""" """Deal with whatever second factor eCW shows. No-op when there isn't one."""
@@ -206,22 +261,7 @@ def handle_2fa(page: Page, context, client_name: str,
ch = _challenge_with_resend(page, client_name, clicked_at) ch = _challenge_with_resend(page, client_name, clicked_at)
if ch.kind == "link": if ch.kind == "link":
if not _approve_in_new_tab(context, ch.value): _approve_until_picked_up(page, context, client_name, ch)
raise RuntimeError("Approval link opened but could not be confirmed")
# eCW's login page polls for the approval; it moves on by itself. But
# it may already have stopped polling while the mail was in flight —
# the link is then spent and confirming it changed nothing, so say that
# rather than letting the caller wait out LOGIN_TIMEOUT for a home
# screen that is never coming.
page.wait_for_timeout(2_000)
why = dead_end_reason(page)
if why:
shot(page, "2fa_timed_out")
raise LoginTimedOut(
f"The approval was confirmed, but the login page had already "
f"given up: {why}.\n"
f" The mail arrived too late to be useful. Starting over.")
print("[2FA] Waiting for the login page to pick up the approval")
return return
if ch.kind in ("code", "totp"): if ch.kind in ("code", "totp"):
+86
View File
@@ -0,0 +1,86 @@
"""
The agent's email to the operator (operator, 2026-10-30): when something
technical goes wrong — a login that fails, a screen that sticks, a run that
stops — say what happened, what the agent did about it and whether that
worked, instead of leaving it for someone to find in the morning.
Sent from the bot's own mailbox (the 2FA mailbox's account, over SMTP). What
it may carry is fixed here, not by the caller:
* screenshots of login and error pages only (`_EMAILABLE`), PNG only — a
claim screen or a page's HTML carries PHI and never leaves the PC;
* no claim numbers: any run of 6+ digits in the subject or body is masked.
Never fatal: a mail that cannot be sent is a log line, and the run goes on.
"""
import re
import smtplib
import ssl
from email.message import EmailMessage
from pathlib import Path
from .. import settings as cfg
# `<HHMMSS>_<step>.png` as page_utils.shot() names them. Login and error
# pages only: not "logged_in" or "landing" (eCW's home can list patients).
_EMAILABLE = re.compile(
r"^\d{6}_(security_image_page|login_unconfirmed|username_error|"
r"username_step_disabled|no_login_form|captcha|restricted|"
r"password_expired|password_expiry_no_cancel|2fa_[a-z_]+|"
r"approval_(page|done|no_button))\.png$")
_DIGITS = re.compile(r"\d{6,}")
def _mask(text: str) -> str:
return _DIGITS.sub("<claim>", text or "")
def emailable(path) -> bool:
return bool(_EMAILABLE.match(Path(path).name))
def _transport(msg: EmailMessage) -> None:
with smtplib.SMTP_SSL(cfg.SMTP_HOST, cfg.SMTP_PORT, timeout=30,
context=ssl.create_default_context()) as smtp:
smtp.login(cfg.SMTP_USERNAME, cfg.SMTP_PASSWORD)
smtp.send_message(msg)
def _send(subject: str, body: str, shots=()) -> bool:
if not cfg.NOTIFY_EMAIL_TO:
return False
msg = EmailMessage()
msg["From"] = cfg.SMTP_USERNAME
msg["To"] = cfg.NOTIFY_EMAIL_TO
msg["Subject"] = f"[eCW Agent] {_mask(subject)}"
msg.set_content(_mask(body))
for p in shots or ():
p = Path(p)
if emailable(p) and p.exists():
msg.add_attachment(p.read_bytes(), maintype="image",
subtype="png", filename=p.name)
try:
_transport(msg)
print(f"[NOTIFY] Mailed: {msg['Subject']}")
return True
except Exception as exc:
print(f"[NOTIFY] Could not mail '{msg['Subject']}': "
f"{str(exc).splitlines()[0] if str(exc) else type(exc).__name__}")
return False
def incident(title: str, what_happened: str, what_agent_did: str,
recovered: bool, shots=()) -> bool:
"""One technical problem: what, what the agent did, and the outcome."""
outcome = "Recovered - the run goes on." if recovered else \
"Not recovered - the agent stopped or is waiting to retry."
body = (f"What happened:\n {what_happened}\n\n"
f"What the agent did:\n {what_agent_did}\n\n"
f"Outcome:\n {outcome}\n\n"
f"Run log and screenshots are on the bot PC and in the Portal.")
return _send(title, body, shots)
def summary(title: str, lines) -> bool:
"""The end of a run: counts and anything left for a person."""
return _send(title, "\n".join(lines))
+50 -12
View File
@@ -56,6 +56,8 @@ from ..pages import preflight
from ..core.text import first_line from ..core.text import first_line
from ..core.browser import cdp_ready, chrome_proxy_problem, start_chrome from ..core.browser import cdp_ready, chrome_proxy_problem, start_chrome
from ..core import run_events, watchdog from ..core import run_events, watchdog
from ..core.page_utils import shot
from . import recovery
from ..auth.login import login from ..auth.login import login
from ..auth.dead_end import LoginTimedOut from ..auth.dead_end import LoginTimedOut
from ..auth.restricted import Restricted from ..auth.restricted import Restricted
@@ -983,6 +985,7 @@ def _check_claims(page, info) -> None:
keep_pages = list(page.context.pages) keep_pages = list(page.context.pages)
except Exception: except Exception:
keep_pages = [] keep_pages = []
stuck = {} # recovery.run_claim's streak of unrecovered claims
for n, claim_id in enumerate(todo, start=1): for n, claim_id in enumerate(todo, start=1):
# A claim that froze the page stops the run here, before the next # A claim that froze the page stops the run here, before the next
# one: the rest of the queue is Not Reached, not 40 failed searches. # one: the rest of the queue is Not Reached, not 40 failed searches.
@@ -998,6 +1001,7 @@ def _check_claims(page, info) -> None:
# 2 and 3 were never searched. A page that will not come clean # 2 and 3 were never searched. A page that will not come clean
# makes this claim a failed search — Review — and the next claim # makes this claim a failed search — Review — and the next claim
# clears and checks again. # clears and checks again.
def attempt():
claim_form.clear_screen(page, claim_id, keep_pages) claim_form.clear_screen(page, claim_id, keep_pages)
try: try:
r = claim_results.search_claim(page, claim_id) r = claim_results.search_claim(page, claim_id)
@@ -1008,19 +1012,32 @@ def _check_claims(page, info) -> None:
# about the queue, and a search that raised (browser gone, a # about the queue, and a search that raised (browser gone, a
# timeout) says nothing about the queue. It is counted apart # timeout) says nothing about the queue. It is counted apart
# and its row is Review, not left blank like a missing claim. # and its row is Review, not left blank like a missing claim.
r = {"id": claim_id, "count": "?", "found": False, "seconds": 0, r = {"id": claim_id, "count": "?", "found": False,
"search_error": first_line(exc)} "seconds": 0, "search_error": first_line(exc)}
# Built before the read, not after: the review-before-close box is # Built before the read, not after: the review-before-close box
# shown while the form is still open and leads with it too. # is shown while the form is still open and leads with it too.
head = _claim_head(n, len(todo), claim_id, r) r["_head"] = _claim_head(n, len(todo), claim_id, r)
if r["found"]: if r["found"]:
watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: open the form") watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: "
data = _read_claim_form(page, claim_id, head) f"open the form")
data = _read_claim_form(page, claim_id, r["_head"])
elif r.get("search_error"): elif r.get("search_error"):
data = {"claim_no": claim_id, data = {"claim_no": claim_id,
"_search_error": r["search_error"]} "_search_error": r["search_error"]}
else: else:
data = {} data = {}
return r, data
def recover_now():
# Operator, 2026-10-30: a stuck screen is the agent's to clear.
watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: recover")
how = recovery.recover(page, claim_id, keep_pages, _open_search)
print(f"[RECOVER {claim_id}] Search page back ({how}) — "
f"trying the claim again")
run_events.emit("recovered", claim=str(claim_id), how=how)
r, data = recovery.run_claim(attempt, recover_now, stuck)
head = r.pop("_head")
# `_error` = the form was opened (or tried) and could not be read: # `_error` = the form was opened (or tried) and could not be read:
# not "read", whatever the dict holds (review item D2). # not "read", whatever the dict holds (review item D2).
error = data.get("_error") if data else None error = data.get("_error") if data else None
@@ -1206,8 +1223,9 @@ def _write_charge_summary(info, outcomes: dict) -> None:
f"This run's charge summary is:\n {path.name}") f"This run's charge summary is:\n {path.name}")
def _claims_phase(page) -> None: def _open_search(page) -> None:
"""Everything on claimLookup.jsp, in the one order that works.""" """The claim search with the run's filters — at the start, and again
when flows/recovery.py reopens it after a stuck screen."""
leftnav.open_claims(page) leftnav.open_claims(page)
print(f"[OK] On: {page.url}") print(f"[OK] On: {page.url}")
claim_filters.set_claim_status(page) # FIRST — see the module docstring claim_filters.set_claim_status(page) # FIRST — see the module docstring
@@ -1215,6 +1233,20 @@ def _claims_phase(page) -> None:
claim_filters.set_date_range(page) claim_filters.set_date_range(page)
claim_filters.clear_balance(page) claim_filters.clear_balance(page)
def _claims_phase(page) -> None:
"""Everything on claimLookup.jsp, in the one order that works."""
_open_search(page)
if cfg.RESUME_CLAIM_IDS_FILE and not cfg.TEST_CLAIM_IDS_FILE:
# The agent's later pass (flows/supervisor.py): today's live queue,
# only the claims still pending, no second export.
info = claim_file.claim_ids(Path(cfg.RESUME_CLAIM_IDS_FILE))
print(f"[RESUME] {len(info['ids'])} claim(s) of today's live queue "
f"still pending")
_process_claims(page, info)
return
if cfg.TEST_CLAIM_IDS_FILE: if cfg.TEST_CLAIM_IDS_FILE:
_process_test_claim_ids_file(page) _process_test_claim_ids_file(page)
return return
@@ -1520,12 +1552,12 @@ def _main(log_path) -> int:
return _stop("config", problem, 2) return _stop("config", problem, 2)
if opened_chrome: if opened_chrome:
# Operator, 2026-10-23: a moment to add the Claude extension before # Operator, 2026-10-23: a moment to add the Claude extension before
# eCW opens. A person's box (shown on a Portal run too); after the # eCW opens; after the usual timeout the run goes on. 2026-10-29:
# usual timeout the run goes on. # bulk runs show no box at all, so QUIET_BOXES makes it a log line.
msgbox.info_box("eCW Bot — Chrome open", msgbox.info_box("eCW Bot — Chrome open",
"Chrome is open. Add the Claude extension if you " "Chrome is open. Add the Claude extension if you "
"need it, then click OK — the bot logs in to eCW " "need it, then click OK — the bot logs in to eCW "
"after this box.", needs_person=True) "after this box.")
if not _disk_check(): if not _disk_check():
return 2 return 2
@@ -1551,6 +1583,12 @@ def _main(log_path) -> int:
except watchdog.Stalled as exc: except watchdog.Stalled as exc:
print(f"[WATCHDOG] Run stopped: {exc}") print(f"[WATCHDOG] Run stopped: {exc}")
code = _stop("stalled", str(exc), watchdog.EXIT_CODE) code = _stop("stalled", str(exc), watchdog.EXIT_CODE)
except recovery.PageStuck as exc:
# The supervisor restarts Chrome and logs in again for the
# claims still pending (flows/recovery.py).
print(f"[RECOVER] Run stopped: {exc}")
shot(page, "page_stuck")
code = _stop("stuck", first_line(exc), recovery.EXIT_CODE)
except Exception as exc: except Exception as exc:
if watchdog.has_fired(): if watchdog.has_fired():
# The frozen call came back with an error somewhere # The frozen call came back with an error somewhere
+96
View File
@@ -0,0 +1,96 @@
"""
Getting the run out of a stuck screen without a person (operator,
2026-10-30: close or refresh, re-login, and continue).
Live 2026-10-29: a Dental claim's sliding fee check left Patient Information
open; its overlay took every click, and the next 18 claims were each marked
"Bot could not search the claim" without being looked at.
A claim the bot could not search or read (`bot_failed`) sets off `recover`:
1. the login page on screen = eCW ended the session → PageStuck;
2. Escape, close Patient Information, clear the screen → search ready?
3. reopen the claim search page (`reopen`) → search ready?
4. still blocked → PageStuck.
`run_claim` then tries that claim once more. Two claims in a row still
failing after their recovery → PageStuck as well. PageStuck ends the run
with EXIT_CODE; the supervisor restarts Chrome, logs in again and carries
on with the claims still pending.
"""
from ..core.page_utils import visible
from ..pages import claim_form, claim_results, sliding_fee
from ..selectors import login as login_sel
EXIT_CODE = 9
STREAK_LIMIT = 2
class PageStuck(RuntimeError):
"""The page cannot be brought back without a new browser and login."""
def bot_failed(r: dict, data: dict) -> bool:
"""The bot could not search or read the claim — a fault of the screen,
not a fact about the claim."""
return bool((r or {}).get("search_error") or (data or {}).get("_error"))
def _logged_out(page) -> bool:
return visible(page, login_sel.SEL_USERNAME, timeout=1_000)
def _escape_dialogs(page) -> None:
for _ in range(2):
try:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
except Exception:
pass
sliding_fee.close_patient_info(page)
def _clear(page, claim_id, keep_pages) -> None:
claim_form.clear_screen(page, claim_id, keep_pages)
def _blocker(page):
return claim_results.search_blocker(page)
def recover(page, claim_id: str, keep_pages, reopen) -> str:
"""'cleared' or 'reopened' once the claim search is free; else raises."""
if _logged_out(page):
raise PageStuck("eCW showed the login page - the session ended")
_escape_dialogs(page)
_clear(page, claim_id, keep_pages)
if not _blocker(page):
return "cleared"
reopen(page)
blocker = _blocker(page)
if not blocker:
return "reopened"
raise PageStuck(f"the claim search stayed blocked after a reopen: "
f"{blocker}")
def run_claim(attempt, recover_now, state: dict):
"""
`attempt()` → (r, data). A bot failure: `recover_now()`, then one more
attempt. `state["streak"]` counts claims in a row that still failed
after that; at STREAK_LIMIT, PageStuck.
"""
state.setdefault("streak", 0)
r, data = attempt()
if not bot_failed(r, data):
state["streak"] = 0
return r, data
recover_now()
r, data = attempt()
if not bot_failed(r, data):
state["streak"] = 0
return r, data
state["streak"] += 1
if state["streak"] >= STREAK_LIMIT:
raise PageStuck(f"{state['streak']} claims in a row could not be "
f"searched or read, even after a recovery")
return r, data
+209
View File
@@ -0,0 +1,209 @@
"""
The agent: runs the bot over the day's Medcor Ready queue until every claim
has a result, without a person (operator, 2026-10-30: "we are an agent, not
a bot — observe, fix, continue, and mail me what happened").
python agent.py
Each pass runs the bot in a child process (a fresh Chrome and login every
time). The first pass downloads the live queue; a later pass takes only the
claims still pending — the queue minus every claim that got a result since
the agent started, a "Bot could not ..." Review not counting as a result.
What a pass's exit code leads to is `next_action`, the operator's rules of
2026-10-30:
* login failures (1, 6): retry after 10, 20 minutes; the 3rd in a row
stops the agent — the client's eCW account must not be restricted;
* restricted (4), eCW screen changed (7), config (2): stop at once;
* stuck page (9), stall (8), claims error (5): restart in a minute;
* the attempt guard said wait (3): wait, not a failure;
* nothing pending: done; a clean pass that left the same claims: stop,
those are for a person.
Every failure is mailed (core/notify.py), and so is the end.
VERIFY only: the agent refuses to start with SAVE_CORRECTIONS=1, since
LIVE-WRITE asks a person before every save.
"""
import os
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from .. import settings as cfg
from .. import claim_file
from ..core import notify, spreadsheet
LOGIN_WAITS = (600, 1200, 1800)
LOGIN_FAIL_LIMIT = 3
CRASH_LIMIT = 6
RUN_LIMIT = 15
_STOP_AT_ONCE = {
2: "the bot's settings are incomplete",
4: "eCW restricted the account",
7: "an eCW screen changed and the bot no longer knows it",
}
_LOGIN = {1: "the login failed", 6: "the 2FA approval timed out"}
_RESTART = {5: "a claim step failed", 8: "a claim stopped making progress",
9: "the eCW page got stuck and could not be cleared"}
@dataclass
class State:
login_fails: int = 0
crashes: int = 0
runs: int = 0
last_pending: list | None = None
outcome: str = ""
history: list = field(default_factory=list)
def next_action(code: int, pending, state: State):
"""(action, wait_seconds, why) — action is done, retry or stop."""
state.runs += 1
if code not in _LOGIN and code != 3:
state.login_fails = 0
if pending == []:
return "done", 0, "every claim in the queue has a result"
if code in _STOP_AT_ONCE:
return "stop", 0, _STOP_AT_ONCE[code]
if state.runs >= RUN_LIMIT:
return "stop", 0, f"{RUN_LIMIT} passes today without finishing"
if code in _LOGIN:
state.login_fails += 1
if state.login_fails >= LOGIN_FAIL_LIMIT:
return "stop", 0, (f"{LOGIN_FAIL_LIMIT} login failures in a row "
f"- stopping so eCW does not restrict the "
f"account")
return "retry", LOGIN_WAITS[state.login_fails - 1], _LOGIN[code]
if code == 3:
return "retry", 300, "the login guard asked to wait"
if code in _RESTART or code != 0:
state.crashes += 1
if state.crashes >= CRASH_LIMIT:
return "stop", 0, f"{CRASH_LIMIT} restarts today"
return "retry", 60, _RESTART.get(code, f"the bot ended with {code}")
if pending is None:
return "stop", 0, "the run ended but no queue file was found"
if state.last_pending is not None and \
sorted(pending) == sorted(state.last_pending):
return "stop", 0, (f"{len(pending)} claim(s) still could not be "
f"checked after a retry - left for a person")
state.last_pending = list(pending)
return "retry", 60, f"{len(pending)} claim(s) to retry"
def pending(queue_ids, rows) -> list:
"""Queue order kept; rows = (claim_no, comments) since the agent began."""
done = {str(c) for c, comments in rows
if not (comments or "").startswith("Bot could not")}
return [c for c in queue_ids if str(c) not in done]
def run_loop(run_bot, pending_now, mail, sleep) -> State:
st = State()
resume = None
while True:
code = run_bot(resume)
left = pending_now()
action, wait, why = next_action(code, left, st)
st.history.append((code, action, why))
print(f"[AGENT] Pass {st.runs}: exit {code} - {action} ({why})")
if action == "done":
st.outcome = "done"
mail("summary", "Run finished", st)
return st
if action == "stop":
st.outcome = "stopped"
mail("incident", "Agent stopped", why,
"Stopped; nothing more will run today without a person.",
False)
return st
mail("incident", "Recovering", why,
f"Waiting {wait // 60} min, then a fresh Chrome and login for "
f"the {len(left) if left else 'queued'} claim(s) still pending.",
True)
resume = left or None
sleep(wait)
# ── the real world ───────────────────────────────────────────────────────────
_BOOT = ("import sys; sys.path.insert(0, 'src'); "
"from ecw_bot.flows.export_medcor_ready import main; sys.exit(main())")
def _queue_file() -> Path:
return cfg.ROOT / "Output" / f"Medcor Ready_{cfg.TODAY}.xlsx"
def _run_bot_child(resume, started_at: datetime):
env = dict(os.environ, SAVE_CORRECTIONS="0", QUIET_BOXES="1",
SHOW_EACH_CLAIM="0", SHOW_CLAIM_IDS="0", CLAIM_PROCESS_LIMIT="0",
ECW_KEEP_OPEN="0", TEST_CLAIM_IDS_FILE="",
RESUME_CLAIM_IDS_FILE="")
if resume:
path = cfg.ROOT / "Output" / f"Agent pending_{cfg.TODAY}.xlsx"
spreadsheet.write_xlsx(path, [["CLAIMS#"]] + [[c] for c in resume])
env["RESUME_CLAIM_IDS_FILE"] = str(path)
print(f"[AGENT] Resuming {len(resume)} pending claim(s)")
else:
print("[AGENT] Running the live Medcor Ready queue")
return subprocess.call([sys.executable, "-c", _BOOT], cwd=cfg.ROOT,
env=env)
def _pending_now(started_at: datetime):
if not _queue_file().exists():
return None
queue = claim_file.claim_ids(_queue_file())["ids"]
import pyodbc
with pyodbc.connect(cfg.RESULTS_DB_CONN, timeout=15) as conn:
rows = conn.cursor().execute(
"SELECT claim_no, comments FROM dbo.CV_ChargeSummary "
"WHERE run_at >= ?", started_at).fetchall()
return pending(queue, [(r[0], r[1]) for r in rows])
def _shots_since(when: datetime) -> list:
folder = Path(cfg.SCREENSHOTS)
if not folder.exists():
return []
t = when.timestamp()
return [p for p in folder.glob("*.png") if p.stat().st_mtime >= t]
def _mail(started_at):
def mail(kind, title, *args):
if kind == "summary":
st = args[0]
lines = [f"The agent finished the Medcor Ready queue for "
f"{cfg.TODAY} in {st.runs} pass(es).",
"", "Passes:"]
lines += [f" exit {c}: {a} - {w}" for c, a, w in st.history]
lines += ["", "Results are in the Portal under today's date."]
notify.summary(title, lines)
return
what, did, recovered = args
notify.incident(title, what, did, recovered,
shots=_shots_since(started_at))
return mail
def main() -> int:
if cfg.SAVE_CORRECTIONS:
print("[AGENT] SAVE_CORRECTIONS=1 - the agent runs VERIFY only; "
"LIVE-WRITE needs a person at every save.")
return 2
started_at = datetime.now().replace(microsecond=0)
print(f"[AGENT] Started {started_at:%Y-%m-%d %H:%M:%S}")
st = run_loop(
run_bot=lambda resume: _run_bot_child(resume, started_at),
pending_now=lambda: _pending_now(started_at),
mail=_mail(started_at),
sleep=time.sleep)
return 0 if st.outcome == "done" else 1
+4
View File
@@ -192,6 +192,10 @@ def _close_patient_info(page: Page) -> None:
"sliding_fee_patient_info_still_open.png") "sliding_fee_patient_info_still_open.png")
# For flows/recovery.py: the same close, after a claim left the dialog up.
close_patient_info = _close_patient_info
def read(page: Page) -> dict: def read(page: Page) -> dict:
"""Open Info -> Sliding Fee Schedule, read it, close both. Never raises.""" """Open Info -> Sliding Fee Schedule, read it, close both. Never raises."""
if not appears(page, sel.SEL_CLAIM_PATIENT_INFO, 5_000): if not appears(page, sel.SEL_CLAIM_PATIENT_INFO, 5_000):
+20
View File
@@ -235,6 +235,15 @@ IMAP_PASSWORD = _env("IMAP_PASSWORD")
IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX") IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX")
IMAP_SSL = _env_bool("IMAP_SSL", True) IMAP_SSL = _env_bool("IMAP_SSL", True)
# The agent's email to the operator (core/notify.py, operator 2026-10-30):
# sent from the 2FA mailbox's own account over SMTP, so no new credential.
# Blank NOTIFY_EMAIL_TO = no mail at all.
NOTIFY_EMAIL_TO = _env("NOTIFY_EMAIL_TO")
SMTP_HOST = _env("SMTP_HOST") or IMAP_HOST
SMTP_PORT = _env_int("SMTP_PORT", 465)
SMTP_USERNAME = _env("SMTP_USERNAME") or IMAP_USERNAME
SMTP_PASSWORD = _env("SMTP_PASSWORD") or IMAP_PASSWORD
# How long to keep polling the mailbox for the OTP mail, and how often. # How long to keep polling the mailbox for the OTP mail, and how often.
# #
# Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery # Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery
@@ -272,6 +281,12 @@ OTP_LINK_AGE_WARN_SECONDS = _env_int("OTP_LINK_AGE_WARN_SECONDS", 540)
# for that and stops with it as the stated reason. # for that and stops with it as the stated reason.
OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1) OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1)
OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600) OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600)
# A link approved but never picked up by the login page (live 2026-10-29: mail
# 6 to 8.5 minutes late, page still on "sent a notification", Resend still
# there): wait this long for the page to move on, then Resend and approve the
# new mail, up to OTP_PICKUP_RESENDS times. eCW allows three resends a login.
OTP_PICKUP_SECONDS = _env_int("OTP_PICKUP_SECONDS", 60)
OTP_PICKUP_RESENDS = _env_int("OTP_PICKUP_RESENDS", 2)
# Ignore anything already sitting in the inbox from before the login attempt. # Ignore anything already sitting in the inbox from before the login attempt.
OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300) OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300)
@@ -516,6 +531,11 @@ NOTIFY_UNMAPPED_PAYERS = _env_bool("NOTIFY_UNMAPPED_PAYERS", True)
# Accepted on purpose — a path in .env that points at nothing is a config # Accepted on purpose — a path in .env that points at nothing is a config
# mistake whichever program reads the file (unit B fix round 1, 2026-10-05). # mistake whichever program reads the file (unit B fix round 1, 2026-10-05).
TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "") TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "")
# Set by the agent (flows/supervisor.py), not by hand: the claims of today's
# live queue still pending after an earlier pass stopped. Run like the live
# queue — no TEST label, the live Charge Summary — but from this list, with
# no fresh export.
RESUME_CLAIM_IDS_FILE = _env("RESUME_CLAIM_IDS_FILE", "")
# ── Per-claim processing ────────────────────────────────────────────────────── # ── Per-claim processing ──────────────────────────────────────────────────────
+7
View File
@@ -194,7 +194,14 @@ def _run(*, fires=False, review=True, show_each=True, dental=False,
out = io.StringIO() out = io.StringIO()
try: try:
with contextlib.redirect_stdout(out): with contextlib.redirect_stdout(out):
# No page to recover on: flows/recovery.py's steps are
# its own tests' business (test_recovery.py).
real_recover = flow.recovery.recover
flow.recovery.recover = lambda *a: "cleared"
try:
flow._process_claims(None, {"ids": ["9103000"], "file": None}) flow._process_claims(None, {"ids": ["9103000"], "file": None})
finally:
flow.recovery.recover = real_recover
finally: finally:
_restore() _restore()
return events, out.getvalue(), written[0] return events, out.getvalue(), written[0]
+7
View File
@@ -321,7 +321,14 @@ def _run(ids, search, events=None):
lambda page, cid, keep=(): events.append(("clear", cid)) or []) lambda page, cid, keep=(): events.append(("clear", cid)) or [])
try: try:
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
# No page to recover on: flows/recovery.py's steps are
# its own tests' business (test_recovery.py).
real_recover = flow.recovery.recover
flow.recovery.recover = lambda *a: "cleared"
try:
flow._process_claims(None, {"ids": list(ids), "file": None}) flow._process_claims(None, {"ids": list(ids), "file": None})
finally:
flow.recovery.recover = real_recover
finally: finally:
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
flow.claim_results.search_claim, flow._read_claim_form, flow.claim_results.search_claim, flow._read_claim_form,
+28
View File
@@ -79,6 +79,34 @@ def a_password_box_that_stays_disabled_is_an_error_with_a_screenshot(page):
assert {p.suffix for p in shots} == {".png", ".html"}, shots assert {p.suffix for p in shots} == {".png", ".html"}, shots
# The security image page with its own Continue: clicking it is what lets
# eCW enable the password box (operator, 2026-10-30: no manual click).
_IMAGE_PAGE = """
<input id="doctorID" value="someone">
<input disabled type="password" id="passwordField">
<div id="img">VERIFY YOUR SECURITY IMAGE
<input type="button" value="%s" onclick="document.getElementById('img')
.style.display = 'none'; document.getElementById('passwordField')
.disabled = false;">
</div>
"""
@case
def continue_on_the_security_image_page_is_clicked_by_the_bot(page):
page.set_content(_IMAGE_PAGE % "Continue")
assert login.wait_after_username(page, 5_000) == "image"
assert login.continue_security_image(page) is True
assert login.wait_after_username(page, 5_000, image=False) == "password"
@case
def no_continue_button_is_left_to_a_person(page):
page.set_content(_IMAGE_PAGE % "Not me")
assert login.continue_security_image(page) is False
assert page.locator("#passwordField").is_disabled()
def main() -> int: def main() -> int:
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
+3
View File
@@ -237,6 +237,9 @@ def without_quiet_boxes_an_info_box_is_shown():
def main() -> int: def main() -> int:
failures = 0 failures = 0
# This PC's .env runs bulk with QUIET_BOXES=1 (2026-10-29); the cases
# that want it on set it themselves.
msgbox.cfg.QUIET_BOXES = False
for fn in CASES: for fn in CASES:
name = fn.__name__.replace("_", " ") name = fn.__name__.replace("_", " ")
try: try:
+130
View File
@@ -0,0 +1,130 @@
r"""
core/notify.py — the agent's email to the operator (operator, 2026-10-30):
what happened, what the agent did about it, and whether it recovered.
Rules the operator set:
* sent from the bot's own mailbox (SMTP, the 2FA mailbox's account);
* screenshots of login and error pages only — never a claim screen, never
page HTML: those carry PHI;
* no claim numbers in the mail.
python tests\test_notify.py
No mail is sent: the SMTP transport is a stand-in.
"""
import sys
import pathlib
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot import settings as cfg # noqa: E402
from ecw_bot.core import notify # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
def _capture():
sent = []
notify._transport = lambda msg: sent.append(msg)
return sent
def _files(tmp, *names):
out = []
for n in names:
p = pathlib.Path(tmp) / n
p.write_bytes(b"x")
out.append(p)
return out
@case
def an_incident_says_what_happened_and_what_the_agent_did(tmp):
sent = _capture()
assert notify.incident("Login failed", "2FA mail came 7 minutes late",
"Resent the OTP, will retry in 10 minutes",
recovered=False) is True
msg = sent[0]
assert msg["To"] == cfg.NOTIFY_EMAIL_TO
assert "Login failed" in msg["Subject"]
body = msg.get_body(("plain",)).get_content()
assert "2FA mail came 7 minutes late" in body
assert "Resent the OTP" in body
assert "Not recovered" in body
@case
def claim_numbers_never_reach_the_mail(tmp):
sent = _capture()
notify.incident("Stuck dialog on claim 9107000",
"Claim 9107000 form would not close", "Reloaded eCW",
recovered=True)
msg = sent[0]
text = msg["Subject"] + msg.get_body(("plain",)).get_content()
assert "9107000" not in text, text
@case
def only_login_and_error_screenshots_are_attached(tmp):
sent = _capture()
shots = _files(tmp, "211944_login_unconfirmed.png",
"9107000_claim_form_leftover_204500.png",
"211944_login_unconfirmed.html",
"101500_security_image_page.png")
notify.incident("Login failed", "x", "y", recovered=False, shots=shots)
names = sorted(a.get_filename() for a in sent[0].iter_attachments())
assert names == ["101500_security_image_page.png",
"211944_login_unconfirmed.png"], names
@case
def nothing_is_sent_when_no_recipient_is_set(tmp):
sent = _capture()
old = cfg.NOTIFY_EMAIL_TO
cfg.NOTIFY_EMAIL_TO = ""
try:
assert notify.incident("x", "y", "z", recovered=True) is False
finally:
cfg.NOTIFY_EMAIL_TO = old
assert sent == []
@case
def a_mail_server_error_never_stops_the_run(tmp):
def boom(msg):
raise OSError("connection refused")
notify._transport = boom
assert notify.incident("x", "y", "z", recovered=True) is False
def main() -> int:
failures = 0
real = notify._transport
cfg.NOTIFY_EMAIL_TO = "operator@example.test"
for fn in CASES:
name = fn.__name__.replace("_", " ")
with tempfile.TemporaryDirectory() as tmp:
try:
fn(tmp)
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
finally:
notify._transport = real
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -220,7 +220,8 @@ def the_header_names_the_user_and_admin_links_are_admin_only():
for name, role in (("sam", "Super Admin"), ("asha", "Admin")): for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
a, _ = _sign_in(app, name) a, _ = _sign_in(app, name)
page = a.get("/runs").text page = a.get("/runs").text
assert f"<b>{name.title()}</b> · {role}" in page, name # The side menu (2026-10-30): name over role.
assert f"<b>{name.title()}</b><small>{role}</small>" in page, name
assert 'href="/admin/users"' in page and 'href="/admin/signins"' in page assert 'href="/admin/users"' in page and 'href="/admin/signins"' in page
v, _ = _sign_in(app, "vee") v, _ = _sign_in(app, "vee")
page = v.get("/runs").text page = v.get("/runs").text
+168
View File
@@ -0,0 +1,168 @@
r"""
flows/recovery.py — the run gets itself out of a stuck screen (operator,
2026-10-30: "you can close or refresh and re-login and continue the agent").
Live 2026-10-29: a Dental claim's sliding fee check left Patient Information
open; its overlay took every click, and the next 18 claims were each marked
"Bot could not search the claim" without being looked at.
Now a claim the bot could not search or read sets off a recovery:
1. the login page on screen = the session ended → PageStuck (re-login);
2. Escape, close Patient Information, clear the screen → search ready?
3. reopen the claim search page → search ready?
4. still blocked → PageStuck.
After a recovery the same claim is tried once more. Two claims in a row
that still fail after their recovery → PageStuck, so the supervisor
restarts Chrome and logs in again.
python tests\test_recovery.py
No browser: the page steps are stand-ins.
"""
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot.flows import recovery # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
class Steps:
"""Stand-ins for the page steps; `blocked` = what search_blocker says,
in order, one per look."""
def __init__(self, blocked, logged_out=False):
self.blocked = list(blocked)
self.logged_out = logged_out
self.done = []
def install(self):
recovery._logged_out = lambda page: self.logged_out
recovery._escape_dialogs = lambda page: self.done.append("escape")
recovery._clear = lambda page, cid, keep: self.done.append("clear")
recovery._blocker = lambda page: self.blocked.pop(0)
def reopen(self, page):
self.done.append("reopen")
@case
def a_dialog_cleared_by_escape_needs_no_reopen():
s = Steps([None])
s.install()
assert recovery.recover(None, "1", (), s.reopen) == "cleared"
assert s.done == ["escape", "clear"], s.done
@case
def a_page_still_blocked_is_reopened():
s = Steps(["covered by div.modalOverlay", None])
s.install()
assert recovery.recover(None, "1", (), s.reopen) == "reopened"
assert s.done == ["escape", "clear", "reopen"], s.done
@case
def a_page_blocked_after_reopen_is_stuck():
s = Steps(["covered", "covered"])
s.install()
try:
recovery.recover(None, "1", (), s.reopen)
except recovery.PageStuck as exc:
assert "covered" in str(exc), exc
else:
raise AssertionError("no PageStuck")
@case
def the_login_page_means_the_session_ended():
s = Steps([], logged_out=True)
s.install()
try:
recovery.recover(None, "1", (), s.reopen)
except recovery.PageStuck as exc:
assert "login page" in str(exc), exc
else:
raise AssertionError("no PageStuck")
assert s.done == [], s.done
def _attempts(*outcomes):
"""attempt() returning each outcome in turn: 'ok' or 'bot'."""
seq = list(outcomes)
def attempt():
o = seq.pop(0)
return ({"search_error": "x"}, {}) if o == "bot" else ({}, {"cpt": []})
return attempt
@case
def a_claim_that_fails_is_recovered_and_tried_again():
tried = []
state = {}
r, data = recovery.run_claim(_attempts("bot", "ok"),
lambda: tried.append(1), state)
assert tried == [1] and not r.get("search_error"), (tried, r)
assert state["streak"] == 0
@case
def a_claim_that_fails_again_is_kept_as_review():
state = {}
r, _ = recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
assert r.get("search_error") and state["streak"] == 1
@case
def two_claims_in_a_row_failing_after_recovery_stop_the_run():
state = {}
recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
try:
recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
except recovery.PageStuck:
pass
else:
raise AssertionError("no PageStuck after two in a row")
@case
def a_good_claim_resets_the_streak():
state = {}
recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
recovery.run_claim(_attempts("ok"), lambda: None, state)
assert state["streak"] == 0
def main() -> int:
failures = 0
real = {k: getattr(recovery, k, None) for k in
("_logged_out", "_escape_dialogs", "_clear", "_blocker")}
for fn in CASES:
name = fn.__name__.replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
finally:
for k, v in real.items():
setattr(recovery, k, v)
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -739,7 +739,14 @@ def process_claims_counts_an_unreadable_form_as_not_read_and_reviews_it():
out = io.StringIO() out = io.StringIO()
try: try:
with contextlib.redirect_stdout(out): with contextlib.redirect_stdout(out):
# No page to recover on: flows/recovery.py's steps are
# its own tests' business (test_recovery.py).
real_recover = flow.recovery.recover
flow.recovery.recover = lambda *a: "cleared"
try:
flow._process_claims(None, {"ids": ["9103000"], "file": None}) flow._process_claims(None, {"ids": ["9103000"], "file": None})
finally:
flow.recovery.recover = real_recover
finally: finally:
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT, (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
cfg.OPEN_CLAIM_FORM, msgbox.info_box, claim_form.open_claim, cfg.OPEN_CLAIM_FORM, msgbox.info_box, claim_form.open_claim,
+140
View File
@@ -0,0 +1,140 @@
r"""
flows/supervisor.py — the agent loop around the bot (operator, 2026-10-30):
run the day's queue to the end without a person, restarting on technical
failures and mailing what happened.
Rules (operator's choices that day):
* login failures (exit 1, 6): retry after 10, 20, then 30 minutes; the
3rd failure in a row stops the agent and mails it — the client's eCW
account must not be restricted;
* restricted (4), eCW screen changed (7), config (2): stop at once — no
retry can fix them;
* a stuck page (9), a stall (8), a claims error (5): restart with a fresh
Chrome and login for the claims still pending;
* the attempt guard said wait (3): wait, it is not a failure;
* nothing pending: done. A clean run that left the same claims pending
as before: stop, those are for a person.
python tests\test_supervisor.py
No bot, no browser, no mail, no database: all stand-ins.
"""
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot.flows import supervisor as sv # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
def _decide(code, pending, state=None):
state = state if state is not None else sv.State()
return sv.next_action(code, pending, state), state
@case
def nothing_pending_is_done():
(action, _, _), _ = _decide(0, [])
assert action == "done"
@case
def login_failures_back_off_10_20_then_stop_on_the_third():
st = sv.State()
waits = []
for _ in range(2):
(action, wait, _), st = _decide(1, ["a"], st)
assert action == "retry"
waits.append(wait)
assert waits == [600, 1200], waits
(action, _, why), st = _decide(6, ["a"], st)
assert action == "stop" and "3 login" in why, why
@case
def a_good_login_resets_the_login_failures():
st = sv.State()
_decide(1, ["a"], st)
_decide(9, ["a"], st)
assert st.login_fails == 0
@case
def restricted_screen_changed_and_config_stop_at_once():
for code in (2, 4, 7):
(action, _, _), _ = _decide(code, ["a"])
assert action == "stop", code
@case
def a_stuck_page_restarts_soon():
(action, wait, _), _ = _decide(9, ["a", "b"])
assert action == "retry" and wait <= 120
@case
def the_guard_saying_wait_is_not_a_failure():
(action, wait, _), st = _decide(3, ["a"])
assert action == "retry" and wait >= 300 and st.login_fails == 0
@case
def a_clean_run_that_left_the_same_claims_stops():
st = sv.State()
(action, _, _), st = _decide(0, ["a", "b"], st)
assert action == "retry"
(action, _, why), st = _decide(0, ["a", "b"], st)
assert action == "stop" and "person" in why, why
@case
def pending_is_the_queue_minus_claims_done_since_the_agent_started():
queue = ["1", "2", "3", "4"]
rows = [("1", "Clean claims"), ("2", "Bot could not search the claim"),
("3", "Telehealth (POS 02 on T1017)")]
assert sv.pending(queue, rows) == ["2", "4"]
@case
def the_loop_restarts_mails_and_ends_when_nothing_is_left():
codes = [6, 9, 0]
left = [["1", "2"], ["2"], []]
runs, mails, slept = [], [], []
st = sv.run_loop(
run_bot=lambda resume: runs.append(resume) or codes.pop(0),
pending_now=lambda: left.pop(0),
mail=lambda kind, *a: mails.append(kind),
sleep=slept.append)
assert runs == [None, ["1", "2"], ["2"]], runs
assert slept == [600, 60], slept
assert mails == ["incident", "incident", "summary"], mails
assert st.outcome == "done"
def main() -> int:
failures = 0
for fn in CASES:
name = fn.__name__.replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+134
View File
@@ -0,0 +1,134 @@
r"""
auth/twofactor.py `handle_2fa()` — an approval the login page never picks up.
Live 2026-10-29, three logins in a row: the push mail came 6 to 8.5 minutes
late, the bot opened its link and clicked "Yes, it's me", and the login page
stayed on "eClinicalWorks sent a notification to your email" with Resend OTP
still there. The bot waited out LOGIN_TIMEOUT and gave up ("Login not
confirmed"). Now it presses Resend, waits for the new mail and approves that
one, up to OTP_PICKUP_RESENDS times (operator, 2026-10-30: no manual step).
python tests\test_twofa_late_approval.py
Local headless chromium, `page.set_content()` only. The mailbox and the
approval tab are stand-ins: no eCW, no mail.
"""
import sys
import pathlib
import tempfile
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from ecw_bot import settings as cfg # noqa: E402
from ecw_bot.auth import twofactor # noqa: E402
from ecw_bot.auth.mailbox import Challenge # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
# The push screen as seen live: the notice in #dispMessage, Resend enabled.
_PAGE = """
<div id="dispMessage">eClinicalWorks sent a notification to your email.
Please confirm to login.</div>
<input type="button" id="resendOtpBtn" value="Resend OTP"
onclick="window.resends = (window.resends || 0) + 1">
<a id="jellybean-panelLink4" style="display:none">Home</a>
<script>window.resends = 0</script>
"""
def _go_home(page):
page.evaluate("document.getElementById('jellybean-panelLink4')"
".style.display = ''; document.getElementById('dispMessage')"
".style.display = 'none'")
def _fakes(page, picked_up_on):
"""Mailbox hands out links in order; approval number `picked_up_on`
(1-based) is the one the page takes."""
seen = {"approvals": 0, "mails": 0}
def get_challenge(**kw):
seen["mails"] += 1
return Challenge("link", f"https://example.test/approve/{seen['mails']}")
def approve(context, url):
seen["approvals"] += 1
if seen["approvals"] == picked_up_on:
_go_home(page)
return True
twofactor.mailbox.get_challenge = get_challenge
twofactor._approve_in_new_tab = approve
return seen
@case
def an_approval_not_picked_up_is_resent_and_approved_again(page):
page.set_content(_PAGE)
seen = _fakes(page, picked_up_on=2)
twofactor.handle_2fa(page, None, "Test", None)
assert seen["approvals"] == 2, seen
assert page.evaluate("window.resends") == 1
assert page.locator("#jellybean-panelLink4").is_visible()
@case
def an_approval_picked_up_at_once_needs_no_resend(page):
page.set_content(_PAGE)
seen = _fakes(page, picked_up_on=1)
twofactor.handle_2fa(page, None, "Test", None)
assert seen["approvals"] == 1, seen
assert page.evaluate("window.resends || 0") == 0
@case
def resends_stop_at_the_limit(page):
page.set_content(_PAGE)
seen = _fakes(page, picked_up_on=99)
twofactor.handle_2fa(page, None, "Test", None)
assert page.evaluate("window.resends") == cfg.OTP_PICKUP_RESENDS
assert seen["approvals"] == cfg.OTP_PICKUP_RESENDS + 1, seen
def main() -> int:
from playwright.sync_api import sync_playwright
failures = 0
real = (twofactor.mailbox.get_challenge, twofactor._approve_in_new_tab)
with tempfile.TemporaryDirectory() as tmp:
cfg.SCREENSHOTS = pathlib.Path(tmp) # never the real folder
cfg.ECW_SECRET_KEY = ""
cfg.OTP_PICKUP_SECONDS = 2
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page = browser.new_page()
for fn in CASES:
name = fn.__name__.replace("_", " ")
try:
fn(page)
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
finally:
(twofactor.mailbox.get_challenge,
twofactor._approve_in_new_tab) = real
browser.close()
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+3 -1
View File
@@ -232,7 +232,9 @@ def a_box_says_chrome_is_open_before_the_login():
assert code == 0, code assert code == 0, code
assert seen[-1] == "login" and len(seen) == 2, seen assert seen[-1] == "login" and len(seen) == 2, seen
_, title, needs_person = seen[0] _, title, needs_person = seen[0]
assert "Chrome" in title and needs_person is True, seen # Operator, 2026-10-29: bulk runs show no box at all, so this one is
# quieted by QUIET_BOXES like any other (a log line instead).
assert "Chrome" in title and needs_person is False, seen
seen.clear() seen.clear()
code, _ = _main_with(lambda page: None, box, code, _ = _main_with(lambda page: None, box,
+31 -3
View File
@@ -283,6 +283,33 @@ def the_dashboard_lists_todays_top_fail_and_review_reasons():
assert "No Fail or Review today" in html assert "No Fail or Review today" in html
@case
def the_dashboard_lists_todays_runs_newest_first():
"""Operator, 2026-10-30 (the approved mockup): a Runs today card —
each run's start, its mode and its claims by result, from the same
rows the reasons card reads, so no second query."""
today = datetime.combine(date.today(), datetime.min.time())
first, second = today + timedelta(hours=16, minutes=39), \
today + timedelta(hours=17, minutes=18)
rows = ([_row("9000001", "Pass", 1, first),
_row("9000002", "Fail", 2, first)]
+ [_row(f"900001{i}", "Pass", i, second) for i in range(3)])
client, repo = _client(FakeRepo(days=[_day(date.today(), 5, 2, 4, 1, 0)],
rows=rows))
html = client.get("/").text
box = html.split('id="runs-today"', 1)[1].split("</section>", 1)[0]
assert box.index("5:18 PM") < box.index("4:39 PM"), box
assert "<b>3</b> claims" in box and "<b>2</b> claims" in box, box
assert repo.calls == [("days",), ("day_results", date.today())], \
repo.calls
@case
def the_dashboard_greets_the_signed_in_user():
html = _client()[0].get("/").text
assert re.search(r"Good (morning|afternoon|evening), ", html), html[:400]
@case @case
def every_page_has_the_footer_and_marks_where_you_are(): def every_page_has_the_footer_and_marks_where_you_are():
client, _ = _client() client, _ = _client()
@@ -440,14 +467,15 @@ def find_a_claim_with_no_number_shows_the_box_only():
# ── every page ─────────────────────────────────────────────────────────────── # ── every page ───────────────────────────────────────────────────────────────
@case @case
def every_page_has_the_navy_header_breadcrumb_and_mode(): def every_page_has_the_side_menu_breadcrumb_and_mode():
client, _ = _client() client, _ = _client()
for path in _PAGES: for path in _PAGES:
html = client.get(path).text html = client.get(path).text
assert '<header class="top">' in html, path # Sage side menu since 2026-10-30 (the approved mockup).
assert '<aside class="side">' in html, path
assert 'class="pill VERIFY"' in html, path assert 'class="pill VERIFY"' in html, path
assert '<nav class="crumbs">' in html, path assert '<nav class="crumbs">' in html, path
assert "--navy: #12355b" in html, path assert "--navy: #2e3f36" in html, path
html = client.get("/day/2026-10-16").text html = client.get("/day/2026-10-16").text
assert '<a href="/days">Claim status</a>' in html assert '<a href="/days">Claim status</a>' in html
assert '<span class="here">Fri, 16 Oct 2026</span>' in html assert '<span class="here">Fri, 16 Oct 2026</span>' in html
+25 -3
View File
@@ -283,6 +283,27 @@ def _reasons(rows, top=6) -> list:
for (cat, text), n in ordered[:top]] for (cat, text), n in ordered[:top]]
def _runs_today(rows) -> list:
"""The Dashboard's Runs today card (operator, 2026-10-30): one entry
per run in the day's rows, newest first, with its counts."""
runs = {}
for r in rows:
run = runs.setdefault(r.get("run_at"), {
"run_at": r.get("run_at"), "run_mode": r.get("run_mode"),
"claims": 0, "Pass": 0, "Fail": 0, "Review": 0})
run["claims"] += 1
if r.get("category") in CATEGORIES:
run[r["category"]] += 1
return sorted(runs.values(), key=lambda x: x["run_at"] or datetime.min,
reverse=True)
def _greeting(now=None) -> str:
hour = (now or datetime.now()).hour
return ("Good morning" if hour < 12 else
"Good afternoon" if hour < 17 else "Good evening")
def portal_settings(urls: str, server: bool) -> dict: def portal_settings(urls: str, server: bool) -> dict:
"""create_app's host / origin / cookie / Start settings from """create_app's host / origin / cookie / Start settings from
PORTAL_URLS (comma separated, as people type them in the browser) and PORTAL_URLS (comma separated, as people type them in the browser) and
@@ -461,11 +482,12 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
if today - timedelta(days=6) <= d["day"] <= today), if today - timedelta(days=6) <= d["day"] <= today),
key=lambda d: d["day"]) key=lambda d: d["day"])
# Only a day with claims costs the second query. # Only a day with claims costs the second query.
reasons = (_reasons(_db(repo.day_results, today)) rows = _db(repo.day_results, today) if counts["claims"] else []
if counts["claims"] else [])
return _page(request, "dashboard.html", [["Dashboard", None]], return _page(request, "dashboard.html", [["Dashboard", None]],
today=counts, has_runs=bool(days), today=counts, has_runs=bool(days),
alert=_last_run_alert(), week=week, reasons=reasons) alert=_last_run_alert(), week=week,
reasons=_reasons(rows), runs_today=_runs_today(rows),
greeting=_greeting())
@app.get("/days", response_class=HTMLResponse) @app.get("/days", response_class=HTMLResponse)
def days_page(request: Request): def days_page(request: Request):
+119 -93
View File
@@ -5,75 +5,99 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="run-token" content="{{ run_token }}"> <meta name="run-token" content="{{ run_token }}">
<title>{% block title %}Charge Validation{% endblock %} · eCW</title> <title>{% block title %}Charge Validation{% endblock %} · eCW</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2312355b'/%3E%3Ctext x='16' y='21' font-family='Segoe UI,Arial' font-size='13' font-weight='800' text-anchor='middle' fill='%23fff'%3ECV%3C/text%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232e3f36'/%3E%3Ctext x='16' y='21' font-family='Segoe UI,Arial' font-size='13' font-weight='800' text-anchor='middle' fill='%23fff'%3ECV%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
{% block head %}{% endblock %} {% block head %}{% endblock %}
<style> <style>
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light /* Sage (operator, 2026-10-30, the approved mockup): a deep sage side
grey, blue buttons; Pass green, Fail red, Review amber everywhere. */ menu, white rounded cards on soft sage-grey, a lime highlight; Pass
green, Fail orange, Review blue everywhere (told apart by lightness
too, not hue alone). The old names stay so every page follows. */
:root { :root {
--navy: #12355b; --navy-2: #0f2a47; --bg: #f4f6fa; --panel: #ffffff; --navy: #2e3f36; --navy-2: #24332b; --bg: #f2f4f1; --panel: #ffffff;
--text: #1f2a37; --muted: #6b7785; --line: #dde3ec; --head: #e8edf4; --text: #1b2420; --muted: #5b6661; --line: #e2e6e2; --head: #eef2ee;
--accent: #1565c0; --accent-dark: #0d4f9e; --accent: #4a6b58; --accent-dark: #3b5747; --lime: #d4e88a;
--pass: #2e7d32; --pass-bg: #e8f5e9; --pass: #245c3a; --pass-bg: #e3f0e7; --pass-bar: #4a6b58;
--fail: #c62828; --fail-bg: #fdecea; --fail: #9a4312; --fail-bg: #fbe9dd; --fail-bar: #d9772e;
--review: #b45309; --review-bg: #fff3e0; --review-bar: #ef6c00; --review: #244f96; --review-bg: #e3ecfa; --review-bar: #3b6fc4;
--stripe: #f9fafc; --hilite: #eef4ff; --sel: #dbe7fb; --stripe: #f8faf8; --hilite: #eef4ef; --sel: #dfe9e2;
--shadow: 0 1px 2px rgba(18, 53, 91, .06), 0 2px 8px rgba(18, 53, 91, .05); --shadow: 0 1px 2px rgba(27, 36, 32, .05), 0 2px 10px rgba(27, 36, 32, .04);
--display: Outfit, "Segoe UI", system-ui, sans-serif;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); body { margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.45 "Segoe UI", system-ui, sans-serif; font: 14px/1.45 Inter, "Segoe UI", system-ui, sans-serif;
display: flex; flex-direction: column; min-height: 100vh; } min-height: 100vh; }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
/* Header, breadcrumb, page and footer share one column (1180px), so .shell { display: flex; flex-wrap: wrap; gap: 20px; padding: 20px;
their edges line up (operator, 2026-10-26). */ min-height: 100vh; align-items: stretch; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 24px; width: 100%; } .page { flex: 999 1 560px; min-width: 0; display: flex;
header.top { background: var(--navy); color: #fff; } flex-direction: column; }
header.top .wrap { display: flex; align-items: center; gap: 14px; aside.side { flex: 1 1 232px; max-width: 260px; min-width: 220px;
min-height: 58px; } background: var(--navy); color: #f2f4f1; border-radius: 24px;
header.top .brand { color: #fff; font-size: 17px; font-weight: 600; padding: 22px 14px; display: flex; flex-direction: column;
letter-spacing: .2px; display: flex; align-items: center; } gap: 24px; }
header.top .brand:hover { text-decoration: none; } aside.side .brand { color: #f2f4f1; display: flex; align-items: center;
header.top .mark { display: inline-grid; place-items: center; width: 30px; gap: 12px; padding: 0 8px; }
height: 30px; border-radius: 8px; background: #fff; aside.side .brand:hover { text-decoration: none; }
color: var(--navy); font-size: 12px; font-weight: 800; aside.side .mark { display: inline-grid; place-items: center; width: 38px;
margin-right: 10px; letter-spacing: .5px; } height: 38px; border-radius: 12px; background: var(--lime);
.pill { border-radius: 12px; padding: 2px 10px; font-size: 11px; color: var(--navy); font-size: 13px; font-weight: 800;
font-weight: 700; letter-spacing: .4px; background: var(--pass); flex: none; }
color: #fff; } aside.side .brand b { display: block; font-family: var(--display);
.pill.LIVE-WRITE { background: var(--fail); } font-weight: 500; font-size: 17px; }
header.top nav.links { margin-left: auto; display: flex; gap: 4px; aside.side .brand small { color: #b9c6be; font-size: 12px; }
align-items: center; } .pill { border-radius: 999px; padding: 3px 10px; font-size: 11px;
header.top nav.links a { color: #e3ecf7; font-weight: 600; padding: 6px 11px; font-weight: 700; letter-spacing: .4px; background: var(--lime);
border-radius: 6px; transition: background .15s; } color: var(--navy); align-self: flex-start; margin-left: 8px; }
header.top nav.links a:hover { background: rgba(255, 255, 255, .08); .pill.LIVE-WRITE { background: var(--fail-bar); color: #fff; }
aside.side nav.links { display: flex; flex-direction: column; gap: 4px; }
aside.side nav.links a { color: #e3e9e5; font-weight: 500; height: 44px;
padding: 0 14px; border-radius: 12px;
display: flex; align-items: center; gap: 12px;
transition: background .15s; }
aside.side nav.links a::before { content: ""; width: 18px; height: 18px;
flex: none; background: currentColor;
-webkit-mask: var(--ic) center / contain no-repeat;
mask: var(--ic) center / contain no-repeat; }
aside.side nav.links a:hover { background: rgba(242, 244, 241, .08);
text-decoration: none; } text-decoration: none; }
header.top nav.links a.on { background: rgba(255, 255, 255, .16); color: #fff; } aside.side nav.links a.on { background: var(--lime); color: var(--navy);
header.top .who { color: #b9c6d6; font-size: 13px; display: flex; font-weight: 700; }
align-items: center; gap: 7px; margin-left: 10px; } aside.side nav.links a[href="/"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Crect x='3' y='3' width='7' height='9' rx='1.5'/%3E%3Crect x='14' y='3' width='7' height='5' rx='1.5'/%3E%3Crect x='14' y='12' width='7' height='9' rx='1.5'/%3E%3Crect x='3' y='16' width='7' height='5' rx='1.5'/%3E%3C/svg%3E"); }
header.top .who b { color: #fff; } aside.side nav.links a[href="/days"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Crect x='4' y='3' width='16' height='18' rx='2'/%3E%3Cpath d='M8 8h8M8 12h8M8 16h5'/%3E%3C/svg%3E"); }
header.top .avatar { display: inline-grid; place-items: center; width: 26px; aside.side nav.links a[href="/runs"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12h4l3 8 4-16 3 8h4'/%3E%3C/svg%3E"); }
height: 26px; border-radius: 50%; background: var(--accent); aside.side nav.links a[href$="/users"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='9' cy='8' r='3.5'/%3E%3Cpath d='M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5M16 4.5a3.5 3.5 0 0 1 0 7M18 14.8c1.8.8 3 2.6 3.5 5.2'/%3E%3C/svg%3E"); }
color: #fff; font-size: 12px; font-weight: 700; } aside.side nav.links a[href$="/signins"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7v5l3 2'/%3E%3C/svg%3E"); }
header.top form.out { margin: 0; } aside.side .who { margin-top: auto; display: flex; align-items: center;
header.top form.out button { background: transparent; border-color: #6f86a3; gap: 12px; padding: 12px; border-radius: 16px;
font-weight: 400; padding: 4px 10px; } background: rgba(242, 244, 241, .08); color: #f2f4f1; }
.msg { border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; } aside.side .who:hover { text-decoration: none;
background: rgba(242, 244, 241, .14); }
aside.side .who b { display: block; font-size: 14px; }
aside.side .who small { color: #b9c6be; font-size: 12px; }
aside.side .avatar { display: inline-grid; place-items: center; width: 36px;
height: 36px; border-radius: 50%; background: var(--accent);
color: #fff; font-size: 13px; font-weight: 700; flex: none; }
aside.side form.out { margin: 0; padding: 0 8px; }
aside.side form.out button { width: 100%; background: transparent;
border-color: rgba(242, 244, 241, .3);
font-weight: 500; }
aside.side form.out button:hover { background: rgba(242, 244, 241, .08); }
.msg { border-radius: 12px; padding: 10px 14px; margin-bottom: 14px; }
.msg.bad { background: var(--fail-bg); color: var(--fail); } .msg.bad { background: var(--fail-bg); color: var(--fail); }
.msg.good { background: var(--pass-bg); color: var(--pass); } .msg.good { background: var(--pass-bg); color: var(--pass); }
nav.crumbs { background: var(--navy-2); color: #b9c6d6; padding: 7px 0; nav.crumbs { color: var(--muted); padding: 4px 4px 0; font-size: 13px; }
font-size: 13px; } nav.crumbs a { color: var(--muted); }
nav.crumbs a { color: #e3ecf7; } nav.crumbs .sep { margin: 0 8px; color: #8a958f; }
nav.crumbs .sep { margin: 0 8px; color: #7d8ea3; } nav.crumbs .here { color: var(--text); font-weight: 600; }
nav.crumbs .here { color: #fff; font-weight: 600; } main { flex: 1; width: 100%; padding: 14px 4px 28px; }
main { flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; footer.foot { color: var(--muted); font-size: 12px; padding: 0 4px; }
padding: 22px 24px 40px; }
footer.foot { background: var(--panel); border-top: 1px solid var(--line);
color: var(--muted); font-size: 12px; }
footer.foot .wrap { display: flex; gap: 16px; align-items: center; footer.foot .wrap { display: flex; gap: 16px; align-items: center;
justify-content: space-between; flex-wrap: wrap; justify-content: space-between; flex-wrap: wrap;
padding-top: 12px; padding-bottom: 12px; } padding: 12px 0; border-top: 1px solid var(--line); }
footer.foot b { color: var(--text); font-weight: 600; } footer.foot b { color: var(--text); font-weight: 600; }
/* Motion: short, never in the way, off when Windows asks for less. */ /* Motion: short, never in the way, off when Windows asks for less. */
/* Movement only, never opacity: a tab the browser holds in the /* Movement only, never opacity: a tab the browser holds in the
@@ -87,36 +111,37 @@
a.tile, a.card { transition: transform .15s, box-shadow .15s, a.tile, a.card { transition: transform .15s, box-shadow .15s,
border-color .15s; } border-color .15s; }
a.tile:hover, a.card:hover { transform: translateY(-2px); a.tile:hover, a.card:hover { transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(18, 53, 91, .12); } box-shadow: 0 6px 18px rgba(27, 36, 32, .10); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; *, *::before, *::after { animation: none !important;
transition: none !important; } transition: none !important; }
} }
h1 { font-size: 20px; margin: 0 0 4px; } h1 { font-family: var(--display); font-weight: 300; font-size: 30px;
letter-spacing: -.3px; margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 16px; } .sub { color: var(--muted); margin: 0 0 16px; }
.card { background: var(--panel); border: 1px solid var(--line); .card { background: var(--panel); border: 0; border-radius: 22px;
border-radius: 10px; padding: 16px 18px; box-shadow: var(--shadow); padding: 20px 22px; box-shadow: var(--shadow); margin-bottom: 18px; }
margin-bottom: 18px; } .card h2 { font-family: var(--display); font-weight: 500; font-size: 19px;
.card h2 { font-size: 15px; margin: 0 0 10px; } margin: 0 0 10px; }
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px; margin-bottom: 18px; } gap: 12px; margin-bottom: 18px; }
.tile { background: var(--panel); border: 1px solid var(--line); .tile { background: var(--panel); border: 2px solid transparent;
border-radius: 10px; padding: 12px 16px; box-shadow: var(--shadow); border-radius: 20px; padding: 16px 18px; box-shadow: var(--shadow);
color: var(--text); display: block; } color: var(--text); display: block; }
.tile .n { font-size: 26px; font-weight: 700; display: block; } .tile .n { font-family: var(--display); font-size: 36px; font-weight: 500;
.tile .l { color: var(--muted); font-size: 12px; text-transform: uppercase; line-height: 1.1; display: block; }
letter-spacing: .5px; } .tile .l { color: var(--muted); font-size: 13px; }
.tile.Pass .n { color: var(--pass); } .tile.Pass .n { color: var(--pass); }
.tile.Fail .n { color: var(--fail); } .tile.Fail .n { color: var(--fail); }
.tile.Review .n { color: var(--review); } .tile.Review .n { color: var(--review); }
a.tile:hover { text-decoration: none; border-color: var(--accent); } a.tile:hover { text-decoration: none; border-color: var(--accent); }
a.tile.on { border: 2px solid var(--accent); padding: 11px 15px; } a.tile.on { border-color: var(--accent); }
select, input, button { font: inherit; padding: 7px 10px; select, input, button { font: inherit; padding: 8px 12px;
border: 1px solid #c9d2de; border-radius: 6px; border: 1px solid #d5dbd6; border-radius: 10px;
background: var(--panel); color: var(--text); } background: var(--panel); color: var(--text); }
button, .btn { background: var(--accent); color: #fff; button, .btn { background: var(--accent); color: #fff;
border: 1px solid var(--accent); border-radius: 6px; border: 1px solid var(--accent); border-radius: 10px;
padding: 7px 14px; cursor: pointer; font-weight: 600; padding: 8px 16px; cursor: pointer; font-weight: 600;
display: inline-block; } display: inline-block; }
button:hover, .btn:hover { background: var(--accent-dark); button:hover, .btn:hover { background: var(--accent-dark);
text-decoration: none; } text-decoration: none; }
@@ -124,7 +149,7 @@
border-color: #c9d2de; font-weight: 400; } border-color: #c9d2de; font-weight: 400; }
button.plain:hover { background: var(--hilite); } button.plain:hover { background: var(--hilite); }
button[disabled] { opacity: .5; cursor: default; } button[disabled] { opacity: .5; cursor: default; }
.cat { display: inline-block; padding: 2px 9px; border-radius: 10px; .cat { display: inline-block; padding: 2px 10px; border-radius: 999px;
font-weight: 600; font-size: 12px; } font-weight: 600; font-size: 12px; }
.cat.Pass { background: var(--pass-bg); color: var(--pass); } .cat.Pass { background: var(--pass-bg); color: var(--pass); }
.cat.Fail { background: var(--fail-bg); color: var(--fail); } .cat.Fail { background: var(--fail-bg); color: var(--fail); }
@@ -135,15 +160,14 @@
.note { color: var(--muted); font-size: 12px; } .note { color: var(--muted); font-size: 12px; }
/* Tables: short, no sideways scroll; long text cut on one line. */ /* Tables: short, no sideways scroll; long text cut on one line. */
table.list { width: 100%; border-collapse: separate; border-spacing: 0; table.list { width: 100%; border-collapse: separate; border-spacing: 0;
background: var(--panel); border: 1px solid var(--line); background: var(--panel); border: 0; box-shadow: var(--shadow);
border-radius: 10px; overflow: hidden; table-layout: fixed; } border-radius: 18px; overflow: hidden; table-layout: fixed; }
table.list th, table.list td { padding: 9px 12px; text-align: left; table.list th, table.list td { padding: 9px 12px; text-align: left;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
overflow: hidden; text-overflow: ellipsis; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; } white-space: nowrap; }
table.list th { background: var(--head); font-size: 12px; color: #3b4757; table.list th { background: var(--head); font-size: 12px;
font-weight: 600; text-transform: uppercase; color: var(--muted); font-weight: 600; }
letter-spacing: .4px; }
table.list tbody tr { cursor: pointer; } table.list tbody tr { cursor: pointer; }
table.list tbody tr:nth-child(even) td { background: var(--stripe); } table.list tbody tr:nth-child(even) td { background: var(--stripe); }
table.list tbody tr:hover td { background: var(--hilite); } table.list tbody tr:hover td { background: var(--hilite); }
@@ -167,7 +191,7 @@
.drawer { position: fixed; top: 0; right: 0; height: 100vh; .drawer { position: fixed; top: 0; right: 0; height: 100vh;
width: min(480px, 100vw); background: var(--panel); width: min(480px, 100vw); background: var(--panel);
border-left: 1px solid var(--line); z-index: 20; border-left: 1px solid var(--line); z-index: 20;
box-shadow: -8px 0 24px rgba(18, 53, 91, .12); box-shadow: -8px 0 24px rgba(27, 36, 32, .12);
padding: 0 0 30px; overflow-y: auto; } padding: 0 0 30px; overflow-y: auto; }
.drawer[hidden] { display: none; } .drawer[hidden] { display: none; }
.drawer .top { display: flex; align-items: center; gap: 10px; .drawer .top { display: flex; align-items: center; gap: 10px;
@@ -186,10 +210,10 @@
white-space: pre-wrap; font-size: 12px; margin: 0; } white-space: pre-wrap; font-size: 12px; margin: 0; }
@media (max-width: 760px) { @media (max-width: 760px) {
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
main { padding: 16px; } .shell { padding: 12px; gap: 12px; }
.wrap { padding-left: 16px; padding-right: 16px; } aside.side { max-width: none; flex-basis: 100%; padding: 14px; gap: 12px; }
header.top .wrap { flex-wrap: wrap; padding-top: 8px; padding-bottom: 8px; } aside.side nav.links { flex-direction: row; flex-wrap: wrap; }
header.top nav.links { margin-left: 0; flex-wrap: wrap; } aside.side .who { margin-top: 0; }
} }
{% block style %}{% endblock %} {% block style %}{% endblock %}
</style> </style>
@@ -213,17 +237,17 @@
</head> </head>
<body> <body>
{% set section = crumbs[1][0] if crumbs | length > 1 else crumbs[0][0] %} {% set section = crumbs[1][0] if crumbs | length > 1 else crumbs[0][0] %}
<header class="top"> <div class="shell">
<div class="wrap"> {% if user %}<aside class="side">
<a class="brand" href="/"><span class="mark" aria-hidden="true">CV</span>eCW Charge Validation</a> <a class="brand" href="/"><span class="mark" aria-hidden="true">CV</span><span><b>Charge Validation</b><small>eCW · Salud</small></span></a>
<span class="pill {{ mode }}" title="SAVE_CORRECTIONS mode">{{ mode }}</span> <span class="pill {{ mode }}" title="SAVE_CORRECTIONS mode">{{ mode }}</span>
{% if user %}<nav class="links"> <nav class="links" aria-label="Main">
{% for label, link, admin in [("Dashboard", "/", False), ("Claim status", "/days", False), ("Runs", "/runs", False), ("Users", "/admin/users", True), ("Sign-ins", "/admin/signins", True)] %}{% if not admin or is_admin %}{% if label == section %}<a href="{{ link }}" class="on" aria-current="page">{{ label }}</a>{% else %}<a href="{{ link }}">{{ label }}</a>{% endif %}{% endif %}{% endfor %} {% for label, link, admin in [("Dashboard", "/", False), ("Claim status", "/days", False), ("Runs", "/runs", False), ("Users", "/admin/users", True), ("Sign-ins", "/admin/signins", True)] %}{% if not admin or is_admin %}{% if label == section %}<a href="{{ link }}" class="on" aria-current="page">{{ label }}</a>{% else %}<a href="{{ link }}">{{ label }}</a>{% endif %}{% endif %}{% endfor %}
<a class="who" href="/account/password" title="Change password"><span class="avatar" aria-hidden="true">{{ (user.display_name or "?")[:1] | upper }}</span><b>{{ user.display_name }}</b> · {{ user.role }}</a> </nav>
<a class="who" href="/account/password" title="Change password"><span class="avatar" aria-hidden="true">{{ (user.display_name or "?")[:1] | upper }}</span><span><b>{{ user.display_name }}</b><small>{{ user.role }}</small></span></a>
<form class="out" method="post" action="/signout"><input type="hidden" name="token" value="{{ run_token }}"><button type="submit">Sign out</button></form> <form class="out" method="post" action="/signout"><input type="hidden" name="token" value="{{ run_token }}"><button type="submit">Sign out</button></form>
</nav>{% endif %} </aside>{% endif %}
</div> <div class="page">
</header>
<nav class="crumbs"> <nav class="crumbs">
<div class="wrap">{% for label, link in crumbs %}{% if not loop.first %}<span class="sep">›</span>{% endif %}{% if link %}<a href="{{ link }}">{{ label }}</a>{% else %}<span class="here">{{ label }}</span>{% endif %}{% endfor %}</div> <div class="wrap">{% for label, link in crumbs %}{% if not loop.first %}<span class="sep">›</span>{% endif %}{% if link %}<a href="{{ link }}">{{ label }}</a>{% else %}<span class="here">{{ label }}</span>{% endif %}{% endfor %}</div>
</nav> </nav>
@@ -233,11 +257,13 @@
<footer class="foot"> <footer class="foot">
<div class="wrap"> <div class="wrap">
<span><b>eCW Charge Validation</b> · Ecare India</span> <span><b>eCW Charge Validation</b> · Ecare India</span>
<span class="phi">Local page — contains patient data; do not share <span class="phi">Contains patient data; do not share screenshots
screenshots outside the team.</span> outside the team.</span>
<span>Mode <b>{{ mode }}</b></span> <span>Mode <b>{{ mode }}</b></span>
</div> </div>
</footer> </footer>
</div>
</div>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+117 -87
View File
@@ -1,79 +1,98 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block style %} {% block style %}
/* The approved mockup (operator, 2026-10-30): greeting and claim search
on top, today's tiles, the lime Agent run card beside results by day,
then today's reasons beside today's runs. */
header.hello { display: flex; flex-wrap: wrap; gap: 14px;
align-items: center; justify-content: space-between;
margin-bottom: 18px; }
header.hello h1 { font-size: 34px; }
header.hello .sub { margin: 0; font-size: 15px; }
form.find { display: flex; gap: 8px; }
form.find input { width: 240px; height: 44px; background: var(--panel); }
form.find button { height: 44px; }
.tiles .tile .head { display: flex; justify-content: space-between;
align-items: center; }
.tile .pct { float: right; font-size: 12px; font-weight: 700;
padding: 2px 9px; border-radius: 999px; margin-top: 2px; }
.tile.Pass .pct { background: var(--pass-bg); color: var(--pass); }
.tile.Fail .pct { background: var(--fail-bg); color: var(--fail); }
.tile.Review .pct { background: var(--review-bg); color: var(--review); }
.tile .rate { display: block; margin-top: 6px; font-size: 12px;
color: var(--pass); font-weight: 600; }
.row2 { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 18px; }
.row2 > .card { margin-bottom: 0; flex: 1 1 340px; min-width: 0; }
.row2 > #week, .row2 > #runs-today { flex: 2 1 420px; }
.card-h { display: flex; justify-content: space-between;
align-items: baseline; margin-bottom: 14px; gap: 10px; }
.card-h h2 { margin: 0; }
/* Agent run: the lime card. */
.botrun { background: var(--lime); color: var(--text); }
.botrun .row { display: flex; gap: 12px; align-items: center; .botrun .row { display: flex; gap: 12px; align-items: center;
flex-wrap: wrap; } flex-wrap: wrap; }
.botrun .row + .row { margin-top: 10px; } .botrun .row + .row { margin-top: 12px; }
.botrun h2 { margin: 0; } .botrun h2 { margin: 0; }
#br-message { color: var(--muted); } #br-message { color: var(--navy); font-weight: 600; }
.botrun button.stop { background: var(--fail); border-color: var(--fail); } .botrun button { background: var(--text); border-color: var(--text); }
.botrun progress { width: 260px; height: 10px; accent-color: var(--accent); } .botrun button:hover { background: #000; }
.botrun pre { background: var(--head); padding: 10px; border-radius: 6px; .botrun button.plain { background: rgba(255, 255, 255, .6);
max-height: 260px; overflow: auto; font-size: 12px; border-color: transparent; color: var(--text); }
white-space: pre-wrap; } .botrun button.stop { background: var(--fail-bar); border-color: var(--fail-bar); }
.botrun summary { cursor: pointer; color: var(--accent); margin-top: 10px; } .botrun .note { color: var(--navy); }
.chip { padding: 2px 9px; border-radius: 10px; font-size: 12px; .botrun progress { width: 220px; height: 10px; accent-color: var(--navy); }
.botrun pre { background: rgba(255, 255, 255, .6); padding: 10px;
border-radius: 12px; max-height: 260px; overflow: auto;
font-size: 12px; white-space: pre-wrap; }
.botrun summary { cursor: pointer; color: var(--navy); margin-top: 10px;
font-weight: 600; }
.chip { padding: 2px 9px; border-radius: 999px; font-size: 12px;
font-weight: 600; } font-weight: 600; }
/* `.row { display: flex }` beats the browser's [hidden] rule. */ /* `.row { display: flex }` beats the browser's [hidden] rule. */
.botrun [hidden] { display: none; } .botrun [hidden] { display: none; }
.tile .rate { display: block; margin-top: 4px; font-size: 12px;
color: var(--pass); font-weight: 600; } /* Results by day. */
/* Two columns, then three equal cards: every row the same edges. */
.split { display: grid; grid-template-columns: 3fr 2fr; gap: 18px;
margin-bottom: 18px; }
.split .card, .actions .card { margin-bottom: 0; }
.card-h { display: flex; justify-content: space-between;
align-items: baseline; margin-bottom: 12px; }
.card-h h2 { margin: 0; }
.week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); .week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px; align-items: end; height: 170px; } gap: 12px; align-items: end; height: 190px; }
a.wd { display: flex; flex-direction: column; align-items: center; a.wd { display: flex; flex-direction: column; align-items: center;
justify-content: flex-end; height: 100%; color: var(--text); justify-content: flex-end; height: 100%; color: var(--text);
text-decoration: none; } text-decoration: none; }
a.wd .col { display: flex; flex-direction: column; width: 70%; a.wd .col { display: flex; flex-direction: column; width: 70%;
max-width: 42px; border-radius: 6px 6px 2px 2px; max-width: 44px; border-radius: 8px; overflow: hidden;
overflow: hidden; background: var(--head); min-height: 4px; background: var(--head); min-height: 4px; height: 0; }
height: 0; }
a.wd:hover .col { outline: 2px solid var(--accent); outline-offset: 2px; } a.wd:hover .col { outline: 2px solid var(--accent); outline-offset: 2px; }
a.wd .col i { display: block; } a.wd .col i { display: block; }
a.wd .col i.Pass { background: var(--pass); } a.wd .col i.Pass { background: var(--pass-bar); }
a.wd .col i.Fail { background: var(--fail); } a.wd .col i.Fail { background: var(--fail-bar); }
a.wd .col i.Review { background: var(--review-bar); } a.wd .col i.Review { background: var(--review-bar); }
a.wd .cnt { font-size: 12px; font-weight: 700; margin-bottom: 4px; } a.wd .cnt { font-size: 12px; font-weight: 700; margin-bottom: 4px; }
a.wd .lbl { font-size: 12px; color: var(--muted); margin-top: 6px; } a.wd .lbl { font-size: 12px; color: var(--muted); margin-top: 6px; }
.legend { display: flex; gap: 14px; margin-top: 10px; font-size: 12px; .legend { display: flex; gap: 14px; font-size: 13px; color: var(--muted); }
color: var(--muted); } .legend span::before { content: ""; display: inline-block; width: 10px;
.legend span::before { content: ""; display: inline-block; width: 9px; height: 10px; border-radius: 3px; margin-right: 6px; }
height: 9px; border-radius: 2px; margin-right: 5px; } .legend .Pass::before { background: var(--pass-bar); }
.legend .Pass::before { background: var(--pass); } .legend .Fail::before { background: var(--fail-bar); }
.legend .Fail::before { background: var(--fail); }
.legend .Review::before { background: var(--review-bar); } .legend .Review::before { background: var(--review-bar); }
ol.reasons { list-style: none; margin: 0; padding: 0; }
ol.reasons li { display: flex; gap: 10px; align-items: baseline; /* Today's reasons and runs. */
padding: 7px 0; border-bottom: 1px solid var(--line); } ol.reasons, ol.runs { list-style: none; margin: 0; padding: 0; }
ol.reasons li:last-child { border-bottom: 0; } ol.reasons li, ol.runs li { display: flex; gap: 12px; align-items: center;
ol.reasons b { min-width: 30px; text-align: center; border-radius: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); }
padding: 1px 8px; font-size: 12px; } ol.reasons li:last-child, ol.runs li:last-child { border-bottom: 0; }
ol.reasons b { min-width: 34px; text-align: center; border-radius: 999px;
padding: 2px 9px; font-size: 12px; }
ol.reasons b.Fail { background: var(--fail-bg); color: var(--fail); } ol.reasons b.Fail { background: var(--fail-bg); color: var(--fail); }
ol.reasons b.Review { background: var(--review-bg); color: var(--review); } ol.reasons b.Review { background: var(--review-bg); color: var(--review); }
ol.reasons span { overflow: hidden; text-overflow: ellipsis; ol.reasons span { overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; } white-space: nowrap; }
.actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); ol.runs .t { font-weight: 600; width: 72px; }
gap: 18px; align-items: stretch; } ol.runs .m { font-size: 11px; font-weight: 700; padding: 2px 9px;
a.card.go { display: flex; align-items: center; gap: 14px; color: var(--text); } border-radius: 999px; background: var(--head); color: var(--navy); }
a.card.go:hover { text-decoration: none; border-color: var(--accent); } ol.runs .c { margin-left: auto; color: var(--muted); }
a.card.go b, form.search > b { display: block; font-size: 15px; } ol.runs .c b { color: var(--text); }
a.card.go .ic { display: grid; place-items: center; width: 40px; height: 40px; ol.runs .cat { min-width: 40px; text-align: center; }
border-radius: 10px; background: var(--hilite);
color: var(--accent); font-size: 20px; flex: none; }
a.card.go .arrow { margin-left: auto; color: var(--accent); font-size: 18px; }
a.card.go .note { display: block; }
form.search.card { display: block; }
form.search .find { display: flex; gap: 8px; margin-top: 8px; }
form.search .find input { flex: 1; min-width: 0; }
.today-h { display: flex; justify-content: space-between; align-items: baseline;
margin: 4px 0 10px; }
.today-h h2 { font-size: 15px; margin: 0; }
#live-summary { color: var(--muted); margin: 0 0 10px; } #live-summary { color: var(--muted); margin: 0 0 10px; }
#live-list { list-style: none; margin: 0; padding: 0; } #live-list { list-style: none; margin: 0; padding: 0; }
#live-list li { display: flex; gap: 10px; align-items: center; #live-list li { display: flex; gap: 10px; align-items: center;
@@ -87,15 +106,25 @@
#live-list li.st-checking .st { color: var(--accent); font-weight: 600; } #live-list li.st-checking .st { color: var(--accent); font-weight: 600; }
#live-list li.st-idle .st { color: var(--muted); } #live-list li.st-idle .st { color: var(--muted); }
@media (max-width: 760px) { @media (max-width: 760px) {
.actions, .split { grid-template-columns: 1fr; } form.find, form.find input { width: 100%; }
} }
/* The last Run ended badly (EEWA-33). */ /* The last Run ended badly (EEWA-33). */
.alert { background: var(--fail-bg); color: var(--fail); .alert { background: var(--fail-bg); color: var(--fail);
border: 1px solid #f5c2bd; border-left: 5px solid var(--fail); border-radius: 16px; padding: 12px 16px; margin-bottom: 18px; }
border-radius: 10px; padding: 12px 16px; margin-bottom: 18px; }
.alert a { color: var(--fail); font-weight: 600; text-decoration: underline; } .alert a { color: var(--fail); font-weight: 600; text-decoration: underline; }
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<header class="hello">
<div>
<h1>{{ greeting }}, {{ (user.display_name or "").split(" ")[0] }}</h1>
<p class="sub">{{ today.day | longdate }} · Medcor Ready queue</p>
</div>
<form class="find" method="get" action="/claim">
<input type="search" name="claim_no" placeholder="Search claim number"
inputmode="numeric" aria-label="Claim number">
<button type="submit">Find</button>
</form>
</header>
{% if alert %} {% if alert %}
<div class="alert" id="last-run-alert" role="alert"> <div class="alert" id="last-run-alert" role="alert">
<b>The last run {{ "ended in a Run Error" if alert.status == "Run Error" else "ended without a record (killed or crashed)" }}</b> <b>The last run {{ "ended in a Run Error" if alert.status == "Run Error" else "ended without a record (killed or crashed)" }}</b>
@@ -103,10 +132,20 @@
<a href="/runs/{{ alert.run_id }}">See the run</a> <a href="/runs/{{ alert.run_id }}">See the run</a>
</div> </div>
{% endif %} {% endif %}
<div class="tiles">
<a class="tile" href="/day/{{ today.day.isoformat() }}"><span class="n">{{ today.claims }}</span><span class="l">Checked today</span>{% if today.claims %}<span class="rate">{{ (100 * today.pass_count / today.claims) | round | int }}% pass</span>{% endif %}</a>
<a class="tile Pass" href="/day/{{ today.day.isoformat() }}?category=Pass"><span class="n">{{ today.pass_count }}</span><span class="l">Pass</span>{% if today.claims %}<span class="pct">{{ (100 * today.pass_count / today.claims) | round | int }}%</span>{% endif %}</a>
<a class="tile Fail" href="/day/{{ today.day.isoformat() }}?category=Fail"><span class="n">{{ today.fail_count }}</span><span class="l">Fail</span>{% if today.claims %}<span class="pct">{{ (100 * today.fail_count / today.claims) | round | int }}%</span>{% endif %}</a>
<a class="tile Review" href="/day/{{ today.day.isoformat() }}?category=Review"><span class="n">{{ today.review_count }}</span><span class="l">Review</span>{% if today.claims %}<span class="pct">{{ (100 * today.review_count / today.claims) | round | int }}%</span>{% endif %}</a>
</div>
<div class="row2">
<section class="card botrun" id="botrun"> <section class="card botrun" id="botrun">
<div class="row"> <div class="row">
<h2>Bot run</h2> <h2>Agent run</h2>
<span id="br-message">…</span> <span id="br-message">…</span>
</div>
<div class="row">
<button type="button" id="br-start"{% if not can_run %} hidden{% endif %}>▶ Start run</button> <button type="button" id="br-start"{% if not can_run %} hidden{% endif %}>▶ Start run</button>
<button type="button" id="br-stop" class="stop" hidden>Stop</button> <button type="button" id="br-stop" class="stop" hidden>Stop</button>
<span id="br-terminal" hidden>Run started in the terminal — stop it <span id="br-terminal" hidden>Run started in the terminal — stop it
@@ -128,20 +167,10 @@
<pre id="br-log"></pre></details> <pre id="br-log"></pre></details>
</section> </section>
<div class="today-h"> <section class="card" id="week">
<h2>Today · {{ today.day | longdate }}</h2> <div class="card-h"><h2>Results by day</h2>
{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}">Open today's claims →</a>{% endif %} <div class="legend"><span class="Pass">Pass</span><span class="Fail">Fail</span><span class="Review">Review</span></div>
</div> <a href="/days" id="claim-status">All days →</a></div>
<div class="tiles">
<a class="tile" href="/day/{{ today.day.isoformat() }}"><span class="n">{{ today.claims }}</span><span class="l">Checked today</span>{% if today.claims %}<span class="rate">{{ (100 * today.pass_count / today.claims) | round | int }}% pass</span>{% endif %}</a>
<a class="tile Pass" href="/day/{{ today.day.isoformat() }}?category=Pass"><span class="n">{{ today.pass_count }}</span><span class="l">Pass</span></a>
<a class="tile Fail" href="/day/{{ today.day.isoformat() }}?category=Fail"><span class="n">{{ today.fail_count }}</span><span class="l">Fail</span></a>
<a class="tile Review" href="/day/{{ today.day.isoformat() }}?category=Review"><span class="n">{{ today.review_count }}</span><span class="l">Review</span></a>
</div>
<div class="split">
<section class="card" id="week">
<div class="card-h"><h2>Last 7 days</h2><a href="/days">All days →</a></div>
{% if week %} {% if week %}
<div class="week"> <div class="week">
{% for d in week %} {% for d in week %}
@@ -152,13 +181,15 @@
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
<div class="legend"><span class="Pass">Pass</span><span class="Fail">Fail</span><span class="Review">Review</span></div>
{% else %} {% else %}
<p class="note">No claims checked in the last 7 days.</p> <p class="note">No claims checked in the last 7 days.</p>
{% endif %} {% endif %}
</section> </section>
<section class="card" id="reasons"> </div>
<div class="card-h"><h2>Today's Fail and Review reasons</h2>{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}?category=Fail">Open →</a>{% endif %}</div>
<div class="row2">
<section class="card" id="reasons">
<div class="card-h"><h2>Top reasons today</h2>{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}?category=Fail">Open →</a>{% endif %}</div>
{% if reasons %} {% if reasons %}
<ol class="reasons"> <ol class="reasons">
{% for r in reasons %}<li><b class="{{ r.category }}">{{ r.count }}</b><span>{{ r.comment }}</span></li> {% for r in reasons %}<li><b class="{{ r.category }}">{{ r.count }}</b><span>{{ r.comment }}</span></li>
@@ -167,20 +198,19 @@
{% else %} {% else %}
<p class="note">No Fail or Review today.</p> <p class="note">No Fail or Review today.</p>
{% endif %} {% endif %}
</section> </section>
</div>
<div class="actions"> <section class="card" id="runs-today">
<a class="card go" href="/days" id="claim-status"><span class="ic" aria-hidden="true">▦</span><span><b>Claim status</b><span class="note">Every day's claims, by result</span></span><span class="arrow">→</span></a> <div class="card-h"><h2>Runs today</h2><a href="/runs">All runs →</a></div>
<a class="card go" href="/runs"><span class="ic" aria-hidden="true">◷</span><span><b>Runs</b><span class="note">Logins, timings, errors</span></span><span class="arrow">→</span></a> {% if runs_today %}
<form class="search card" method="get" action="/claim"> <ol class="runs">
<b>Find a claim</b> {% for r in runs_today %}<li><span class="t">{{ r.run_at | clock }}</span><span class="m">{{ r.run_mode }}</span><span class="c"><b>{{ r.claims }}</b> claims</span><span class="cat Pass">{{ r.Pass }}</span><span class="cat Fail">{{ r.Fail }}</span><span class="cat Review">{{ r.Review }}</span></li>
<div class="find"> {% endfor %}
<input type="search" name="claim_no" placeholder="Claim #" </ol>
inputmode="numeric" aria-label="Claim number"> {% else %}
<button type="submit">Find</button> <p class="note">No runs today yet.</p>
</div> {% endif %}
</form> </section>
</div> </div>
{% if not has_runs %} {% if not has_runs %}
<p class="note">No results saved yet. Run the bot with <code>RESULTS_DB=1</code> <p class="note">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
+80 -91
View File
@@ -1,110 +1,98 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sign in{% endblock %} {% block title %}Sign in{% endblock %}
{% block style %} {% block style %}
/* Operator, 2026-10-26: the Portal's front door — what it is on the /* The approved mockup (operator, 2026-10-30): a deep sage panel with
left, the form on the right; one column on a phone. */ what the Portal is, the form in a white card; one column on a phone. */
/* A slow-moving navy-to-blue background, this page only. */ footer.foot { display: none; }
body { background: linear-gradient(120deg, #0f2a47, #12355b 35%, main { padding: 0; }
#1d4f86 65%, #1565c0); nav.crumbs { display: none; }
background-size: 220% 220%; animation: drift 18s ease infinite; } .auth { display: flex; flex-wrap: wrap; gap: 20px;
@keyframes drift { 0%, 100% { background-position: 0% 50%; } min-height: calc(100vh - 40px); }
50% { background-position: 100% 50%; } } .auth-brand { flex: 1 1 520px; background: var(--navy); color: #f2f4f1;
footer.foot { background: rgba(255, 255, 255, .92); } border-radius: 28px; padding: 44px 52px; display: flex;
.auth { display: grid; grid-template-columns: 5fr 6fr; max-width: 860px; flex-direction: column; justify-content: space-between;
margin: 40px auto; background: var(--panel); border-radius: 16px; gap: 32px; }
overflow: hidden; box-shadow: 0 24px 60px rgba(4, 20, 40, .35); .auth-brand .logo { display: flex; align-items: center; gap: 12px;
font-family: var(--display); font-weight: 500;
font-size: 18px; }
.auth-brand .big { display: inline-grid; place-items: center; width: 42px;
height: 42px; border-radius: 12px; background: var(--lime);
color: var(--navy); font-size: 14px; font-weight: 800; }
.auth-brand h1 { font-family: var(--display); font-weight: 200;
font-size: clamp(52px, 7vw, 88px); line-height: .95;
letter-spacing: -2px; margin: 0 0 18px; color: #f2f4f1; }
.auth-brand h1 span { color: var(--lime); font-weight: 300; }
.auth-brand p.lead { margin: 0; font-size: 17px; line-height: 1.55;
color: #c9d4cd; max-width: 520px; }
.auth-brand ul { list-style: none; margin: 0; padding: 0; display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px; }
.auth-brand li { background: rgba(242, 244, 241, .08); border-radius: 18px;
padding: 16px; display: flex; flex-direction: column;
gap: 8px; animation: rise .5s ease both; }
.auth-brand li:nth-child(2) { animation-delay: .1s; }
.auth-brand li:nth-child(3) { animation-delay: .2s; }
.auth-brand li svg { color: var(--lime); }
.auth-brand li b { color: #f2f4f1; font-size: 15px; }
.auth-brand li small { color: #c9d4cd; font-size: 13px; line-height: 1.45; }
.auth-side { flex: 999 1 400px; display: flex; align-items: center;
justify-content: center; padding: 24px 8px; }
.auth-form { width: 100%; max-width: 420px; background: var(--panel);
border-radius: 28px; padding: 40px; box-shadow: var(--shadow);
animation: rise .5s ease both; } animation: rise .5s ease both; }
.auth-brand { background: var(--navy); color: #dbe5f1; padding: 40px 34px; .auth-form h2 { font-family: var(--display); font-weight: 500;
display: flex; flex-direction: column; gap: 22px; font-size: 32px; letter-spacing: -.5px; margin: 0 0 6px; }
position: relative; overflow: hidden; } .auth-form .intro { color: var(--muted); margin: 0 0 22px; font-size: 15px; }
/* Two soft circles drifting behind the brand. */ .field { margin-bottom: 18px; }
.auth-brand::before, .auth-brand::after { content: ""; position: absolute; .field label { display: block; font-weight: 500; margin-bottom: 8px;
border-radius: 50%; background: rgba(255, 255, 255, .07); }
.auth-brand::before { width: 220px; height: 220px; right: -70px; top: -60px;
animation: float 9s ease-in-out infinite; }
.auth-brand::after { width: 150px; height: 150px; left: -50px; bottom: -40px;
background: rgba(21, 101, 192, .35);
animation: float 11s ease-in-out infinite reverse; }
@keyframes float { 0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-18px, 22px); } }
.auth-brand > * { position: relative; z-index: 1; }
.auth-brand .big { display: inline-grid; place-items: center; width: 56px;
height: 56px; border-radius: 16px; background: #fff;
color: var(--navy); font-size: 19px; font-weight: 800;
animation: ring 2.8s ease-out infinite; }
@keyframes ring { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, .45); }
70%, 100% { box-shadow: 0 0 0 14px rgba(255, 255, 255, 0); } }
.auth-brand h1 { color: #fff; font-size: 28px; margin: 0; line-height: 1.2; }
.auth-brand ul { list-style: none; margin: 0; padding: 0;
display: grid; gap: 14px; }
.auth-brand li { display: flex; gap: 12px; align-items: center;
animation: slide .5s ease both; }
.auth-brand li:nth-child(1) { animation-delay: .15s; }
.auth-brand li:nth-child(2) { animation-delay: .3s; }
.auth-brand li:nth-child(3) { animation-delay: .45s; }
@keyframes slide { from { transform: translateX(-16px); }
to { transform: none; } }
.auth-brand li .dot { flex: none; display: inline-grid; place-items: center;
width: 30px; height: 30px; border-radius: 9px;
background: rgba(255, 255, 255, .12); color: #fff;
font-size: 14px; } font-size: 14px; }
.auth-brand li b { color: #fff; } .field .box { display: flex; align-items: center; height: 48px;
.auth-brand .foot-note { margin-top: auto; font-size: 12px; color: #8fa3bb; } border: 1px solid #d5dbd6; border-radius: 12px;
.auth-form { padding: 40px 38px; display: flex; flex-direction: column; background: var(--panel);
justify-content: center; }
.auth-form h2 { font-size: 24px; margin: 0 0 22px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-weight: 600; margin-bottom: 6px;
font-size: 13px; }
.field .box { display: flex; align-items: center; border: 1px solid #c9d2de;
border-radius: 8px; background: var(--panel);
transition: border-color .15s, box-shadow .15s; } transition: border-color .15s, box-shadow .15s; }
.field .box:focus-within { border-color: var(--accent); .field .box:focus-within { border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(21, 101, 192, .15); } box-shadow: 0 0 0 3px rgba(74, 107, 88, .18); }
.field .ic { padding: 0 4px 0 12px; color: var(--muted); font-size: 15px; } .field .ic { padding: 0 2px 0 14px; color: var(--muted); display: flex; }
.field input { flex: 1; border: 0; padding: 11px 10px; min-width: 0; .field input { flex: 1; border: 0; padding: 0 10px; height: 100%;
background: transparent; outline: none; } min-width: 0; background: transparent; outline: none; }
.field button.eye { background: transparent; border: 0; color: var(--accent); .field button.eye { background: transparent; border: 0; color: var(--accent);
font-weight: 600; font-size: 12px; padding: 0 12px; } font-weight: 600; font-size: 13px; padding: 0 14px; }
.field button.eye:hover { background: transparent; text-decoration: underline; } .field button.eye:hover { background: transparent; text-decoration: underline; }
#caps { color: var(--review); font-size: 12px; margin-top: 6px; } #caps { color: var(--review); font-size: 12px; margin-top: 6px; }
.auth-form button[type="submit"] { width: 100%; padding: 12px 14px; .auth-form button[type="submit"] { width: 100%; height: 52px;
font-size: 15px; border-radius: 8px; font-size: 16px; border-radius: 14px;
margin-top: 4px; position: relative; margin-top: 4px;
overflow: hidden;
transition: transform .15s, box-shadow .15s; } transition: transform .15s, box-shadow .15s; }
.auth-form button[type="submit"]:hover { transform: translateY(-1px); .auth-form button[type="submit"]:hover { transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(21, 101, 192, .35); } box-shadow: 0 8px 18px rgba(74, 107, 88, .3); }
/* A light sweep across the button on hover. */ .auth-form .rules { margin: 20px 0 0; padding: 12px 14px;
.auth-form button[type="submit"]::after { content: ""; position: absolute; background: var(--bg); border-radius: 12px;
top: 0; left: -60%; width: 40%; height: 100%; line-height: 1.5; }
background: linear-gradient(90deg, transparent,
rgba(255, 255, 255, .35), transparent);
transform: skewX(-20deg); }
.auth-form button[type="submit"]:hover::after { left: 120%;
transition: left .6s ease; }
.auth-form .rules { margin-top: 18px; padding-top: 14px;
border-top: 1px solid var(--line); }
@media (max-width: 760px) { @media (max-width: 760px) {
.auth { grid-template-columns: 1fr; margin: 8px 0; } .auth-brand { padding: 28px 24px; }
.auth-brand, .auth-form { padding: 24px 20px; }
.auth-brand ul { display: none; } .auth-brand ul { display: none; }
.auth-form { padding: 28px 22px; }
} }
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="auth"> <div class="auth">
<section class="auth-brand"> <section class="auth-brand">
<span class="big" aria-hidden="true">CV</span> <div class="logo"><span class="big" aria-hidden="true">CV</span>Ecare OTS</div>
<h1>Charge Validation</h1> <div>
<h1>Charge<br><span>Validation</span></h1>
<p class="lead">Agent run monitoring for the eCW Medcor Ready queue:
every claim checked against the Charge Condition Document.</p>
</div>
<ul> <ul>
<li><span class="dot" aria-hidden="true">✓</span><b>Charge conditions</b></li> <li><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3 8 4-16 3 8h4"></path></svg><b>Daily run status</b><small>Live progress of the agent and each run.</small></li>
<li><span class="dot" aria-hidden="true">▦</span><b>Pass, Fail, Review</b></li> <li><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"></rect><path d="M3 10h18M9 4v16"></path></svg><b>Claim results</b><small>Pass, Fail and Review with the reason.</small></li>
<li><span class="dot" aria-hidden="true">◷</span><b>Every run recorded</b></li> <li><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7z"></path></svg><b>{{ mode }} mode</b><small>{% if mode == "VERIFY" %}Nothing is saved to eCW claims.{% else %}Corrections are saved after a person confirms.{% endif %}</small></li>
</ul> </ul>
<p class="foot-note">Ecare India · {{ mode }} mode</p>
</section> </section>
<section class="auth-form"> <section class="auth-side">
<h2>Sign in</h2> <div class="auth-form">
<h2>Welcome back</h2>
<p class="intro">Sign in to the Charge Validation portal.</p>
{% if ecare_on %}<p class="note">Ecare staff: your Ecare office username and password.</p>{% endif %} {% if ecare_on %}<p class="note">Ecare staff: your Ecare office username and password.</p>{% endif %}
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %} {% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run {% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
@@ -114,19 +102,20 @@
<input type="hidden" name="next" value="{{ next_url }}"> <input type="hidden" name="next" value="{{ next_url }}">
<div class="field"> <div class="field">
<label for="username">Username</label> <label for="username">Username</label>
<div class="box"><span class="ic" aria-hidden="true">👤</span><input id="username" name="username" autocomplete="username" required autofocus></div> <div class="box"><span class="ic" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21c1-4 4-6 8-6s7 2 8 6"></path></svg></span><input id="username" name="username" autocomplete="username" required autofocus></div>
</div> </div>
<div class="field"> <div class="field">
<label for="pw">Password</label> <label for="pw">Password</label>
<div class="box"><span class="ic" aria-hidden="true">🔒</span><input id="pw" name="password" type="password" autocomplete="current-password" required><button type="button" class="eye" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button></div> <div class="box"><span class="ic" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="4" y="11" width="16" height="10" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span><input id="pw" name="password" type="password" autocomplete="current-password" required><button type="button" class="eye" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button></div>
<div id="caps" role="status" hidden>Caps Lock is on</div> <div id="caps" role="status" hidden>Caps Lock is on</div>
</div> </div>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form> </form>
<p class="note rules">Signed out after {{ idle_minutes }} minutes idle. <p class="note rules">Ecare network or VPN only. Signed out after
{{ lock_after }} wrong passwords lock the account for {{ idle_minutes }} minutes idle. {{ lock_after }} wrong passwords lock
{{ lock_minutes }} minutes. Forgot a Portal password? An Admin can the account for {{ lock_minutes }} minutes. Forgot a Portal password?
reset it.</p> An Admin can reset it.</p>
</div>
</section> </section>
</div> </div>
{% endblock %} {% endblock %}