#!/usr/bin/env python3
"""
Mesa Gen Tracker - external-facing read-mostly gen status page for Mesa.
Mirrors gen_manager hierarchy (site -> group -> gens) but:
  - Larger gen cards stacked vertically inside each group
  - Each card surfaces live run status / shutdown reason + engine hours / power
  - No edit / move / status-change / clear-active-note controls
  - "Add Note" auto-prefixes "Mesa: " and writes to the same gen_history.db
"""

import cgi
import cgitb
import datetime
import html
import json
import os
import sys
import time

sys.path.insert(0, '/opt/ngon/apps')
sys.path.insert(0, '/var/www/html/ngon')
from managers.auth_manager import AuthManager, generate_login_page_html, generate_access_denied_html, generate_login_modal_html
from links import generate_dropdown_html, generate_dropdown_css, generate_dropdown_js

# Self-fading "Repaired" chip window (kept in sync with status_desktop.js / mesa_gen_tracker.js)
REPAIRED_WINDOW_S = 12 * 3600


def repaired_chip_html(repaired_at, status):
    """Initial server-side render of the fading 'Repaired' chip. Shown only when the
    gen is back to active and was repaired within REPAIRED_WINDOW_S. The JS refresh loop
    keeps it live-fading after load. Returns '' when no chip should show."""
    if status != "active" or not repaired_at:
        return ''
    try:
        age = time.time() - float(repaired_at)
    except (TypeError, ValueError):
        return ''
    if age < 0 or age >= REPAIRED_WINDOW_S:
        return ''
    label = f"{max(1, round(age / 60))}m" if age < 3600 else f"{round(age / 3600)}h"
    return (
        f'<span class="gen-status-badge gen-repaired-chip" '
        f'style="background:#00aa44;color:white;" '
        f'title="Repaired {label} ago">✓ Repaired {label} ago</span>'
    )

def monday_of(d):
    """The Monday of d's week — the unit everything PM is expressed in."""
    return d - datetime.timedelta(days=d.weekday())


def pm_done_display(done):
    """Tag for a gen with no PM booked but a completion recorded THIS WEEK.

    Scoped to the current week rather than a rolling N days because that's the
    unit the whole schedule speaks in: a completion is news for the week it
    happened in, and the moment the next week starts it's just history. So it
    drops off at the week rollover (Sunday night), not N days after the fact.
    The record itself never expires — it stays in pm_last_completed and in the
    gen's note history."""
    if not isinstance(done, dict) or not done.get("date"):
        return None
    try:
        day = datetime.date.fromisoformat(str(done["date"]))
    except (TypeError, ValueError):
        return None
    if day != monday_of(datetime.date.today()):
        return None
    pretty = day.strftime("%m/%d/%y")
    note = (done.get("note") or "").strip()
    by = (done.get("by") or "").strip()
    title = (f"PM completed week of {pretty}"
             + (f" \u2014 {note}" if note else "") + (f" \u00b7 by {by}" if by else ""))
    return {"kind": "done", "text": f"\u2713 done wk {day.strftime('%-m/%-d')}",
            "title": title, "completed": True}


def pm_display(pm, done=None):
    """Shared PM formatting for a gen's pm_schedule / pm_last_completed config
    blocks. A booked date wins; with nothing booked it falls back to the last
    completed PM ("when was this gen last serviced"). Returns {color, text, title}
    or None. Kept in sync with pmDisplay() in mesa_gen_tracker.js (which re-renders
    it on every live poll) and with the compact chip on the status page."""
    if not isinstance(pm, dict):
        pm = {}
    date_str = pm.get("date")
    if not date_str:
        return pm_done_display(done)
    try:
        day = datetime.date.fromisoformat(str(date_str))
    except (TypeError, ValueError):
        return pm_done_display(done)
    # The stored date is the MONDAY of the booked week. This is Mesa's CALENDAR
    # commitment, not our interval maths — so it stays visually quiet (one muted
    # blue tag) and never competes with the countdown for attention.
    days = (day - datetime.date.today()).days
    pretty = day.strftime("%m/%d/%y")
    short = day.strftime("%-m/%-d")
    if days > 7:
        text, long = f"booked wk {short}", f"PM booked for the week of {pretty}"
    elif days > 0:
        text, long = f"booked wk {short}", f"PM booked for the week of {pretty} (in {days}d)"
    elif days >= -6:
        text, long = "booked THIS wk", f"PM booked for THIS week (of {pretty})"
    else:
        text, long = f"booked wk {short} \u2014 past", f"PM was booked for the week of {pretty}, which has passed"
    note = (pm.get("note") or "").strip()
    by = (pm.get("by") or "").strip()
    title = long + (f" \u2014 {note}" if note else "") + (f" \u00b7 set by {by}" if by else "")
    return {"kind": "booked", "text": text, "title": title}


def pm_line_html(pm, done=None):
    """Initial server-side render of the calendar tag. The countdown tag is added
    by the live poll (it needs engine hours, which aren't in master_config)."""
    info = pm_display(pm, done)
    if not info:
        return ''
    return (f'<span class="pm-pill {info["kind"]}" '
            f'title="{html.escape(info["title"], quote=True)}">'
            f'{html.escape(info["text"])}</span>')


GEN_HISTORY_DB = "/opt/ngon/data/gen_history.db"


def load_note_authors():
    """{(gen_id, note_text): (user, timestamp)} for every note ever written.

    The active note in master_config is just a string — no author, no date. Every
    note that got there via Add Note / status change also left a row in
    gen_history.db, so match on the exact text to recover who wrote it and when.
    A note that doesn't match (hand-edited config, pre-history note) simply
    renders without a byline. Read-only and best-effort: the page must still
    render if the DB is busy or missing.
    """
    out = {}
    try:
        import sqlite3
        con = sqlite3.connect(f"file:{GEN_HISTORY_DB}?mode=ro", uri=True, timeout=5)
        try:
            rows = con.execute("""
                SELECT gen_id, note_text, user, timestamp FROM (
                    SELECT gen_id, note_text, user, timestamp,
                           ROW_NUMBER() OVER (PARTITION BY gen_id, note_text
                                              ORDER BY id DESC) rn
                    FROM gen_notes)
                WHERE rn = 1
            """).fetchall()
        finally:
            con.close()
        for gen_id, text, user, ts in rows:
            if gen_id and text:
                out[(gen_id, text.strip())] = (user or 'system', ts or '')
    except Exception:
        pass
    return out


def note_byline(gen_id, notes, authors):
    """"KB · 8/14" for an active note we can attribute, else ''."""
    text = (notes or '').strip()
    if not text:
        return ''
    meta = authors.get((str(gen_id), text))
    if not meta:
        return ''
    user, ts = meta
    try:
        when = datetime.datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S").strftime("%-m/%-d/%y")
    except (ValueError, TypeError):
        when = ''
    return f"{user} \u00b7 {when}" if when else str(user)


def note_block_html(gen_id, notes, authors):
    """The maintenance note as its own callout: a byline, then the text."""
    text = (notes or '').strip()
    if not text:
        return ''
    by = note_byline(gen_id, notes, authors)
    byline = f'<div class="gen-note-meta">&#128221; {html.escape(by)}</div>' if by else ''
    return byline + f'<div class="gen-note-body">{html.escape(text)}</div>'


MASTER_CONFIG_FILE = "/opt/ngon/config/master_config.json"

def _load_gen_statuses():
    """Maintenance-tag palette from master_config misc.display — the same source
    constants_cgi.py serves to the browser and gen_manager.py reads. This page
    used to carry its own copy of the literal; it happened to match, but a
    fourth copy is a fourth thing to forget. Literal below is the fallback."""
    fallback = {
        'active':      {'label': 'Active',      'color': '#00ff00'},
        'active-swap': {'label': 'Active-Swap', 'color': '#ff6600'},
        'swap':        {'label': 'Swap',        'color': '#ff6600'},
        'inspect':     {'label': 'Inspect',     'color': '#ffaa00'},
        'parts':       {'label': 'Parts',       'color': '#ff4444'},
        'gas':         {'label': 'Gas',         'color': '#4a9eff'},
        'oos':         {'label': 'OOS',         'color': '#888888'},
    }
    try:
        with open(MASTER_CONFIG_FILE) as f:
            got = ((json.load(f).get('misc') or {}).get('display') or {}).get('genStatuses')
        return got or fallback
    except Exception:
        return fallback


GEN_STATUSES = _load_gen_statuses()

form = cgi.FieldStorage()

auth = AuthManager('mesa_gen_tracker')
auth_required, should_exit, headers = auth.require_auth(form)

if should_exit:
    print("Content-Type: application/json")
    if headers:
        print(headers)
    print("")
    if auth_required:
        print('{"success": false, "error": "Authentication required"}')
    else:
        print('{"success": true}')
    sys.exit(0)

# Logout handling. Uses AuthManager rather than clearing the cookie by hand: the
# session cookie is domain-scoped (Domain=.ngon.us) for cross-subdomain SSO, and a
# browser will NOT drop it for a Set-Cookie that omits Domain - that only clears a
# host-only cookie of the same name. This handler did omit it, so it had been
# clearing a pre-migration leftover and leaving the real session intact: the page
# said "Logged Out" and the user was still logged in.
if "logout" in form and form.getvalue("logout"):
    print("Content-Type: text/html")
    print(AuthManager.logout_cookie_header())
    print("")
    print("""
    <html><body style="background:#1a1a1a;color:#fff;font-family:Arial,sans-serif;text-align:center;padding:50px;">
        <h1 style="color:#00ff00;">Logged Out</h1>
        <a href="mesa_gen_tracker.py" style="color:#4a9eff;">Return to Mesa Gen Tracker</a>
    </body></html>
    """)
    sys.exit(0)

cgitb.enable()

_dir = os.path.dirname(os.path.abspath(__file__))
_js_v = int(os.path.getmtime(os.path.join(_dir, 'mesa_gen_tracker.js')))

print("Content-Type: text/html; charset=utf-8")
print("Cache-Control: no-cache, no-store, must-revalidate")
print("Pragma: no-cache")
print("Expires: 0")
print()

# If the user isn't authenticated yet, serve the standalone login page and stop.
if auth_required:
    if auth.is_authenticated():
        print(generate_access_denied_html("Mesa Gen Tracker"))
    else:
        print(generate_login_page_html("Mesa Gen Tracker"))
    sys.exit(0)


def load_config():
    if os.path.exists(MASTER_CONFIG_FILE):
        with open(MASTER_CONFIG_FILE, "r") as f:
            return json.load(f)
    return {"sites": {}}


config = load_config()


def sheet_stamp_html():
    """Which schedule the anchors came from, and how long ago it landed. Makes a
    forgotten weekly upload visible instead of leaving stale numbers looking live."""
    last = ((config.get("misc") or {}).get("pm_service") or {}).get("last_upload") or {}
    date = last.get("sheet_date")
    if not date:
        return ('<span style="color:#ffce6b;">No Mesa schedule uploaded yet &mdash; '
                'service anchors are unseeded.</span>')
    age = ''
    try:
        d = datetime.date.fromisoformat(str(date))
        days = (datetime.date.today() - d).days
        age = 'today' if days == 0 else ('yesterday' if days == 1 else f'{days} days ago')
    except (TypeError, ValueError):
        pass
    who = last.get("by") or ''
    colour = '#8b98a6'
    try:
        if (datetime.date.today() - datetime.date.fromisoformat(str(date))).days > 10:
            colour = '#ffce6b'      # a fortnight-old sheet is no longer "this week's"
    except (TypeError, ValueError):
        pass
    return (f'<span style="color:{colour};">Service schedule: '
            f'{html.escape(str(date))}'
            + (f' ({age})' if age else '')
            + (f' &middot; uploaded by {html.escape(str(who))}' if who else '')
            + f' &middot; {last.get("applied", 0)} anchors set</span>')


def gen_region(site_name, site_data, group_name):
    """
    Derive 'TX' / 'ND' for a generator card.
    - For the special 'Out of Service' site, the group name has the suffix.
    - For everything else, classify by latitude (TX ~ 30.x, ND ~ 48.x).
    Returns '' for unknown / Spares / unclassified.
    """
    if site_name == "Out of Service":
        if " - TX" in group_name:
            return "TX"
        if " - ND" in group_name:
            return "ND"
        return ""
    coords = ((site_data or {}).get("location") or {}).get("coordinates") or []
    if coords and isinstance(coords[0], list) and len(coords[0]) >= 2:
        try:
            lat = float(coords[0][1])
        except (TypeError, ValueError):
            return ""
        if lat > 40:
            return "ND"
        if lat > 20:
            return "TX"
    return ""

print("""
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Mesa Gen Tracker</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
    <link rel="stylesheet" href="shutdown_history.css?v=2">
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: #1a1a1a;
            color: #e0e0e0;
            margin: 0;
            padding: 0;
            line-height: 1.4;
        }

        .header {
            background-color: rgba(255,255,255,0.05) !important;
            border-radius: 8px !important;
            padding: 30px !important;
            margin: 20px !important;
            border: 1px solid rgba(255,255,255,0.1) !important;
            display: flex !important;
            justify-content: space-between !important;
            align-items: flex-start !important;
        }
        .header h1 { margin: 0; color: #fff; font-size: 22px; }
        .header .subtitle { color: #888; font-size: 12px; margin-top: 4px; }
        .header-controls { display: flex; gap: 10px; align-items: center; }

        .btn {
            padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer;
            font-weight: bold; font-size: 12px;
        }
        .btn-primary { background: #00ff00; color: black; }
        .btn-primary:hover { background: #00cc00; }
        .btn-danger { background: #dc3545; color: white; }
        .btn-danger:hover { background: #b91c1c; }
        .btn-help { background: #4a9eff; color: white; }
        .btn-help:hover { background: #2a7edf; }
        .btn-plan { background: #7a4fb5; color: white; }
        .btn-plan:hover { background: #63409a; }
        .btn-upload { background: #00aa44; color: white; }
        .btn-upload:hover { background: #008a37; }

        /* Upload / reconcile modal */
        #uploadModal .modal-content { width: 860px; }
        .up-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
        .up-row input[type="date"], .up-row input[type="file"] {
            padding: 6px 8px; background: #2a2a2a; border: 1px solid #3a3a3a;
            color: white; border-radius: 4px; font-size: 12px; font-family: inherit;
        }
        .up-hint { color: #8b98a6; font-size: 11px; margin-bottom: 10px; line-height: 1.5; }
        .up-refuse {
            background: rgba(255,68,68,.10); border-left: 3px solid #ff4444;
            border-radius: 0 4px 4px 0; padding: 10px; color: #ff8a8a;
            font-size: 12px; line-height: 1.5;
        }
        .up-summary { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0; }
        .up-sec-title {
            color: #8b98a6; font-size: 10px; font-weight: 700; letter-spacing: .07em;
            text-transform: uppercase; margin: 14px 0 6px;
        }
        .up-item {
            display: flex; gap: 8px; align-items: flex-start;
            background: #12161b; border-radius: 4px; padding: 7px 9px; margin-bottom: 4px;
            font-size: 12px; border-left: 3px solid #2f3742;
        }
        .up-item.hold { border-left-color: #ffaa00; }
        .up-item.service { border-left-color: #00aa44; }
        .up-item input[type="checkbox"] { margin-top: 2px; flex: 0 0 auto; }
        .up-item .up-body { flex: 1; min-width: 0; }
        .up-item .up-unit { color: #fff; font-weight: bold; }
        .up-item .up-detail { color: #b7c2cd; margin-top: 2px; }
        .up-item .up-warn { color: #ffce6b; margin-top: 2px; }
        .up-item .up-nums { color: #7fbcff; font-family: monospace; font-size: 11px; }
        .up-quiet { color: #6f7c89; font-size: 11px; margin-top: 8px; line-height: 1.5; }

        /* Notifications HowTo modal */
        #helpModal .help-intro { color: #ccc; font-size: 13px; line-height: 1.5; margin-bottom: 16px; }
        #helpModal .help-step { display: flex; gap: 12px; margin-bottom: 16px; align-items: flex-start; }
        #helpModal .help-step .num {
            flex: 0 0 26px; width: 26px; height: 26px; border-radius: 50%;
            background: #4a9eff; color: #fff; font-weight: bold; font-size: 13px;
            display: flex; align-items: center; justify-content: center;
        }
        #helpModal .help-step .body { flex: 1; color: #e0e0e0; font-size: 13px; line-height: 1.45; }
        #helpModal .help-step .body b { color: #fff; }
        #helpModal .store-links { display: flex; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
        #helpModal .store-link {
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #fff;
            padding: 8px 12px; border-radius: 6px; text-decoration: none; font-size: 12px;
            display: inline-flex; align-items: center; gap: 6px;
        }
        #helpModal .store-link:hover { border-color: #4a9eff; }
        #helpModal .topic-row {
            display: flex; align-items: center; gap: 8px; margin-top: 8px;
            background: #0a0a0a; border: 1px solid #2a2a2a; border-radius: 6px; padding: 8px 10px;
        }
        #helpModal .topic-name { font-family: monospace; font-size: 14px; color: #00ff00; flex: 1; }
        #helpModal .topic-region { color: #888; font-size: 11px; }
        #helpModal .copy-btn {
            background: #4a9eff; color: #fff; border: none; border-radius: 4px;
            padding: 5px 10px; font-size: 11px; font-weight: bold; cursor: pointer;
        }
        #helpModal .copy-btn:hover { background: #2a7edf; }
        #helpModal .help-tip {
            background: #0a0a0a; border-left: 3px solid #ffaa00; border-radius: 4px;
            padding: 10px; font-size: 12px; color: #ccc; margin-top: 12px; line-height: 1.45;
        }
        #helpModal .help-tip b { color: #ffce6b; }
        #helpModal .help-note {
            background: #0a0a0a; border-left: 3px solid #4a9eff; border-radius: 4px;
            padding: 10px; font-size: 12px; color: #ccc; margin-top: 12px; line-height: 1.45;
        }
        #helpModal .help-note b { color: #7fbcff; }

        .main-content {
            padding: 0 20px 20px 20px;
            display: grid;
            grid-template-columns: minmax(0, 1fr) 340px;
            gap: 15px;
            align-items: start;
        }
        .main-content > .left-col { min-width: 0; }
        @media (max-width: 900px) {
            .main-content { grid-template-columns: 1fr; }
            .chat-col { position: static !important; max-height: 400px !important; }
        }

        /* Chat column */
        .chat-col {
            background-color: rgba(255,255,255,0.05);
            border: 1px solid rgba(255,255,255,0.1);
            border-radius: 8px;
            padding: 12px;
            position: sticky;
            top: 20px;
            display: flex;
            flex-direction: column;
            height: calc(100vh - 180px);
            min-height: 400px;
        }
        .chat-header {
            color: #4a9eff; font-weight: bold; font-size: 13px;
            display: flex; justify-content: space-between; align-items: center;
            padding-bottom: 6px; border-bottom: 1px solid #2a2a2a;
            margin-bottom: 8px;
        }
        .chat-header .chat-as { color: #888; font-size: 10px; font-weight: normal; }
        #chatRegionLabel { color: #00ff00; margin-left: 5px; }
        .chat-messages {
            flex: 1; overflow-y: auto; padding-right: 4px;
            display: flex; flex-direction: column; gap: 8px;
            scrollbar-width: thin;
            scrollbar-color: #666 transparent;
        }
        .chat-messages::-webkit-scrollbar { width: 6px; }
        .chat-messages::-webkit-scrollbar-track { background: transparent; }
        .chat-messages::-webkit-scrollbar-thumb { background: #666; border-radius: 3px; }
        .chat-messages::-webkit-scrollbar-thumb:hover { background: #888; }
        .chat-empty { color: #666; font-style: italic; font-size: 12px; text-align: center; margin-top: 20px; }
        .chat-msg {
            background: #1a1a1a; border-radius: 6px;
            padding: 6px 8px; font-size: 12px;
        }
        .chat-msg .meta {
            display: flex; justify-content: space-between;
            color: #888; font-size: 10px; margin-bottom: 2px;
        }
        .chat-msg .meta .who { color: #4a9eff; font-weight: bold; }
        .chat-msg.self .meta .who { color: #00ff00; }
        .chat-msg .body { color: #e0e0e0; white-space: pre-wrap; word-break: break-word; }
        /* A gen id in a chat line opens that gen's modal. Underlined as well as
           blue so it reads as clickable mid-sentence, not just as emphasis. */
        .chat-msg .body a.gen-ref {
            color: #4a9eff; font-weight: bold; text-decoration: underline;
            cursor: pointer;
        }
        .chat-msg .body a.gen-ref:hover { color: #7fc0ff; }
        .chat-input-row {
            display: flex; gap: 6px; margin-top: 8px;
            padding-top: 8px; border-top: 1px solid #2a2a2a;
        }
        .chat-input-row textarea {
            flex: 1; padding: 6px 8px; background: #2a2a2a;
            border: 1px solid #3a3a3a; color: white;
            border-radius: 4px; font-size: 12px;
            resize: none; min-height: 36px; max-height: 120px;
            font-family: inherit;
        }
        .chat-input-row textarea:focus { outline: none; border-color: #4a9eff; }
        .chat-input-row button {
            padding: 0 12px; background: #4a9eff; color: white;
            border: none; border-radius: 4px; cursor: pointer;
            font-weight: bold; font-size: 12px;
        }
        .chat-input-row button:hover { background: #2a7edf; }
        .chat-input-row button:disabled { background: #555; cursor: not-allowed; }

        .controls {
            background-color: rgba(255,255,255,0.05);
            border: 1px solid rgba(255,255,255,0.1);
            padding: 12px 15px;
            border-radius: 8px;
            margin-bottom: 15px;
            display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
        }
        .controls input, .controls select {
            padding: 6px 10px; background: #2a2a2a; border: 1px solid #3a3a3a;
            color: white; border-radius: 4px; font-size: 12px;
        }
        .controls input { width: 220px; }
        .controls .live-indicator { color: #888; font-size: 11px; margin-left: auto; }
        /* Match count — only rendered while a filter is narrowing the view, so a
           filtered page never leaves you wondering whether it's empty or broken. */
        .filter-count { color: #7fbcff; font-size: 11px; font-weight: bold; }
        .filter-count:empty { display: none; }
        .controls .live-dot {
            display: inline-block; width: 8px; height: 8px; border-radius: 50%;
            background: #00ff00; margin-right: 5px; vertical-align: middle;
        }

        #sitesContainer {
            display: flex; flex-wrap: wrap; gap: 15px; align-items: flex-start;
        }

        /* Zero-height, full-width flex break emitted BEFORE bottom-tier sites (John,
           OOS/Spares). In a flex-wrap layout the line break is decided by flex-basis, so
           a 100%-basis element forces whatever follows onto a fresh row — letting John
           keep its normal card width instead of stretching. height:0 means a stray break
           (when the following site is filtered out, e.g. John hidden in the ND view)
           leaves no visible gap. */
        .site-row-break {
            flex: 0 0 100%;
            height: 0;
            margin: 0;
            padding: 0;
            border: none;
        }

        .site-container {
            background-color: rgba(255,255,255,0.05);
            border: 1px solid rgba(255,255,255,0.1);
            border-radius: 10px;
            padding: 12px;
            flex: 1 1 320px;
            max-width: 520px;
            min-width: 300px;
        }
        .site-header {
            display: flex; justify-content: space-between; align-items: center;
            margin-bottom: 10px; padding-bottom: 8px;
            border-bottom: 1px solid #2a2a2a;
        }
        .site-title { color: #00ff00; font-size: 16px; font-weight: bold; }
        .site-id { color: #888; font-size: 10px; }

        .groups-wrapper { display: flex; flex-direction: column; gap: 8px; }
        .group-container {
            background: #222; border: 1px solid #333;
            border-radius: 6px; padding: 8px 10px;
        }
        .group-title {
            color: #4a9eff; font-weight: bold; font-size: 12px;
            margin-bottom: 6px;
        }

        /* Vertically stacked, taller gen cards */
        .generators-stack {
            display: flex; flex-direction: column; gap: 6px;
        }
        .gen-card {
            background: #1a1a1a; border: 1px solid #333;
            border-radius: 4px; padding: 8px 10px;
            cursor: pointer; transition: all 0.15s ease;
        }
        .gen-card:hover { background: #2a2a2a; border-color: #4a9eff; }
        .gen-card.hidden { display: none; }

        .gen-card-header {
            display: flex; align-items: center; gap: 6px;
            margin-bottom: 4px;
        }
        .gen-id {
            font-weight: bold; color: #fff; font-size: 13px;
        }
        .gen-status-badge {
            display: inline-block; padding: 1px 5px; border-radius: 2px;
            font-size: 9px; font-weight: bold;
        }

        /* Up / down / stuck dot — mirrors status page (getGeneratorStatus) */
        .gen-status-dot {
            width: 10px; height: 10px; border-radius: 50%;
            display: inline-block; flex-shrink: 0; background: #888;
        }
        .gen-status-dot.online  { background: #00ff00; }
        .gen-status-dot.offline { background: #ff4444; }
        .gen-status-dot.stuck   { background: #ff6600; }

        /* Badges pushed to the right edge of the card header */
        .gen-badges {
            margin-left: auto; display: flex; gap: 4px;
            align-items: center; flex-wrap: wrap; justify-content: flex-end;
        }
        /* The loaner badge is wrapped in a permanent <span data-field="loaner"> so the
           live poll has something to write innerHTML into even when the gen isn't a
           loaner. That wrapper is a JS anchor, not layout — but as a flex item it forms
           a line box, and the inline-block badge inside sits on that line's baseline
           with descender space below it. align-items:center then centres the WRAPPER,
           leaving the badge ~2px lower than the Active tag (which is a direct flex item
           with no wrapper). display:contents drops the wrapper's box so the badge
           becomes a direct flex item like Active is — and stops an EMPTY wrapper from
           still eating one of the 4px flex gaps. Scoped to the loaner wrapper on
           purpose: data-field="statusbadge" sits on the badge itself, and
           display:contents there would throw the badge's own background/padding away. */
        .gen-badges > [data-field="loaner"] { display: contents; }

        /* Live-telemetry wifi + our gen-warnings triangle (read-only) */
        .gen-live-wifi { font-size: 10px; color: #00ff00; flex-shrink: 0; }
        .gen-warn-icon { font-size: 11px; flex-shrink: 0; }
        .gen-warn-icon.warn     { color: #ffb300; }
        .gen-warn-icon.critical { color: #ff4444; animation: gen-warn-pulse 1.6s ease-in-out infinite; }
        @keyframes gen-warn-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }

        .gen-run-row {
            display: flex; align-items: center; gap: 8px;
            font-size: 11px; margin-bottom: 3px;
        }
        .gen-run-label { color: #888; }
        .gen-run-status {
            font-weight: bold;
            color: #888;
            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
        }
        .gen-run-status.running { color: #00ff00; }
        .gen-run-status.fault   { color: #ff6b6b; }
        .gen-run-status.idle    { color: #888; }
        .gen-run-status.manual  { color: #aaa; }
        /* Runtime / downtime counter (DD.HH:MM), right-aligned like status page */
        .gen-run-dur { color: #fff; font-size: 11px; margin-left: auto; flex-shrink: 0; }

        .gen-stats-row {
            display: flex; gap: 12px; font-size: 10px;
            color: #888; margin-bottom: 3px; align-items: center;
        }
        .gen-stats-row span strong { color: #ccc; font-weight: 600; }
        .gen-kw-cell { display: inline-flex; align-items: center; gap: 5px; }

        /* Full-width per-gen kW bar at the card bottom — mirrors the status page
           genKwBar(): a blue→green→orange gradient on a FIXED 0..trip scale so fills
           line up across gens (balanced gens end at the same spot; the odd gen sticks
           out). .gkb-cover masks the unfilled right portion so the bright fill ends at
           the gen's kW, in its zone color. Gradient + fill % set inline by the JS. */
        .gen-kw-bar {
            position: relative; width: 100%; height: 5px;
            margin-top: 6px; border-radius: 2px; overflow: hidden;
        }
        .gen-kw-bar .gkb-cover {
            position: absolute; top: 0; right: 0; bottom: 0;
            /* left set inline to the fill %; everything to its right is muted empty track */
            background: rgba(9, 11, 14, 0.82);
        }
        /* Not-running gen: inert neutral track — keeps card height consistent, no kW implied. */
        .gen-kw-bar-empty { background: #23262b; }

        /* Down reason / warning detail line (gen "error codes") */
        .gen-warnline { font-size: 10px; margin-top: 3px; word-break: break-word; }
        .gen-warnline:empty { display: none; }

        /* --- Maintenance section -------------------------------------------------
           Everything above this line is what the GEN is reporting right now (run
           state, kW, NG, alarms). Everything inside it is what WE know about its
           servicing. They were interleaved as same-weight coloured lines, so on a
           down gen the red shutdown reason and a red PM-overdue line blended into
           one wall of red and neither read. The section rule + label draws the
           boundary; the states inside are TAGS, not more coloured text, so they
           carry hue without competing with a live alarm. */
        .gen-maint { margin-top: 8px; }
        .gen-maint.empty { display: none; }
        .gen-maint-label {
            display: flex; align-items: center; gap: 6px;
            color: #66727f; font-size: 9px; font-weight: 700;
            letter-spacing: .08em; text-transform: uppercase;
            margin-bottom: 5px;
        }
        .gen-maint-label::after {
            content: ''; flex: 1; height: 1px; background: #242b33;
        }
        .gen-pm-line { display: flex; flex-wrap: wrap; gap: 5px; }
        .gen-pm-line:empty { display: none; }

        /* Outlined tags: hue at low saturation, so a red PM tag reads as a tag and
           not as a second shutdown alarm. */
        .pm-pill {
            display: inline-flex; align-items: center;
            padding: 2px 7px; border-radius: 10px;
            font-size: 10px; font-weight: 700; white-space: nowrap;
            border: 1px solid;
        }
        .pm-pill.overdue  { color:#ff8a8a; border-color:rgba(255,68,68,.55);   background:rgba(255,68,68,.13); }
        .pm-pill.due      { color:#ffb27a; border-color:rgba(255,102,0,.55);   background:rgba(255,102,0,.13); }
        .pm-pill.upcoming { color:#ffd48a; border-color:rgba(255,170,0,.45);   background:rgba(255,170,0,.10); }
        .pm-pill.ok       { color:#93a3b3; border-color:rgba(147,163,179,.32); background:rgba(147,163,179,.07); }
        .pm-pill.nodata   { color:#8a8a8a; border-color:rgba(138,138,138,.32); background:rgba(138,138,138,.07); }
        /* Booked: colour answers WHEN THEY'RE COMING, not how overdue it is. */
        .pm-pill.booked   { color:#7fbcff; border-color:rgba(74,158,255,.45);  background:rgba(74,158,255,.10); }
        .pm-pill.booked-now   { color:#c9a6ff; border-color:rgba(170,68,255,.50);  background:rgba(170,68,255,.13); }
        .pm-pill.booked-next  { color:#7fbcff; border-color:rgba(74,158,255,.45);  background:rgba(74,158,255,.10); }
        .pm-pill.booked-later { color:#93a3b3; border-color:rgba(147,163,179,.32); background:rgba(147,163,179,.07); }
        .pm-pill.done     { color:#7fd6a0; border-color:rgba(0,170,68,.45);    background:rgba(0,170,68,.10); }

        /* Modal: schedule-a-PM row */
        .pm-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
        .pm-row input {
            padding: 6px 8px; background: #2a2a2a; border: 1px solid #3a3a3a;
            color: white; border-radius: 4px; font-size: 13px; font-family: inherit;
        }
        .pm-row input:focus { outline: none; border-color: #4a9eff; }
        .pm-row input[type="date"] { flex: 0 0 150px; }
        .pm-row input[type="text"] { flex: 1; min-width: 140px; }
        .pm-row .modal-btn { padding: 6px 12px; font-size: 12px; }
        .pm-current { font-size: 11px; color: #888; margin-top: 6px; }

        /* Maintenance note — deliberately a CALLOUT, not another 10px line. It's the
           one field on the card a human wrote, and at the old weight it read as more
           telemetry: same size, same grey, a hairline rule above it. Own surface +
           blue accent rail + brighter, larger text separates "what someone said about
           this gen" from "what the gen is reporting". */
        .gen-note {
            margin-top: 6px;
            padding: 7px 9px;
            background: #0e131a;
            border-left: 3px solid #4a9eff;
            border-radius: 0 4px 4px 0;
            color: #e8eef5;
            font-size: 12px;
            line-height: 1.45;
            white-space: normal;
            word-break: break-word;
        }
        .gen-note:empty, .gen-note.empty { display: none; }
        .gen-note-meta {
            color: #6f9bd1; font-size: 10px; font-weight: bold;
            margin-bottom: 3px;
        }
        .gen-note-body { white-space: pre-wrap; }

        /* Modal */
        .modal {
            display: none; position: fixed; z-index: 1000;
            left: 0; top: 0; width: 100%; height: 100%;
            background-color: rgba(0,0,0,0.8);
        }
        .modal-content {
            background-color: #1a1a1a;
            margin: 5% auto; padding: 20px;
            border: 2px solid #4a9eff;
            border-radius: 8px;
            width: 500px; max-width: 90%;
            max-height: 85vh; overflow-y: auto;
            box-shadow: 0 4px 20px rgba(74,158,255,0.3);
            scrollbar-width: thin;
            scrollbar-color: #666 transparent;
        }
        .modal-content::-webkit-scrollbar,
        .history-panel::-webkit-scrollbar { width: 6px; }
        .modal-content::-webkit-scrollbar-track,
        .history-panel::-webkit-scrollbar-track { background: transparent; }
        .modal-content::-webkit-scrollbar-thumb,
        .history-panel::-webkit-scrollbar-thumb { background: #666; border-radius: 3px; }
        .modal-content::-webkit-scrollbar-thumb:hover,
        .history-panel::-webkit-scrollbar-thumb:hover { background: #888; }
        .history-panel { scrollbar-width: thin; scrollbar-color: #666 transparent; }
        .modal h3 { color: #4a9eff; margin-top: 0; }
        .modal .ro-row {
            display: flex; justify-content: space-between;
            padding: 5px 0; border-bottom: 1px solid #2a2a2a;
            font-size: 13px;
        }
        .modal .ro-row .ro-label { color: #888; }
        .modal .ro-row .ro-value { color: #fff; text-align: right; }
        .modal .ro-block {
            background: #0a0a0a; border-radius: 4px;
            padding: 10px; margin-top: 10px;
            color: #ccc; font-size: 12px;
            white-space: pre-wrap; word-break: break-word;
        }
        .modal .ro-block.empty { color: #666; font-style: italic; }
        /* Active note in the modal gets the same callout as the card, one size up:
           this is where the note is actually READ. */
        .modal .ro-block.note-block {
            background: #0e131a; border-left: 3px solid #4a9eff;
            border-radius: 0 4px 4px 0;
            color: #e8eef5; font-size: 13px; line-height: 1.5;
        }
        .modal .ro-block.note-block.empty {
            background: #0a0a0a; border-left: 3px solid #2a2a2a; color: #666;
        }
        .note-meta { color: #6f9bd1; font-size: 11px; font-weight: bold; margin-bottom: 4px; }

        .modal label {
            display: block; color: #888; font-size: 12px;
            margin: 12px 0 4px 0;
        }
        .modal textarea {
            width: 100%; padding: 8px; background: #2a2a2a;
            border: 1px solid #3a3a3a; color: white;
            border-radius: 4px; font-size: 13px;
            min-height: 60px; box-sizing: border-box; resize: vertical;
            font-family: inherit;
        }
        .modal textarea:focus { outline: none; border-color: #4a9eff; }
        .modal .note-hint {
            color: #4a9eff; font-size: 10px; font-style: italic;
            margin-top: 2px;
        }
        .modal-buttons {
            display: flex; gap: 8px; justify-content: flex-end;
            margin-top: 15px;
        }
        .modal-btn {
            padding: 8px 14px; border: none; border-radius: 4px;
            cursor: pointer; font-size: 13px; font-weight: bold;
        }
        .modal-btn-save { background: #4a9eff; color: white; }
        .modal-btn-save:hover { background: #2a7edf; }
        .modal-btn-cancel { background: #666; color: white; }
        .modal-btn-cancel:hover { background: #555; }
        .modal-btn-done { background: #00aa44; color: white; }
        .modal-btn-done:hover { background: #008a37; }

        /* History panel */
        .history-panel {
            margin-top: 15px; padding: 10px;
            background: #0a0a0a; border-radius: 6px;
            max-height: 220px; overflow-y: auto;
        }
        .history-title {
            color: #4a9eff; font-weight: bold;
            margin-bottom: 8px; font-size: 12px;
        }
        /* .history-* now live in shutdown_history.css (loaded below), shared with
           the gen modal on the status and mobile pages. The note history on this
           page uses the same classes, which is why they are not scoped to
           shutdowns. */
    </style>
</head>
<body>

<div id="mainContent">
    <div class="header">
        <div>
            <h1><i class="fas fa-bolt"></i> Mesa Gen Tracker</h1>
            <div class="subtitle">Live status, run state, and notes for every generator</div>
            <div class="subtitle" id="sheetStamp">""" + sheet_stamp_html() + """</div>
        </div>
        <div class="header-controls">
            <button class="btn btn-plan" onclick="window.location.href='service_plan.py'">
                <i class="fas fa-route"></i> Service Plan
            </button>
            <button class="btn btn-upload" onclick="openUploadModal()">
                <i class="fas fa-file-arrow-up"></i> Upload Schedule
            </button>
            <button class="btn btn-help" onclick="openHelpModal()">
                <i class="fas fa-bell"></i> Notifications HowTo
            </button>
            <button class="btn btn-danger" onclick="window.location.href='mesa_gen_tracker.py?logout=1'">
                <i class="fas fa-sign-out-alt"></i> Logout
            </button>
        </div>
    </div>

    <div class="main-content">
      <div class="left-col">
        <div class="controls">
            <input type="text" id="searchInput" placeholder="Search gen ID or note text...">
            <select id="regionFilter">
                <option value="all">All Regions</option>
                <option value="TX">TX</option>
                <option value="ND">ND</option>
            </select>
            <select id="statusFilter">
                <option value="all">All Statuses</option>
""")

for status_key, status_data in GEN_STATUSES.items():
    print(f'                <option value="{status_key}">{status_data["label"]}</option>')

print("""
            </select>
            <select id="maintFilter" title="Filter by service state">
                <option value="all">All Maintenance</option>
                <option value="attention">&#9888; Needs service (overdue + due)</option>
                <optgroup label="Service interval">
                    <option value="overdue">PM overdue</option>
                    <option value="due">PM due (&le; 7 days)</option>
                    <option value="upcoming">PM upcoming (&le; 21 days)</option>
                    <option value="week1">Projected due next week</option>
                    <option value="week2">Projected due the week after</option>
                    <option value="ok">Nothing due</option>
                    <option value="nodata">No engine-hour reading</option>
                </optgroup>
                <optgroup label="Scheduling">
                    <option value="booked">Booked with Mesa</option>
                    <option value="unbooked">Needs service, not booked</option>
                    <option value="done">Completed this week</option>
                </optgroup>
                <optgroup label="Notes">
                    <option value="noted">Has a note</option>
                </optgroup>
            </select>
            <span id="filterCount" class="filter-count"></span>
            <div class="live-indicator">
                <span class="live-dot"></span>
                <span id="liveStatus">Live · updates every 30s</span>
            </div>
        </div>

        <div id="sitesContainer">
""")

# Render sites -> groups -> gens (skeleton; JS fills run status & stats)
SPECIAL_SITES = {"Out of Service", "Out of Service - TX", "Spares"}

# Sites forced below the other normal sites of their region (but still above OOS/Spares).
# John is a small satellite pod in a second West-TX location; we keep it in the TX region
# rather than splitting a one-pod region off, and just sink it to the bottom of the TX
# list so the main West TX sites read first. Region filtering is client-side + only hides
# cards, so this Python render order is what actually places the site in each region view.
SITES_LAST_IN_REGION = {"John"}


def _site_render_rank(name):
    """Three tiers, stable within each (preserves config order): normal sites (0),
    bottom-of-region sites like John (1), then OOS/Spares (2) at the very bottom."""
    if name in SPECIAL_SITES:
        return 2
    if name in SITES_LAST_IN_REGION:
        return 1
    return 0


note_authors = load_note_authors()
sorted_sites = sorted(config.get("sites", {}).items(), key=lambda kv: _site_render_rank(kv[0]))

for site_name, site_data in sorted_sites:
    generator_groups = site_data.get("generator_groups", {}) or {}
    total_gens = sum(len(g.get("generators", {}) or {}) for g in generator_groups.values())
    if total_gens == 0:
        continue

    site_id = site_data.get("site_id", "")
    safe_site = html.escape(site_name)

    # Bottom-tier sites (John, then OOS/Spares) each start on a fresh row below the grid.
    if _site_render_rank(site_name) >= 1:
        print(f'<div class="site-row-break"></div>')
    print(f'<div class="site-container">')
    print(f'  <div class="site-header">')
    print(f'    <div class="site-title">{safe_site}</div>')
    if site_id:
        print(f'    <div class="site-id">{html.escape(str(site_id))}</div>')
    print(f'  </div>')
    print(f'  <div class="groups-wrapper">')

    for group_name, group_data in generator_groups.items():
        generators = group_data.get("generators", {}) or {}
        if not generators:
            continue
        safe_group = html.escape(group_name)
        print(f'    <div class="group-container">')
        print(f'      <div class="group-title">{safe_group} ({len(generators)})</div>')
        print(f'      <div class="generators-stack">')

        # Sort for stable display in OOS/Spares; otherwise preserve config order
        if site_name in SPECIAL_SITES:
            gen_items = sorted(generators.items(), key=lambda x: x[0])
        else:
            gen_items = generators.items()

        for gen_id, gen_data in gen_items:
            if isinstance(gen_data, dict):
                status = gen_data.get("maintenance_status", "active")
                notes = gen_data.get("notes", "") or ""
                is_loaner = gen_data.get("is_loaner", False)
                repaired_at = gen_data.get("repaired_at")
                pm = gen_data.get("pm_schedule")
                pm_done = gen_data.get("pm_last_completed")
            else:
                status = "active"
                notes = ""
                is_loaner = False
                repaired_at = None
                pm = None
                pm_done = None

            status_info = GEN_STATUSES.get(status, GEN_STATUSES["active"])
            text_color = "black" if status_info["color"] in ("#00ff00", "#ffaa00") else "white"

            # data-field markers let the live poll re-sync these config-driven bits
            # (status tag / loaner) without a page reload — see refreshLiveData().
            badge_html = (
                f'<span class="gen-status-badge" data-field="statusbadge" '
                f'style="background:{status_info["color"]};color:{text_color};">'
                f'{status_info["label"]}</span>'
            )
            loaner_inner = '<span class="gen-status-badge" style="background:#aa44ff;color:white;">Loaner</span>' if is_loaner else ''
            loaner_html = f'<span data-field="loaner">{loaner_inner}</span>'
            repaired_html = repaired_chip_html(repaired_at, status)

            note_class = '' if notes else 'empty'
            note_html = note_block_html(gen_id, notes, note_authors)

            search_blob = html.escape(f"{gen_id} {notes}".lower(), quote=True)
            safe_gen_id = html.escape(str(gen_id), quote=True)
            region = gen_region(site_name, site_data, group_name)

            print(
                f'        <div class="gen-card" '
                f'data-gen="{safe_gen_id}" '
                f'data-site="{html.escape(site_name, quote=True)}" '
                f'data-group="{html.escape(group_name, quote=True)}" '
                f'data-status="{status}" '
                f'data-region="{region}" '
                f'data-search="{search_blob}" '
                f'data-notes="{html.escape(notes, quote=True)}" '
                f'data-note-meta="{html.escape(note_byline(gen_id, notes, note_authors), quote=True)}" '
                f'data-pm-date="{html.escape(str((pm or {}).get("date") or ""), quote=True)}" '
                f'data-pm-note="{html.escape(str((pm or {}).get("note") or ""), quote=True)}" '
                f'data-pm-done-date="{html.escape(str((pm_done or {}).get("date") or ""), quote=True)}" '
                f'data-pm-done-note="{html.escape(str((pm_done or {}).get("note") or ""), quote=True)}" '
                f'data-pm-done-by="{html.escape(str((pm_done or {}).get("by") or ""), quote=True)}" '
                f'onclick="openGenModal(this)">'
            )
            print(f'          <div class="gen-card-header">')
            print(f'            <span class="gen-status-dot" data-field="dot"></span>')
            print(f'            <span class="gen-id">{html.escape(str(gen_id))}</span>')
            print(f'            <span data-field="liveicon"></span>')
            print(f'            <span data-field="warnicon"></span>')
            print(f'            <span class="gen-badges">{badge_html}{loaner_html}{repaired_html}</span>')
            print(f'          </div>')
            print(f'          <div class="gen-run-row">')
            print(f'            <span class="gen-run-label">Status:</span>')
            print(f'            <span class="gen-run-status idle" data-field="run">—</span>')
            print(f'            <span class="gen-run-dur" data-field="dur"></span>')
            print(f'          </div>')
            print(f'          <div class="gen-stats-row">')
            print(f'            <span>Hours: <strong data-field="hrs">—</strong></span>')
            print(f'            <span class="gen-kw-cell">kW: <strong data-field="kw">—</strong></span>')
            print(f'            <span>NG: <strong data-field="ng">—</strong></span>')
            print(f'          </div>')
            print(f'          <div class="gen-warnline" data-field="warnline"></div>')
            # Maintenance section: hidden until it has something to say. The live
            # poll un-hides it once the countdown tag lands.
            maint_empty = '' if (pm_line_html(pm, pm_done) or note_html) else ' empty'
            print(f'          <div class="gen-maint{maint_empty}" data-field="maint">')
            print(f'            <div class="gen-maint-label">Maintenance</div>')
            print(f'            <div class="gen-pm-line" data-field="pmline">{pm_line_html(pm, pm_done)}</div>')
            print(f'            <div class="gen-note {note_class}" data-field="note">{note_html}</div>')
            print(f'          </div>')
            print(f'          <div class="gen-kw-bar gen-kw-bar-empty" data-field="kwbar"></div>')
            print(f'        </div>')

        print(f'      </div>')  # generators-stack
        print(f'    </div>')    # group-container

    print(f'  </div>')  # groups-wrapper
    print(f'</div>')    # site-container

print(f"""
        </div>
      </div><!-- /.left-col -->

      <aside class="chat-col">
        <div class="chat-header">
          <span><i class="fas fa-comments"></i> Page Chat<span id="chatRegionLabel"></span></span>
          <span class="chat-as">as <strong>{html.escape(auth.get_current_user() or '?')}</strong></span>
        </div>
        <div class="chat-messages" id="chatMessages">
          <div class="chat-empty" id="chatEmpty">Select a region (TX or ND) to view its chat.</div>
        </div>
        <div class="chat-input-row">
          <textarea id="chatInput" placeholder="Select a region to chat..." rows="1" disabled></textarea>
          <button id="chatSend" onclick="sendChat()" disabled>Send</button>
        </div>
      </aside>
    </div>
</div>

<script>
window.CHAT_USER = {json.dumps(auth.get_current_user() or 'unknown')};
</script>

<!-- Read-only gen detail modal with add-note + history -->
<div id="genModal" class="modal">
    <div class="modal-content">
        <h3 id="modalGenId"><i class="fas fa-bolt"></i> Generator</h3>

        <div class="ro-row"><span class="ro-label">Site</span><span class="ro-value" id="modalSite">—</span></div>
        <div class="ro-row"><span class="ro-label">Group</span><span class="ro-value" id="modalGroup">—</span></div>
        <div class="ro-row"><span class="ro-label">Status</span><span class="ro-value" id="modalStatus">—</span></div>
        <div class="ro-row"><span class="ro-label">Run State</span><span class="ro-value" id="modalRun">—</span></div>
        <div class="ro-row" id="modalWarnRow" style="display:none;"><span class="ro-label">Warnings</span><span class="ro-value" id="modalWarn" style="color:#ffb300;">—</span></div>
        <div class="ro-row"><span class="ro-label">Engine Hrs</span><span class="ro-value" id="modalHrs">—</span></div>
        <div class="ro-row"><span class="ro-label">kW</span><span class="ro-value" id="modalKw">—</span></div>
        <div class="ro-row"><span class="ro-label">NG Pressure</span><span class="ro-value" id="modalNg">—</span></div>

        <label>Active Note:</label>
        <div id="modalNote" class="ro-block note-block empty">No active note</div>

        <div class="history-panel">
            <div class="history-title"><i class="fas fa-triangle-exclamation"></i> Shutdown History (30 days)</div>
            <div id="shutdownHistoryContainer">Loading&hellip;</div>
        </div>

        <label>Scheduled PM (by week):</label>
        <div class="pm-row">
            <input type="date" id="pmDateInput">
            <input type="text" id="pmNoteInput" placeholder="What's being done (optional)">
            <button class="modal-btn modal-btn-save" onclick="savePmSchedule()">Save</button>
            <button class="modal-btn modal-btn-done" onclick="completePm()">Complete</button>
            <button class="modal-btn modal-btn-cancel" onclick="clearPmSchedule()">Clear</button>
        </div>
        <div class="note-hint">Pick any day &mdash; it books the whole week starting that Monday.</div>
        <div class="pm-current" id="pmCurrent">No PM scheduled</div>

        <label for="mesaNoteInput">Add Mesa Note:</label>
        <textarea id="mesaNoteInput" placeholder="Add a note about this generator..."></textarea>
        <div class="note-hint">Your note will be saved as: &quot;Mesa: &lt;your text&gt;&quot;</div>

        <div class="history-panel">
            <div class="history-title"><i class="fas fa-history"></i> Note History</div>
            <div id="historyContainer">Loading history...</div>
        </div>

        <div class="modal-buttons">
            <button class="modal-btn modal-btn-save" onclick="addMesaNote()">Add Note</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeGenModal()">Close</button>
        </div>
    </div>
</div>

<!-- Upload + reconcile a Mesa service schedule -->
<div id="uploadModal" class="modal">
    <div class="modal-content">
        <h3><i class="fas fa-file-arrow-up"></i> Upload Service Schedule</h3>
        <div class="up-hint">
            Pick the schedule workbook and tell us the date Mesa generated it &mdash; that
            date isn't stored inside the file, and it's what lets us tell their snapshot
            apart from anything recorded here since. Nothing is written until you review
            the changes and press Apply.
        </div>
        <div class="up-row">
            <input type="file" id="upFile" accept=".xlsx">
            <label style="color:#888;font-size:12px;margin:0;">Sheet dated</label>
            <input type="date" id="upDate">
            <button class="modal-btn modal-btn-save" onclick="checkUpload()">Check changes</button>
        </div>
        <div id="upResult"></div>
        <div class="modal-buttons">
            <button class="modal-btn modal-btn-done" id="upApply" onclick="applyUpload()" style="display:none;">Apply</button>
            <button class="modal-btn modal-btn-cancel" onclick="closeUploadModal()">Close</button>
        </div>
    </div>
</div>

<!-- Notifications HowTo modal (non-technical phone-alert setup) -->
<div id="helpModal" class="modal">
    <div class="modal-content">
        <h3><i class="fas fa-bell"></i> Get Notified on Your Phone</h3>
        <div class="help-intro">
            Want an alert on your phone every time a note is posted here? Set it up once and
            you'll get a notification whenever something is added for your generators.
            It's free &mdash; no text messages, no sign-up, no account.
        </div>

        <div class="help-step">
            <div class="num">1</div>
            <div class="body">
                <b>Install the free &ldquo;ntfy&rdquo; app</b> on your phone:
                <div class="store-links">
                    <a class="store-link" href="https://apps.apple.com/us/app/ntfy/id1625396347" target="_blank" rel="noopener"><i class="fab fa-apple"></i> iPhone &middot; App Store</a>
                    <a class="store-link" href="https://play.google.com/store/apps/details?id=io.heckel.ntfy" target="_blank" rel="noopener"><i class="fab fa-google-play"></i> Android &middot; Google Play</a>
                </div>
            </div>
        </div>

        <div class="help-step">
            <div class="num">2</div>
            <div class="body">
                Open the app and tap the <b>+</b> button (&ldquo;Subscribe to topic&rdquo;).
            </div>
        </div>

        <div class="help-step">
            <div class="num">3</div>
            <div class="body">
                Enter the name(s) below exactly and tap <b>Subscribe</b>. Add <b>both</b> for
                all alerts, or just the area you cover:
                <div class="topic-row">
                    <span class="topic-name">mesa-ngon-nd</span>
                    <span class="topic-region">North Dakota</span>
                    <button class="copy-btn" onclick="copyTopic('mesa-ngon-nd', this)">Copy</button>
                </div>
                <div class="topic-row">
                    <span class="topic-name">mesa-ngon-tx</span>
                    <span class="topic-region">Texas</span>
                    <button class="copy-btn" onclick="copyTopic('mesa-ngon-tx', this)">Copy</button>
                </div>
            </div>
        </div>

        <div class="help-step">
            <div class="num">4</div>
            <div class="body">
                That's it! You'll now get a notification whenever a note is posted for a
                generator in that area.
            </div>
        </div>

        <div class="help-note">
            <b>Good to know:</b> you won't get an alert for notes <b>you</b> post yourself
            &mdash; only for notes from the NGON team. So if you post a test note and don't
            see a notification, that's normal, not a problem. To try it out, ask the NGON
            team to post one.
        </div>

        <div class="help-tip">
            <b>Android tip:</b> if alerts seem slow or don't arrive, open your phone's
            Settings &rarr; Apps &rarr; ntfy and turn <b>off</b> battery optimization so it
            can run in the background. iPhones work automatically.
        </div>

        <div class="modal-buttons">
            <button class="modal-btn modal-btn-cancel" onclick="closeHelpModal()">Close</button>
        </div>
    </div>
</div>

<script>
window.GEN_STATUSES = """)
print(json.dumps(GEN_STATUSES))
print(""";
</script>
<script src="shutdown_history.js?v=1"></script>
<script src="mesa_gen_tracker.js?v=""" + str(_js_v) + """"></script>
</body>
</html>
""")
