import math
import re as _re
from datetime import datetime, timedelta

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st

"""
DEWA PR Tracker — Streamlit Dashboard
Run:  streamlit run dashboard.py
"""
import math
import re as _re
import uuid
from datetime import datetime, timedelta

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from streamlit.runtime.scriptrunner import get_script_run_ctx

import database as db
from config import (
    COST_PER_PODCAST_OLD_AED,
    NOTEBOOKLM_MONTHLY_USD,
    NOTEBOOKLM_START_DATE,
    USD_TO_AED,
    DASHBOARD_TITLE,
    PAGE_SIZE,
)

# ── Page setup ────────────────────────────────────────────────────────────────
st.set_page_config(
    page_title=DASHBOARD_TITLE,
    page_icon="📰",
    layout="wide",
    initial_sidebar_state="collapsed",   # collapsed by default on mobile
)

# ── Visitor tracking — log each new browser session once ─────────────────────
def _get_session_id():
    ctx = get_script_run_ctx()
    if ctx:
        return ctx.session_id
    return str(uuid.uuid4())

if "visit_logged" not in st.session_state:
    db.init_db()
    sid = _get_session_id()
    db.log_visit(sid)
    st.session_state["visit_logged"] = True

# ── SVG helpers ───────────────────────────────────────────────────────────────
def _svg(paths, size=16, color="#0077b6"):
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" '
        f'viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" '
        f'stroke-linecap="round" stroke-linejoin="round" '
        f'style="vertical-align:middle;margin-right:6px;flex-shrink:0">{paths}</svg>'
    )

# Icon paths
_P_DOC  = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>'
_P_MIC  = '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>'
_P_COIN = '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>'
_P_BAR  = '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>'
_P_LIST = '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>'
_P_CAL  = '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>'
_P_LINK = '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'
_P_CLK  = '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'
_P_WAVE = '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'
_P_SYNC = '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'

def section_title(icon_path, text, icon_color="#0077b6"):
    st.markdown(
        f'
'
        f'{_svg(icon_path, size=18, color=icon_color)}{text}
',
        unsafe_allow_html=True,
    )

# ── CSS — light theme + mobile ────────────────────────────────────────────────
st.markdown("""

""", unsafe_allow_html=True)

# ── Data loading (cached 5 min) ───────────────────────────────────────────────
@st.cache_data(ttl=300)
def load_data():
    db.init_db()
    prs      = [dict(r) for r in db.get_all_press_releases()]
    pods     = [dict(r) for r in db.get_all_podcasts()]
    roi_rows = [dict(r) for r in db.get_roi_monthly_since(NOTEBOOKLM_START_DATE)]
    stats    = db.get_stats()
    log      = [dict(r) for r in db.get_scrape_log()]
    return prs, pods, roi_rows, stats, log

prs, pods, roi_rows, stats, scrape_log = load_data()
df_pr  = pd.DataFrame(prs)
df_pod = pd.DataFrame(pods)

if not df_pod.empty and "title" in df_pod.columns:
    df_pod["language"] = df_pod["title"].apply(
        lambda t: "ar" if _re.search(r"[\u0600-\u06FF]", str(t)) else "en"
    )
if not df_pr.empty:
    df_pr["date"]  = pd.to_datetime(df_pr["date"], errors="coerce")
    df_pr["week"]  = df_pr["date"].dt.to_period("W").astype(str)
    df_pr["month"] = df_pr["date"].dt.to_period("M").astype(str)
if not df_pod.empty:
    df_pod["date"] = pd.to_datetime(df_pod["date"], errors="coerce")

# ── ROI pre-calculations ──────────────────────────────────────────────────────
NOTEBOOKLM_AED = NOTEBOOKLM_MONTHLY_USD * USD_TO_AED
NLM_START      = pd.Timestamp(NOTEBOOKLM_START_DATE)

df_roi = pd.DataFrame(roi_rows) if roi_rows else pd.DataFrame(
    columns=["month","pr_count","en_episodes","ar_episodes","total_episodes"]
)
if not df_roi.empty:
    df_roi["old_cost"] = df_roi["pr_count"] * COST_PER_PODCAST_OLD_AED
    df_roi["new_cost"] = NOTEBOOKLM_AED
    df_roi["savings"]  = df_roi["old_cost"] - df_roi["new_cost"]
    total_prs_nlm  = int(df_roi["pr_count"].sum())
    total_old_cost = float(df_roi["old_cost"].sum())
    total_new_cost = float(df_roi["new_cost"].sum())
    total_savings  = float(df_roi["savings"].sum())
    roi_pct        = (total_savings / total_old_cost * 100) if total_old_cost > 0 else 0
else:
    total_prs_nlm = total_old_cost = total_new_cost = total_savings = roi_pct = 0

# ── Sidebar ───────────────────────────────────────────────────────────────────
with st.sidebar:
    st.markdown(
        '
DEWA
'
        '
Dubai Electricity & Water Authority
',
        unsafe_allow_html=True,
    )
    st.divider()

    if not df_pr.empty and df_pr["date"].notna().any():
        min_date = df_pr["date"].min().date()
        max_date = df_pr["date"].max().date()
    else:
        min_date = datetime(2019, 1, 1).date()
        max_date = datetime.today().date()

    date_from, date_to = st.date_input(
        "Date range",
        value=(NLM_START.date(), max_date),
        min_value=min_date, max_value=max_date,
    )
    categories = ["All"]
    if not df_pr.empty and "category" in df_pr.columns:
        cats = df_pr["category"].dropna().unique().tolist()
        categories += sorted(cats)
    selected_cat = st.selectbox("Category", categories)
    has_podcast_filter = st.checkbox("Show only PRs with podcast", value=False)

    st.divider()
    if st.button("Refresh data", use_container_width=True):
        st.cache_data.clear()
        st.rerun()
    if st.button("Run scraper now", use_container_width=True):
        with st.spinner("Scraping DEWA website…"):
            try:
                from scraper import run_scrape
                prs_n, pods_n = run_scrape()
                st.success(f"Done — {prs_n} PRs, {pods_n} podcasts saved.")
                st.cache_data.clear()
                st.rerun()
            except Exception as e:
                st.error(f"Scraper error: {e}")
    st.divider()

    last_scraped = stats.get('last_scraped','Never') or 'Never'
    st.caption(f"Last scraped: {last_scraped}")

    # ── Live countdown to next 08:00 Dubai time ───────────────────────────────
    st.markdown("""

    Next auto-refresh

    <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
      fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
    </svg>
    --:--:--

Daily at 08:00 Dubai time

""", unsafe_allow_html=True)

# ── Apply filters ─────────────────────────────────────────────────────────────
filtered = df_pr.copy()
if not filtered.empty:
    if filtered["date"].notna().any():
        filtered = filtered[
            (filtered["date"].dt.date >= date_from) &
            (filtered["date"].dt.date <= date_to)
        ]
    if selected_cat != "All":
        filtered = filtered[filtered["category"] == selected_cat]
    if has_podcast_filter:
        filtered = filtered[filtered["podcast_count"] > 0]

# ═══════════════════════════════════════════════════════════════════════════════
# HEADER
# ═══════════════════════════════════════════════════════════════════════════════
st.markdown(
    f'
'
    f'{_svg(_P_DOC, size=30, color="#0077b6")}'
    f'
{DASHBOARD_TITLE}
'
    f'
'
    f'
'
    f'NotebookLM adoption since {NOTEBOOKLM_START_DATE}  ·  '
    f'{datetime.today().strftime("%d %B %Y")}
',
    unsafe_allow_html=True,
)
st.markdown("
", unsafe_allow_html=True)

# ═══════════════════════════════════════════════════════════════════════════════
# KPI CARDS
# ═══════════════════════════════════════════════════════════════════════════════
today       = datetime.today().date()
week_start  = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
last_month  = (month_start - timedelta(days=1)).replace(day=1)

if not filtered.empty and filtered["date"].notna().any():
    prs_today      = len(filtered[filtered["date"].dt.date == today])
    prs_week       = len(filtered[filtered["date"].dt.date >= week_start])
    prs_month      = len(filtered[filtered["date"].dt.date >= month_start])
    prs_last_month = len(filtered[
        (filtered["date"].dt.date >= last_month) &
        (filtered["date"].dt.date <  month_start)
    ])
else:
    prs_today = prs_week = prs_month = prs_last_month = 0

prs_with_pod = len(filtered[filtered["podcast_count"] > 0]) if not filtered.empty else 0
coverage_pct = (prs_with_pod / len(filtered) * 100) if len(filtered) > 0 else 0
month_delta  = prs_month - prs_last_month
d_cls        = "kpi-delta-pos" if month_delta >= 0 else "kpi-delta-neg"
d_sign       = "+" if month_delta >= 0 else ""

def kpi_card(color, icon_path, label, value, sub="", icon_color=None):
    clr = icon_color or {"blue":"#0077b6","green":"#16a34a","amber":"#d97706","purple":"#7c3aed","rose":"#e11d48"}[color]
    ico = _svg(icon_path, size=13, color=clr)
    return (
        f'
'
        f'
{ico}{label}
'
        f'
{value}
'
        f'{"
" + sub + "
" if sub else ""}'
        f'
'
    )

st.markdown(
    f'
'
    f'{kpi_card("blue",  _P_DOC,  "Total Press Releases", f"{len(filtered):,}", f"since {date_from.strftime(chr(37)+chr(100)+chr(32)+chr(37)+chr(98)+chr(32)+chr(37)+chr(89))}")}'
    f'{kpi_card("green", _P_MIC,  "Podcast Coverage",     f"{coverage_pct:.1f}%", f"{prs_with_pod} of {len(filtered)} PRs", "#16a34a")}'
    f'{kpi_card("amber", _P_CAL,  "This Month",           f"{prs_month:,}", f"{d_sign}{month_delta} vs last month", "#d97706")}'
    f'{kpi_card("purple",_P_WAVE, "This Week",            f"{prs_week:,}", f"since {week_start.strftime(chr(37)+chr(100)+chr(32)+chr(37)+chr(98))}", "#7c3aed")}'
    f'{kpi_card("rose",  _P_CLK,  "Today",               f"{prs_today:,}", today.strftime("%d %B %Y"), "#e11d48")}'
    f'
',
    unsafe_allow_html=True,
)
st.markdown("
", unsafe_allow_html=True)
st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# ROI — NotebookLM
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_COIN, "NotebookLM ROI — Since 23 May 2025", "#16a34a")

months_active = len(df_roi)
st.markdown(
    f'
'
    f'
'
    f'
{_svg(_P_CLK, 13, "#e11d48")}Traditional Cost
'
    f'
AED {total_old_cost:,.0f}
'
    f'
{total_prs_nlm} PRs × AED {COST_PER_PODCAST_OLD_AED:,}/podcast
'
    f'
'
    f'
'
    f'
{_svg(_P_MIC, 13, "#16a34a")}NotebookLM Cost
'
    f'
AED {total_new_cost:,.0f}
'
    f'
{months_active} months × AED {NOTEBOOKLM_AED:.0f}/mo (${NOTEBOOKLM_MONTHLY_USD})
'
    f'
'
    f'
'
    f'
{_svg(_P_COIN, 13, "#d97706")}Total Savings
'
    f'
AED {total_savings:,.0f}
'
    f'
Since {NOTEBOOKLM_START_DATE}
'
    f'
'
    f'
'
    f'
{_svg(_P_BAR, 13, "#0077b6")}Return on Investment
'
    f'
{roi_pct:.1f}%
'
    f'
Savings ÷ traditional cost
'
    f'
'
    f'
',
    unsafe_allow_html=True,
)

# ── Charts + table ────────────────────────────────────────────────────────────
if not df_roi.empty:
    chart_col, table_col = st.columns([3, 2], gap="medium")

    PLOT_LAYOUT = dict(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", font_size=12,
        legend_title_text="",
        margin=dict(t=40, b=10, l=10, r=10),
        xaxis=dict(showgrid=False, title="", tickfont=dict(size=11)),
        yaxis=dict(gridcolor="#f1f5f9", title="AED", tickfont=dict(size=11)),
    )

    with chart_col:
        df_cl = df_roi[["month","old_cost","new_cost"]].melt(
            id_vars="month", var_name="Type", value_name="AED"
        )
        df_cl["Type"] = df_cl["Type"].map({
            "old_cost": "Traditional (AED 20k/podcast)",
            "new_cost": "NotebookLM ($20/month)"
        })
        fig1 = px.bar(df_cl, x="month", y="AED", color="Type", barmode="group",
            color_discrete_map={
                "Traditional (AED 20k/podcast)": "#fca5a5",
                "NotebookLM ($20/month)":         "#86efac",
            },
            title="Monthly Cost: Traditional vs NotebookLM",
        )
        fig1.update_layout(**PLOT_LAYOUT, height=300)
        st.plotly_chart(fig1, use_container_width=True)

        fig2 = px.bar(df_roi, x="month", y="pr_count",
            color_discrete_sequence=["#93c5fd"],
            title="Press Releases with Podcasts per Month",
        )
        fig2.update_layout(**{**PLOT_LAYOUT, "yaxis": dict(gridcolor="#f1f5f9", title="PRs", tickfont=dict(size=11))}, height=260)
        st.plotly_chart(fig2, use_container_width=True)

    with table_col:
        st.markdown(
            '
'
            'AED 20,000 per PR podcast production (EN + AR = one production unit). '
            'NotebookLM billed as flat $20/month.
',
            unsafe_allow_html=True,
        )
        rows_html = ""
        for _, r in df_roi.iterrows():
            rows_html += (
                f'
'
                f'{r["month"]}	'
                f'{int(r["pr_count"])}	'
                f'{int(r["en_episodes"])}	'
                f'{int(r["ar_episodes"])}	'
                f'AED {r["old_cost"]:,.0f}	'
                f'AED {r["new_cost"]:,.0f}	'
                f'AED {r["savings"]:,.0f}	'
                f'
'
            )
        rows_html += (
            f'
'
            f'TOTAL	'
            f'{total_prs_nlm}	'
            f'{int(df_roi["en_episodes"].sum())}	'
            f'{int(df_roi["ar_episodes"].sum())}	'
            f'AED {total_old_cost:,.0f}	'
            f'AED {total_new_cost:,.0f}	'
            f'AED {total_savings:,.0f}	'
            f'
'
        )
        st.markdown(
            f'
'
            f'

'
            f'Month	PRs	EN	AR	'
            f'Old Cost	New Cost	Savings	'
            f'

{rows_html}

'
            f'
',
            unsafe_allow_html=True,
        )

st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# PUBLICATION TRENDS
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_BAR, "Publication Trends", "#7c3aed")
tab_daily, tab_weekly, tab_monthly = st.tabs(["Daily", "Weekly", "Monthly"])

TREND_LAYOUT = dict(
    paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
    font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
    xaxis=dict(showgrid=False, title=""),
    yaxis=dict(gridcolor="#f1f5f9"),
)

def trend_bar(df_g, x, y, title, color):
    fig = px.bar(df_g, x=x, y=y, title=title, color_discrete_sequence=[color])
    fig.update_layout(**TREND_LAYOUT)
    return fig

with tab_daily:
    if not filtered.empty and filtered["date"].notna().any():
        daily = filtered.groupby(filtered["date"].dt.date).size().reset_index(name="count")
        daily.columns = ["date","count"]
        st.plotly_chart(trend_bar(daily,"date","count","Press Releases per Day","#93c5fd"), use_container_width=True)
    else:
        st.info("No data yet.")

with tab_weekly:
    if not filtered.empty and "week" in filtered.columns and filtered["date"].notna().any():
        weekly = filtered.groupby("week").size().reset_index(name="count")
        st.plotly_chart(trend_bar(weekly,"week","count","Press Releases per Week","#a5b4fc"), use_container_width=True)
    else:
        st.info("No data yet.")

with tab_monthly:
    if not filtered.empty and "month" in filtered.columns and filtered["date"].notna().any():
        monthly = filtered.groupby("month").size().reset_index(name="count")
        st.plotly_chart(trend_bar(monthly,"month","count","Press Releases per Month","#c4b5fd"), use_container_width=True)
    else:
        st.info("No data yet.")

st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# PRESS RELEASES TABLE
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_LIST, "Press Releases", "#0077b6")

search = st.text_input("Search", placeholder="Filter press releases by title…", label_visibility="collapsed")
display = (
    filtered[filtered["title"].str.contains(search, case=False, na=False)]
    if search else filtered.copy()
)

total_pages = max(1, math.ceil(len(display) / PAGE_SIZE))
page_num    = st.number_input("Page", min_value=1, max_value=total_pages, value=1, step=1)
start, end  = (page_num-1)*PAGE_SIZE, page_num*PAGE_SIZE
page_data   = display.iloc[start:end]

st.caption(f"Showing {start+1}–{min(end,len(display))} of {len(display)} results  (page {page_num}/{total_pages})")

for _, row in page_data.iterrows():
    url   = row.get("url","")
    title = row.get("title","—")
    d     = row.get("date")
    date_str = d.strftime("%d %b %Y") if pd.notna(d) else "—"
    desc  = str(row.get("description") or "")
    desc_short = (desc[:150]+"…") if len(desc)>150 else desc
    pod_badge = (
        f'{_svg(_P_MIC,11,"#c2410c")} Podcast'
        if row.get("podcast_count",0) else ""
    )
    st.markdown(
        f'
'
        f'
'
        f'{title}'
        f'
'
        f'{date_str}{pod_badge}'
        f'

'
        f'{"
" + desc_short + "
" if desc_short else ""}'
        f'
',
        unsafe_allow_html=True,
    )

# ═══════════════════════════════════════════════════════════════════════════════
# PODCASTS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
section_title(_P_MIC, "Podcasts", "#d97706")

if not df_pod.empty:
    lang_filter = st.radio("Language", ["All","English","Arabic"], horizontal=True)
    df_show = df_pod.copy()
    if lang_filter == "English":
        df_show = df_show[df_show["language"]=="en"]
    elif lang_filter == "Arabic":
        df_show = df_show[df_show["language"]=="ar"]

    pod_q = st.text_input("Search podcasts", placeholder="Filter by title…", label_visibility="collapsed")
    if pod_q:
        df_show = df_show[df_show["title"].str.contains(pod_q, case=False, na=False)]

    st.caption(f"Showing {len(df_show)} of {len(df_pod)} episodes")

    for _, pod in df_show.head(50).iterrows():
        with st.expander(f"{pod.get('title','—')}  ·  {pod.get('date','')}"):
            ca, cb = st.columns([3,1])
            with ca:
                if pod.get("pr_title"):
                    st.markdown(f"**Linked PR:** {pod['pr_title']}")
                if pod.get("url"):
                    st.markdown(f"[Open on RSS.com]({pod['url']})")
                if pod.get("audio_url"):
                    st.audio(pod["audio_url"])
            with cb:
                if pod.get("duration"):
                    st.metric("Duration", pod["duration"])
                st.metric("Saved", f"AED {COST_PER_PODCAST_OLD_AED:,}")

# ═══════════════════════════════════════════════════════════════════════════════
# VISITOR ANALYTICS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
_P_EYE   = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
_P_USERS = '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
section_title(_P_EYE, "Dashboard Views", "#7c3aed")

vstats = db.get_visitor_stats()
v1, v2, v3 = st.columns(3)
with v1:
    st.markdown(
        f'
'
        f'
{_svg(_P_USERS,13,"#7c3aed")}Total Sessions
'
        f'
{vstats["total"]:,}
'
        f'
unique visitors since launch
'
        f'
', unsafe_allow_html=True,
    )
with v2:
    st.markdown(
        f'
'
        f'
{_svg(_P_EYE,13,"#0077b6")}Today
'
        f'
{vstats["today"]:,}
'
        f'
{today.strftime("%d %B %Y")}
'
        f'
', unsafe_allow_html=True,
    )
with v3:
    last_visit = vstats["recent"][0]["visited_at"][:16].replace("T", " ") if vstats["recent"] else "—"
    st.markdown(
        f'
'
        f'
{_svg(_P_CLK,13,"#16a34a")}Last Visit
'
        f'
{last_visit}
'
        f'
UTC time
'
        f'
', unsafe_allow_html=True,
    )

st.markdown("
", unsafe_allow_html=True)

if vstats["by_day"]:
    df_visits = pd.DataFrame([dict(r) for r in vstats["by_day"]])
    df_visits.columns = ["date", "visits"]
    df_visits = df_visits.sort_values("date")
    fig_v = px.bar(df_visits, x="date", y="visits",
        title="Daily Views (last 30 days)",
        color_discrete_sequence=["#a78bfa"],
    )
    fig_v.update_layout(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
        xaxis=dict(showgrid=False, title=""),
        yaxis=dict(gridcolor="#f1f5f9", title="Sessions"),
        height=220,
    )
    st.plotly_chart(fig_v, use_container_width=True)

with st.expander("Recent visits"):
    if vstats["recent"]:
        df_rec = pd.DataFrame([dict(r) for r in vstats["recent"]])
        df_rec.columns = ["Visited At (UTC)", "User Agent"]
        df_rec["Visited At (UTC)"] = df_rec["Visited At (UTC)"].str[:19].str.replace("T"," ")
        st.dataframe(df_rec, use_container_width=True, hide_index=True)
    else:
        st.info("No visits recorded yet.")

# ═══════════════════════════════════════════════════════════════════════════════
# SCRAPE LOG
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
with st.expander("Scrape history"):
    if scrape_log:
        st.dataframe(
            pd.DataFrame(scrape_log)[["ran_at","prs_found","pods_found","status","notes"]],
            use_container_width=True,
        )
    else:
        st.info("No scrape runs recorded yet.")

st.markdown(
    '
'
    'DEWA PR Tracker · Playwright · SQLite · Streamlit
',
    unsafe_allow_html=True,
)

-from streamlit.runtime.scriptrunner import get_script_run_ctx


Edit

# ── Visitor tracking — log each new browser session once ─────────────────────
def _get_session_id():
    ctx = get_script_run_ctx()
    if ctx:
        return ctx.session_id
    return str(uuid.uuid4())

if "visit_logged" not in st.session_state:
    db.init_db()
    sid = _get_session_id()
    db.log_visit(sid)
    st.session_state["visit_logged"] = True

# ── SVG helpers ───────────────────────────────────────────────────────────────

# ── SVG helpers ───────────────────────────────────────────────────────────────


The file /Users/khalidbinkalban/Projects/dewa-pr-tracker/dashboard.py has been updated successfully.

"""
DEWA PR Tracker — Streamlit Dashboard
Run:  streamlit run dashboard.py
"""
import math
import re as _re
from datetime import datetime, timedelta

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st

import database as db
from config import (
    COST_PER_PODCAST_OLD_AED,
    NOTEBOOKLM_MONTHLY_USD,
    NOTEBOOKLM_START_DATE,
    USD_TO_AED,
    DASHBOARD_TITLE,
    PAGE_SIZE,
)

# ── Page setup ────────────────────────────────────────────────────────────────
st.set_page_config(
    page_title=DASHBOARD_TITLE,
    page_icon="📰",
    layout="wide",
    initial_sidebar_state="collapsed",   # collapsed by default on mobile
)

# ── Visitor tracking — log each new browser session once ─────────────────────
def _get_session_id():
    ctx = get_script_run_ctx()
    if ctx:
        return ctx.session_id
    return str(uuid.uuid4())

if "visit_logged" not in st.session_state:
    db.init_db()
    sid = _get_session_id()
    db.log_visit(sid)
    st.session_state["visit_logged"] = True

# ── SVG helpers ───────────────────────────────────────────────────────────────
def _svg(paths, size=16, color="#0077b6"):
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" '
        f'viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" '
        f'stroke-linecap="round" stroke-linejoin="round" '
        f'style="vertical-align:middle;margin-right:6px;flex-shrink:0">{paths}</svg>'
    )

# Icon paths
_P_DOC  = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>'
_P_MIC  = '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>'
_P_COIN = '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>'
_P_BAR  = '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>'
_P_LIST = '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>'
_P_CAL  = '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>'
_P_LINK = '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'
_P_CLK  = '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'
_P_WAVE = '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'
_P_SYNC = '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'

def section_title(icon_path, text, icon_color="#0077b6"):
    st.markdown(
        f'
'
        f'{_svg(icon_path, size=18, color=icon_color)}{text}
',
        unsafe_allow_html=True,
    )

# ── CSS — light theme + mobile ────────────────────────────────────────────────
st.markdown("""

""", unsafe_allow_html=True)

# ── Data loading (cached 5 min) ───────────────────────────────────────────────
@st.cache_data(ttl=300)
def load_data():
    db.init_db()
    prs      = [dict(r) for r in db.get_all_press_releases()]
    pods     = [dict(r) for r in db.get_all_podcasts()]
    roi_rows = [dict(r) for r in db.get_roi_monthly_since(NOTEBOOKLM_START_DATE)]
    stats    = db.get_stats()
    log      = [dict(r) for r in db.get_scrape_log()]
    return prs, pods, roi_rows, stats, log

prs, pods, roi_rows, stats, scrape_log = load_data()
df_pr  = pd.DataFrame(prs)
df_pod = pd.DataFrame(pods)

if not df_pod.empty and "title" in df_pod.columns:
    df_pod["language"] = df_pod["title"].apply(
        lambda t: "ar" if _re.search(r"[\u0600-\u06FF]", str(t)) else "en"
    )
if not df_pr.empty:
    df_pr["date"]  = pd.to_datetime(df_pr["date"], errors="coerce")
    df_pr["week"]  = df_pr["date"].dt.to_period("W").astype(str)
    df_pr["month"] = df_pr["date"].dt.to_period("M").astype(str)
if not df_pod.empty:
    df_pod["date"] = pd.to_datetime(df_pod["date"], errors="coerce")

# ── ROI pre-calculations ──────────────────────────────────────────────────────
NOTEBOOKLM_AED = NOTEBOOKLM_MONTHLY_USD * USD_TO_AED
NLM_START      = pd.Timestamp(NOTEBOOKLM_START_DATE)

df_roi = pd.DataFrame(roi_rows) if roi_rows else pd.DataFrame(
    columns=["month","pr_count","en_episodes","ar_episodes","total_episodes"]
)
if not df_roi.empty:
    df_roi["old_cost"] = df_roi["pr_count"] * COST_PER_PODCAST_OLD_AED
    df_roi["new_cost"] = NOTEBOOKLM_AED
    df_roi["savings"]  = df_roi["old_cost"] - df_roi["new_cost"]
    total_prs_nlm  = int(df_roi["pr_count"].sum())
    total_old_cost = float(df_roi["old_cost"].sum())
    total_new_cost = float(df_roi["new_cost"].sum())
    total_savings  = float(df_roi["savings"].sum())
    roi_pct        = (total_savings / total_old_cost * 100) if total_old_cost > 0 else 0
else:
    total_prs_nlm = total_old_cost = total_new_cost = total_savings = roi_pct = 0

# ── Sidebar ───────────────────────────────────────────────────────────────────
with st.sidebar:
    st.markdown(
        '
DEWA
'
        '
Dubai Electricity & Water Authority
',
        unsafe_allow_html=True,
    )
    st.divider()

    if not df_pr.empty and df_pr["date"].notna().any():
        min_date = df_pr["date"].min().date()
        max_date = df_pr["date"].max().date()
    else:
        min_date = datetime(2019, 1, 1).date()
        max_date = datetime.today().date()

    date_from, date_to = st.date_input(
        "Date range",
        value=(NLM_START.date(), max_date),
        min_value=min_date, max_value=max_date,
    )
    categories = ["All"]
    if not df_pr.empty and "category" in df_pr.columns:
        cats = df_pr["category"].dropna().unique().tolist()
        categories += sorted(cats)
    selected_cat = st.selectbox("Category", categories)
    has_podcast_filter = st.checkbox("Show only PRs with podcast", value=False)

    st.divider()
    if st.button("Refresh data", use_container_width=True):
        st.cache_data.clear()
        st.rerun()
    if st.button("Run scraper now", use_container_width=True):
        with st.spinner("Scraping DEWA website…"):
            try:
                from scraper import run_scrape
                prs_n, pods_n = run_scrape()
                st.success(f"Done — {prs_n} PRs, {pods_n} podcasts saved.")
                st.cache_data.clear()
                st.rerun()
            except Exception as e:
                st.error(f"Scraper error: {e}")
    st.divider()

    last_scraped = stats.get('last_scraped','Never') or 'Never'
    st.caption(f"Last scraped: {last_scraped}")

    # ── Live countdown to next 08:00 Dubai time ───────────────────────────────
    st.markdown("""

    Next auto-refresh

    <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
      fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
    </svg>
    --:--:--

Daily at 08:00 Dubai time

""", unsafe_allow_html=True)

# ── Apply filters ─────────────────────────────────────────────────────────────
filtered = df_pr.copy()
if not filtered.empty:
    if filtered["date"].notna().any():
        filtered = filtered[
            (filtered["date"].dt.date >= date_from) &
            (filtered["date"].dt.date <= date_to)
        ]
    if selected_cat != "All":
        filtered = filtered[filtered["category"] == selected_cat]
    if has_podcast_filter:
        filtered = filtered[filtered["podcast_count"] > 0]

# ═══════════════════════════════════════════════════════════════════════════════
# HEADER
# ═══════════════════════════════════════════════════════════════════════════════
st.markdown(
    f'
'
    f'{_svg(_P_DOC, size=30, color="#0077b6")}'
    f'
{DASHBOARD_TITLE}
'
    f'
'
    f'
'
    f'NotebookLM adoption since {NOTEBOOKLM_START_DATE}  ·  '
    f'{datetime.today().strftime("%d %B %Y")}
',
    unsafe_allow_html=True,
)
st.markdown("
", unsafe_allow_html=True)

# ═══════════════════════════════════════════════════════════════════════════════
# KPI CARDS
# ═══════════════════════════════════════════════════════════════════════════════
today       = datetime.today().date()
week_start  = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
last_month  = (month_start - timedelta(days=1)).replace(day=1)

if not filtered.empty and filtered["date"].notna().any():
    prs_today      = len(filtered[filtered["date"].dt.date == today])
    prs_week       = len(filtered[filtered["date"].dt.date >= week_start])
    prs_month      = len(filtered[filtered["date"].dt.date >= month_start])
    prs_last_month = len(filtered[
        (filtered["date"].dt.date >= last_month) &
        (filtered["date"].dt.date <  month_start)
    ])
else:
    prs_today = prs_week = prs_month = prs_last_month = 0

prs_with_pod = len(filtered[filtered["podcast_count"] > 0]) if not filtered.empty else 0
coverage_pct = (prs_with_pod / len(filtered) * 100) if len(filtered) > 0 else 0
month_delta  = prs_month - prs_last_month
d_cls        = "kpi-delta-pos" if month_delta >= 0 else "kpi-delta-neg"
d_sign       = "+" if month_delta >= 0 else ""

def kpi_card(color, icon_path, label, value, sub="", icon_color=None):
    clr = icon_color or {"blue":"#0077b6","green":"#16a34a","amber":"#d97706","purple":"#7c3aed","rose":"#e11d48"}[color]
    ico = _svg(icon_path, size=13, color=clr)
    return (
        f'
'
        f'
{ico}{label}
'
        f'
{value}
'
        f'{"
" + sub + "
" if sub else ""}'
        f'
'
    )

st.markdown(
    f'
'
    f'{kpi_card("blue",  _P_DOC,  "Total Press Releases", f"{len(filtered):,}", f"since {date_from.strftime(chr(37)+chr(100)+chr(32)+chr(37)+chr(98)+chr(32)+chr(37)+chr(89))}")}'
    f'{kpi_card("green", _P_MIC,  "Podcast Coverage",     f"{coverage_pct:.1f}%", f"{prs_with_pod} of {len(filtered)} PRs", "#16a34a")}'
    f'{kpi_card("amber", _P_CAL,  "This Month",           f"{prs_month:,}", f"{d_sign}{month_delta} vs last month", "#d97706")}'
    f'{kpi_card("purple",_P_WAVE, "This Week",            f"{prs_week:,}", f"since {week_start.strftime(chr(37)+chr(100)+chr(32)+chr(37)+chr(98))}", "#7c3aed")}'
    f'{kpi_card("rose",  _P_CLK,  "Today",               f"{prs_today:,}", today.strftime("%d %B %Y"), "#e11d48")}'
    f'
',
    unsafe_allow_html=True,
)
st.markdown("
", unsafe_allow_html=True)
st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# ROI — NotebookLM
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_COIN, "NotebookLM ROI — Since 23 May 2025", "#16a34a")

months_active = len(df_roi)
st.markdown(
    f'
'
    f'
'
    f'
{_svg(_P_CLK, 13, "#e11d48")}Traditional Cost
'
    f'
AED {total_old_cost:,.0f}
'
    f'
{total_prs_nlm} PRs × AED {COST_PER_PODCAST_OLD_AED:,}/podcast
'
    f'
'
    f'
'
    f'
{_svg(_P_MIC, 13, "#16a34a")}NotebookLM Cost
'
    f'
AED {total_new_cost:,.0f}
'
    f'
{months_active} months × AED {NOTEBOOKLM_AED:.0f}/mo (${NOTEBOOKLM_MONTHLY_USD})
'
    f'
'
    f'
'
    f'
{_svg(_P_COIN, 13, "#d97706")}Total Savings
'
    f'
AED {total_savings:,.0f}
'
    f'
Since {NOTEBOOKLM_START_DATE}
'
    f'
'
    f'
'
    f'
{_svg(_P_BAR, 13, "#0077b6")}Return on Investment
'
    f'
{roi_pct:.1f}%
'
    f'
Savings ÷ traditional cost
'
    f'
'
    f'
',
    unsafe_allow_html=True,
)

# ── Charts + table ────────────────────────────────────────────────────────────
if not df_roi.empty:
    chart_col, table_col = st.columns([3, 2], gap="medium")

    PLOT_LAYOUT = dict(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", font_size=12,
        legend_title_text="",
        margin=dict(t=40, b=10, l=10, r=10),
        xaxis=dict(showgrid=False, title="", tickfont=dict(size=11)),
        yaxis=dict(gridcolor="#f1f5f9", title="AED", tickfont=dict(size=11)),
    )

    with chart_col:
        df_cl = df_roi[["month","old_cost","new_cost"]].melt(
            id_vars="month", var_name="Type", value_name="AED"
        )
        df_cl["Type"] = df_cl["Type"].map({
            "old_cost": "Traditional (AED 20k/podcast)",
            "new_cost": "NotebookLM ($20/month)"
        })
        fig1 = px.bar(df_cl, x="month", y="AED", color="Type", barmode="group",
            color_discrete_map={
                "Traditional (AED 20k/podcast)": "#fca5a5",
                "NotebookLM ($20/month)":         "#86efac",
            },
            title="Monthly Cost: Traditional vs NotebookLM",
        )
        fig1.update_layout(**PLOT_LAYOUT, height=300)
        st.plotly_chart(fig1, use_container_width=True)

        fig2 = px.bar(df_roi, x="month", y="pr_count",
            color_discrete_sequence=["#93c5fd"],
            title="Press Releases with Podcasts per Month",
        )
        fig2.update_layout(**{**PLOT_LAYOUT, "yaxis": dict(gridcolor="#f1f5f9", title="PRs", tickfont=dict(size=11))}, height=260)
        st.plotly_chart(fig2, use_container_width=True)

    with table_col:
        st.markdown(
            '
'
            'AED 20,000 per PR podcast production (EN + AR = one production unit). '
            'NotebookLM billed as flat $20/month.
',
            unsafe_allow_html=True,
        )
        rows_html = ""
        for _, r in df_roi.iterrows():
            rows_html += (
                f'
'
                f'{r["month"]}	'
                f'{int(r["pr_count"])}	'
                f'{int(r["en_episodes"])}	'
                f'{int(r["ar_episodes"])}	'
                f'AED {r["old_cost"]:,.0f}	'
                f'AED {r["new_cost"]:,.0f}	'
                f'AED {r["savings"]:,.0f}	'
                f'
'
            )
        rows_html += (
            f'
'
            f'TOTAL	'
            f'{total_prs_nlm}	'
            f'{int(df_roi["en_episodes"].sum())}	'
            f'{int(df_roi["ar_episodes"].sum())}	'
            f'AED {total_old_cost:,.0f}	'
            f'AED {total_new_cost:,.0f}	'
            f'AED {total_savings:,.0f}	'
            f'
'
        )
        st.markdown(
            f'
'
            f'

'
            f'Month	PRs	EN	AR	'
            f'Old Cost	New Cost	Savings	'
            f'

{rows_html}

'
            f'
',
            unsafe_allow_html=True,
        )

st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# PUBLICATION TRENDS
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_BAR, "Publication Trends", "#7c3aed")
tab_daily, tab_weekly, tab_monthly = st.tabs(["Daily", "Weekly", "Monthly"])

TREND_LAYOUT = dict(
    paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
    font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
    xaxis=dict(showgrid=False, title=""),
    yaxis=dict(gridcolor="#f1f5f9"),
)

def trend_bar(df_g, x, y, title, color):
    fig = px.bar(df_g, x=x, y=y, title=title, color_discrete_sequence=[color])
    fig.update_layout(**TREND_LAYOUT)
    return fig

with tab_daily:
    if not filtered.empty and filtered["date"].notna().any():
        daily = filtered.groupby(filtered["date"].dt.date).size().reset_index(name="count")
        daily.columns = ["date","count"]
        st.plotly_chart(trend_bar(daily,"date","count","Press Releases per Day","#93c5fd"), use_container_width=True)
    else:
        st.info("No data yet.")

with tab_weekly:
    if not filtered.empty and "week" in filtered.columns and filtered["date"].notna().any():
        weekly = filtered.groupby("week").size().reset_index(name="count")
        st.plotly_chart(trend_bar(weekly,"week","count","Press Releases per Week","#a5b4fc"), use_container_width=True)
    else:
        st.info("No data yet.")

with tab_monthly:
    if not filtered.empty and "month" in filtered.columns and filtered["date"].notna().any():
        monthly = filtered.groupby("month").size().reset_index(name="count")
        st.plotly_chart(trend_bar(monthly,"month","count","Press Releases per Month","#c4b5fd"), use_container_width=True)
    else:
        st.info("No data yet.")

st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# PRESS RELEASES TABLE
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_LIST, "Press Releases", "#0077b6")

search = st.text_input("Search", placeholder="Filter press releases by title…", label_visibility="collapsed")
display = (
    filtered[filtered["title"].str.contains(search, case=False, na=False)]
    if search else filtered.copy()
)

total_pages = max(1, math.ceil(len(display) / PAGE_SIZE))
page_num    = st.number_input("Page", min_value=1, max_value=total_pages, value=1, step=1)
start, end  = (page_num-1)*PAGE_SIZE, page_num*PAGE_SIZE
page_data   = display.iloc[start:end]

st.caption(f"Showing {start+1}–{min(end,len(display))} of {len(display)} results  (page {page_num}/{total_pages})")

for _, row in page_data.iterrows():
    url   = row.get("url","")
    title = row.get("title","—")
    d     = row.get("date")
    date_str = d.strftime("%d %b %Y") if pd.notna(d) else "—"
    desc  = str(row.get("description") or "")
    desc_short = (desc[:150]+"…") if len(desc)>150 else desc
    pod_badge = (
        f'{_svg(_P_MIC,11,"#c2410c")} Podcast'
        if row.get("podcast_count",0) else ""
    )
    st.markdown(
        f'
'
        f'
'
        f'{title}'
        f'
'
        f'{date_str}{pod_badge}'
        f'

'
        f'{"
" + desc_short + "
" if desc_short else ""}'
        f'
',
        unsafe_allow_html=True,
    )

# ═══════════════════════════════════════════════════════════════════════════════
# PODCASTS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
section_title(_P_MIC, "Podcasts", "#d97706")

if not df_pod.empty:
    lang_filter = st.radio("Language", ["All","English","Arabic"], horizontal=True)
    df_show = df_pod.copy()
    if lang_filter == "English":
        df_show = df_show[df_show["language"]=="en"]
    elif lang_filter == "Arabic":
        df_show = df_show[df_show["language"]=="ar"]

    pod_q = st.text_input("Search podcasts", placeholder="Filter by title…", label_visibility="collapsed")
    if pod_q:
        df_show = df_show[df_show["title"].str.contains(pod_q, case=False, na=False)]

    st.caption(f"Showing {len(df_show)} of {len(df_pod)} episodes")

    for _, pod in df_show.head(50).iterrows():
        with st.expander(f"{pod.get('title','—')}  ·  {pod.get('date','')}"):
            ca, cb = st.columns([3,1])
            with ca:
                if pod.get("pr_title"):
                    st.markdown(f"**Linked PR:** {pod['pr_title']}")
                if pod.get("url"):
                    st.markdown(f"[Open on RSS.com]({pod['url']})")
                if pod.get("audio_url"):
                    st.audio(pod["audio_url"])
            with cb:
                if pod.get("duration"):
                    st.metric("Duration", pod["duration"])
                st.metric("Saved", f"AED {COST_PER_PODCAST_OLD_AED:,}")

# ═══════════════════════════════════════════════════════════════════════════════
# VISITOR ANALYTICS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
_P_EYE   = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
_P_USERS = '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
section_title(_P_EYE, "Dashboard Views", "#7c3aed")

vstats = db.get_visitor_stats()
v1, v2, v3 = st.columns(3)
with v1:
    st.markdown(
        f'
'
        f'
{_svg(_P_USERS,13,"#7c3aed")}Total Sessions
'
        f'
{vstats["total"]:,}
'
        f'
unique visitors since launch
'
        f'
', unsafe_allow_html=True,
    )
with v2:
    st.markdown(
        f'
'
        f'
{_svg(_P_EYE,13,"#0077b6")}Today
'
        f'
{vstats["today"]:,}
'
        f'
{today.strftime("%d %B %Y")}
'
        f'
', unsafe_allow_html=True,
    )
with v3:
    last_visit = vstats["recent"][0]["visited_at"][:16].replace("T", " ") if vstats["recent"] else "—"
    st.markdown(
        f'
'
        f'
{_svg(_P_CLK,13,"#16a34a")}Last Visit
'
        f'
{last_visit}
'
        f'
UTC time
'
        f'
', unsafe_allow_html=True,
    )

st.markdown("
", unsafe_allow_html=True)

if vstats["by_day"]:
    df_visits = pd.DataFrame([dict(r) for r in vstats["by_day"]])
    df_visits.columns = ["date", "visits"]
    df_visits = df_visits.sort_values("date")
    fig_v = px.bar(df_visits, x="date", y="visits",
        title="Daily Views (last 30 days)",
        color_discrete_sequence=["#a78bfa"],
    )
    fig_v.update_layout(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
        xaxis=dict(showgrid=False, title=""),
        yaxis=dict(gridcolor="#f1f5f9", title="Sessions"),
        height=220,
    )
    st.plotly_chart(fig_v, use_container_width=True)

with st.expander("Recent visits"):
    if vstats["recent"]:
        df_rec = pd.DataFrame([dict(r) for r in vstats["recent"]])
        df_rec.columns = ["Visited At (UTC)", "User Agent"]
        df_rec["Visited At (UTC)"] = df_rec["Visited At (UTC)"].str[:19].str.replace("T"," ")
        st.dataframe(df_rec, use_container_width=True, hide_index=True)
    else:
        st.info("No visits recorded yet.")

# ═══════════════════════════════════════════════════════════════════════════════
# SCRAPE LOG
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
with st.expander("Scrape history"):
    if scrape_log:
        st.dataframe(
            pd.DataFrame(scrape_log)[["ran_at","prs_found","pods_found","status","notes"]],
            use_container_width=True,
        )
    else:
        st.info("No scrape runs recorded yet.")

st.markdown(
    '
'
    'DEWA PR Tracker · Playwright · SQLite · Streamlit
',
    unsafe_allow_html=True,
)

-# ── Visitor tracking — log each new browser session once ─────────────────────

-if "visit_logged" not in st.session_state:

-    st.session_state["visit_logged"] = True


# ═══════════════════════════════════════════════════════════════════════════════
# VISITOR ANALYTICS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
_P_EYE   = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
_P_USERS = '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
section_title(_P_EYE, "Dashboard Views", "#7c3aed")

vstats = db.get_visitor_stats()
v1, v2, v3 = st.columns(3)
with v1:
    st.markdown(
        f'
'
        f'
{_svg(_P_USERS,13,"#7c3aed")}Total Sessions
'
        f'
{vstats["total"]:,}
'
        f'
unique visitors since launch
'
        f'
', unsafe_allow_html=True,
    )
with v2:
    st.markdown(
        f'
'
        f'
{_svg(_P_EYE,13,"#0077b6")}Today
'
        f'
{vstats["today"]:,}
'
        f'
{today.strftime("%d %B %Y")}
'
        f'
', unsafe_allow_html=True,
    )
with v3:
    last_visit = vstats["recent"][0]["visited_at"][:16].replace("T", " ") if vstats["recent"] else "—"
    st.markdown(
        f'
'
        f'
{_svg(_P_CLK,13,"#16a34a")}Last Visit
'
        f'
{last_visit}
'
        f'
UTC time
'
        f'
', unsafe_allow_html=True,
    )

st.markdown("
", unsafe_allow_html=True)

if vstats["by_day"]:
    df_visits = pd.DataFrame([dict(r) for r in vstats["by_day"]])
    df_visits.columns = ["date", "visits"]
    df_visits = df_visits.sort_values("date")
    fig_v = px.bar(df_visits, x="date", y="visits",
        title="Daily Views (last 30 days)",
        color_discrete_sequence=["#a78bfa"],
    )
    fig_v.update_layout(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
        xaxis=dict(showgrid=False, title=""),
        yaxis=dict(gridcolor="#f1f5f9", title="Sessions"),
        height=220,
    )
    st.plotly_chart(fig_v, use_container_width=True)

with st.expander("Recent visits"):
    if vstats["recent"]:
        df_rec = pd.DataFrame([dict(r) for r in vstats["recent"]])
        df_rec.columns = ["Visited At (UTC)", "User Agent"]
        df_rec["Visited At (UTC)"] = df_rec["Visited At (UTC)"].str[:19].str.replace("T"," ")
        st.dataframe(df_rec, use_container_width=True, hide_index=True)
    else:
        st.info("No visits recorded yet.")

# ═══════════════════════════════════════════════════════════════════════════════
# SCRAPE LOG
# ═══════════════════════════════════════════════════════════════════════════════

# ═══════════════════════════════════════════════════════════════════════════════
# SCRAPE LOG
# ═══════════════════════════════════════════════════════════════════════════════


"""
DEWA PR Tracker — Streamlit Dashboard
Run:  streamlit run dashboard.py
"""
import math
import re as _re
from datetime import datetime, timedelta

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st

import database as db
from config import (
    COST_PER_PODCAST_OLD_AED,
    NOTEBOOKLM_MONTHLY_USD,
    NOTEBOOKLM_START_DATE,
    USD_TO_AED,
    DASHBOARD_TITLE,
    PAGE_SIZE,
)

# ── Page setup ────────────────────────────────────────────────────────────────
st.set_page_config(
    page_title=DASHBOARD_TITLE,
    page_icon="📰",
    layout="wide",
    initial_sidebar_state="collapsed",   # collapsed by default on mobile
)

# ── SVG helpers ───────────────────────────────────────────────────────────────
def _svg(paths, size=16, color="#0077b6"):
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" '
        f'viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" '
        f'stroke-linecap="round" stroke-linejoin="round" '
        f'style="vertical-align:middle;margin-right:6px;flex-shrink:0">{paths}</svg>'
    )

# Icon paths
_P_DOC  = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>'
_P_MIC  = '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>'
_P_COIN = '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>'
_P_BAR  = '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>'
_P_LIST = '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>'
_P_CAL  = '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>'
_P_LINK = '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'
_P_CLK  = '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'
_P_WAVE = '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'
_P_SYNC = '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'

def section_title(icon_path, text, icon_color="#0077b6"):
    st.markdown(
        f'
'
        f'{_svg(icon_path, size=18, color=icon_color)}{text}
',
        unsafe_allow_html=True,
    )

# ── CSS — light theme + mobile ────────────────────────────────────────────────
st.markdown("""

""", unsafe_allow_html=True)

# ── Data loading (cached 5 min) ───────────────────────────────────────────────
@st.cache_data(ttl=300)
def load_data():
    db.init_db()
    prs      = [dict(r) for r in db.get_all_press_releases()]
    pods     = [dict(r) for r in db.get_all_podcasts()]
    roi_rows = [dict(r) for r in db.get_roi_monthly_since(NOTEBOOKLM_START_DATE)]
    stats    = db.get_stats()
    log      = [dict(r) for r in db.get_scrape_log()]
    return prs, pods, roi_rows, stats, log

prs, pods, roi_rows, stats, scrape_log = load_data()
df_pr  = pd.DataFrame(prs)
df_pod = pd.DataFrame(pods)

if not df_pod.empty and "title" in df_pod.columns:
    df_pod["language"] = df_pod["title"].apply(
        lambda t: "ar" if _re.search(r"[\u0600-\u06FF]", str(t)) else "en"
    )
if not df_pr.empty:
    df_pr["date"]  = pd.to_datetime(df_pr["date"], errors="coerce")
    df_pr["week"]  = df_pr["date"].dt.to_period("W").astype(str)
    df_pr["month"] = df_pr["date"].dt.to_period("M").astype(str)
if not df_pod.empty:
    df_pod["date"] = pd.to_datetime(df_pod["date"], errors="coerce")

# ── ROI pre-calculations ──────────────────────────────────────────────────────
NOTEBOOKLM_AED = NOTEBOOKLM_MONTHLY_USD * USD_TO_AED
NLM_START      = pd.Timestamp(NOTEBOOKLM_START_DATE)

df_roi = pd.DataFrame(roi_rows) if roi_rows else pd.DataFrame(
    columns=["month","pr_count","en_episodes","ar_episodes","total_episodes"]
)
if not df_roi.empty:
    df_roi["old_cost"] = df_roi["pr_count"] * COST_PER_PODCAST_OLD_AED
    df_roi["new_cost"] = NOTEBOOKLM_AED
    df_roi["savings"]  = df_roi["old_cost"] - df_roi["new_cost"]
    total_prs_nlm  = int(df_roi["pr_count"].sum())
    total_old_cost = float(df_roi["old_cost"].sum())
    total_new_cost = float(df_roi["new_cost"].sum())
    total_savings  = float(df_roi["savings"].sum())
    roi_pct        = (total_savings / total_old_cost * 100) if total_old_cost > 0 else 0
else:
    total_prs_nlm = total_old_cost = total_new_cost = total_savings = roi_pct = 0

# ── Sidebar ───────────────────────────────────────────────────────────────────
with st.sidebar:
    st.markdown(
        '
DEWA
'
        '
Dubai Electricity & Water Authority
',
        unsafe_allow_html=True,
    )
    st.divider()

    if not df_pr.empty and df_pr["date"].notna().any():
        min_date = df_pr["date"].min().date()
        max_date = df_pr["date"].max().date()
    else:
        min_date = datetime(2019, 1, 1).date()
        max_date = datetime.today().date()

    date_from, date_to = st.date_input(
        "Date range",
        value=(NLM_START.date(), max_date),
        min_value=min_date, max_value=max_date,
    )
    categories = ["All"]
    if not df_pr.empty and "category" in df_pr.columns:
        cats = df_pr["category"].dropna().unique().tolist()
        categories += sorted(cats)
    selected_cat = st.selectbox("Category", categories)
    has_podcast_filter = st.checkbox("Show only PRs with podcast", value=False)

    st.divider()
    if st.button("Refresh data", use_container_width=True):
        st.cache_data.clear()
        st.rerun()
    if st.button("Run scraper now", use_container_width=True):
        with st.spinner("Scraping DEWA website…"):
            try:
                from scraper import run_scrape
                prs_n, pods_n = run_scrape()
                st.success(f"Done — {prs_n} PRs, {pods_n} podcasts saved.")
                st.cache_data.clear()
                st.rerun()
            except Exception as e:
                st.error(f"Scraper error: {e}")
    st.divider()

    last_scraped = stats.get('last_scraped','Never') or 'Never'
    st.caption(f"Last scraped: {last_scraped}")

    # ── Live countdown to next 08:00 Dubai time ───────────────────────────────
    st.markdown("""

    Next auto-refresh

    <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
      fill="none" stroke="#0284c7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
    </svg>
    --:--:--

Daily at 08:00 Dubai time

""", unsafe_allow_html=True)

# ── Apply filters ─────────────────────────────────────────────────────────────
filtered = df_pr.copy()
if not filtered.empty:
    if filtered["date"].notna().any():
        filtered = filtered[
            (filtered["date"].dt.date >= date_from) &
            (filtered["date"].dt.date <= date_to)
        ]
    if selected_cat != "All":
        filtered = filtered[filtered["category"] == selected_cat]
    if has_podcast_filter:
        filtered = filtered[filtered["podcast_count"] > 0]

# ═══════════════════════════════════════════════════════════════════════════════
# HEADER
# ═══════════════════════════════════════════════════════════════════════════════
st.markdown(
    f'
'
    f'{_svg(_P_DOC, size=30, color="#0077b6")}'
    f'
{DASHBOARD_TITLE}
'
    f'
'
    f'
'
    f'NotebookLM adoption since {NOTEBOOKLM_START_DATE}  ·  '
    f'{datetime.today().strftime("%d %B %Y")}
',
    unsafe_allow_html=True,
)
st.markdown("
", unsafe_allow_html=True)

# ═══════════════════════════════════════════════════════════════════════════════
# KPI CARDS
# ═══════════════════════════════════════════════════════════════════════════════
today       = datetime.today().date()
week_start  = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
last_month  = (month_start - timedelta(days=1)).replace(day=1)

if not filtered.empty and filtered["date"].notna().any():
    prs_today      = len(filtered[filtered["date"].dt.date == today])
    prs_week       = len(filtered[filtered["date"].dt.date >= week_start])
    prs_month      = len(filtered[filtered["date"].dt.date >= month_start])
    prs_last_month = len(filtered[
        (filtered["date"].dt.date >= last_month) &
        (filtered["date"].dt.date <  month_start)
    ])
else:
    prs_today = prs_week = prs_month = prs_last_month = 0

prs_with_pod = len(filtered[filtered["podcast_count"] > 0]) if not filtered.empty else 0
coverage_pct = (prs_with_pod / len(filtered) * 100) if len(filtered) > 0 else 0
month_delta  = prs_month - prs_last_month
d_cls        = "kpi-delta-pos" if month_delta >= 0 else "kpi-delta-neg"
d_sign       = "+" if month_delta >= 0 else ""

def kpi_card(color, icon_path, label, value, sub="", icon_color=None):
    clr = icon_color or {"blue":"#0077b6","green":"#16a34a","amber":"#d97706","purple":"#7c3aed","rose":"#e11d48"}[color]
    ico = _svg(icon_path, size=13, color=clr)
    return (
        f'
'
        f'
{ico}{label}
'
        f'
{value}
'
        f'{"
" + sub + "
" if sub else ""}'
        f'
'
    )

st.markdown(
    f'
'
    f'{kpi_card("blue",  _P_DOC,  "Total Press Releases", f"{len(filtered):,}", f"since {date_from.strftime(chr(37)+chr(100)+chr(32)+chr(37)+chr(98)+chr(32)+chr(37)+chr(89))}")}'
    f'{kpi_card("green", _P_MIC,  "Podcast Coverage",     f"{coverage_pct:.1f}%", f"{prs_with_pod} of {len(filtered)} PRs", "#16a34a")}'
    f'{kpi_card("amber", _P_CAL,  "This Month",           f"{prs_month:,}", f"{d_sign}{month_delta} vs last month", "#d97706")}'
    f'{kpi_card("purple",_P_WAVE, "This Week",            f"{prs_week:,}", f"since {week_start.strftime(chr(37)+chr(100)+chr(32)+chr(37)+chr(98))}", "#7c3aed")}'
    f'{kpi_card("rose",  _P_CLK,  "Today",               f"{prs_today:,}", today.strftime("%d %B %Y"), "#e11d48")}'
    f'
',
    unsafe_allow_html=True,
)
st.markdown("
", unsafe_allow_html=True)
st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# ROI — NotebookLM
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_COIN, "NotebookLM ROI — Since 23 May 2025", "#16a34a")

months_active = len(df_roi)
st.markdown(
    f'
'
    f'
'
    f'
{_svg(_P_CLK, 13, "#e11d48")}Traditional Cost
'
    f'
AED {total_old_cost:,.0f}
'
    f'
{total_prs_nlm} PRs × AED {COST_PER_PODCAST_OLD_AED:,}/podcast
'
    f'
'
    f'
'
    f'
{_svg(_P_MIC, 13, "#16a34a")}NotebookLM Cost
'
    f'
AED {total_new_cost:,.0f}
'
    f'
{months_active} months × AED {NOTEBOOKLM_AED:.0f}/mo (${NOTEBOOKLM_MONTHLY_USD})
'
    f'
'
    f'
'
    f'
{_svg(_P_COIN, 13, "#d97706")}Total Savings
'
    f'
AED {total_savings:,.0f}
'
    f'
Since {NOTEBOOKLM_START_DATE}
'
    f'
'
    f'
'
    f'
{_svg(_P_BAR, 13, "#0077b6")}Return on Investment
'
    f'
{roi_pct:.1f}%
'
    f'
Savings ÷ traditional cost
'
    f'
'
    f'
',
    unsafe_allow_html=True,
)

# ── Charts + table ────────────────────────────────────────────────────────────
if not df_roi.empty:
    chart_col, table_col = st.columns([3, 2], gap="medium")

    PLOT_LAYOUT = dict(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", font_size=12,
        legend_title_text="",
        margin=dict(t=40, b=10, l=10, r=10),
        xaxis=dict(showgrid=False, title="", tickfont=dict(size=11)),
        yaxis=dict(gridcolor="#f1f5f9", title="AED", tickfont=dict(size=11)),
    )

    with chart_col:
        df_cl = df_roi[["month","old_cost","new_cost"]].melt(
            id_vars="month", var_name="Type", value_name="AED"
        )
        df_cl["Type"] = df_cl["Type"].map({
            "old_cost": "Traditional (AED 20k/podcast)",
            "new_cost": "NotebookLM ($20/month)"
        })
        fig1 = px.bar(df_cl, x="month", y="AED", color="Type", barmode="group",
            color_discrete_map={
                "Traditional (AED 20k/podcast)": "#fca5a5",
                "NotebookLM ($20/month)":         "#86efac",
            },
            title="Monthly Cost: Traditional vs NotebookLM",
        )
        fig1.update_layout(**PLOT_LAYOUT, height=300)
        st.plotly_chart(fig1, use_container_width=True)

        fig2 = px.bar(df_roi, x="month", y="pr_count",
            color_discrete_sequence=["#93c5fd"],
            title="Press Releases with Podcasts per Month",
        )
        fig2.update_layout(**{**PLOT_LAYOUT, "yaxis": dict(gridcolor="#f1f5f9", title="PRs", tickfont=dict(size=11))}, height=260)
        st.plotly_chart(fig2, use_container_width=True)

    with table_col:
        st.markdown(
            '
'
            'AED 20,000 per PR podcast production (EN + AR = one production unit). '
            'NotebookLM billed as flat $20/month.
',
            unsafe_allow_html=True,
        )
        rows_html = ""
        for _, r in df_roi.iterrows():
            rows_html += (
                f'
'
                f'{r["month"]}	'
                f'{int(r["pr_count"])}	'
                f'{int(r["en_episodes"])}	'
                f'{int(r["ar_episodes"])}	'
                f'AED {r["old_cost"]:,.0f}	'
                f'AED {r["new_cost"]:,.0f}	'
                f'AED {r["savings"]:,.0f}	'
                f'
'
            )
        rows_html += (
            f'
'
            f'TOTAL	'
            f'{total_prs_nlm}	'
            f'{int(df_roi["en_episodes"].sum())}	'
            f'{int(df_roi["ar_episodes"].sum())}	'
            f'AED {total_old_cost:,.0f}	'
            f'AED {total_new_cost:,.0f}	'
            f'AED {total_savings:,.0f}	'
            f'
'
        )
        st.markdown(
            f'
'
            f'

'
            f'Month	PRs	EN	AR	'
            f'Old Cost	New Cost	Savings	'
            f'

{rows_html}

'
            f'
',
            unsafe_allow_html=True,
        )

st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# PUBLICATION TRENDS
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_BAR, "Publication Trends", "#7c3aed")
tab_daily, tab_weekly, tab_monthly = st.tabs(["Daily", "Weekly", "Monthly"])

TREND_LAYOUT = dict(
    paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
    font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
    xaxis=dict(showgrid=False, title=""),
    yaxis=dict(gridcolor="#f1f5f9"),
)

def trend_bar(df_g, x, y, title, color):
    fig = px.bar(df_g, x=x, y=y, title=title, color_discrete_sequence=[color])
    fig.update_layout(**TREND_LAYOUT)
    return fig

with tab_daily:
    if not filtered.empty and filtered["date"].notna().any():
        daily = filtered.groupby(filtered["date"].dt.date).size().reset_index(name="count")
        daily.columns = ["date","count"]
        st.plotly_chart(trend_bar(daily,"date","count","Press Releases per Day","#93c5fd"), use_container_width=True)
    else:
        st.info("No data yet.")

with tab_weekly:
    if not filtered.empty and "week" in filtered.columns and filtered["date"].notna().any():
        weekly = filtered.groupby("week").size().reset_index(name="count")
        st.plotly_chart(trend_bar(weekly,"week","count","Press Releases per Week","#a5b4fc"), use_container_width=True)
    else:
        st.info("No data yet.")

with tab_monthly:
    if not filtered.empty and "month" in filtered.columns and filtered["date"].notna().any():
        monthly = filtered.groupby("month").size().reset_index(name="count")
        st.plotly_chart(trend_bar(monthly,"month","count","Press Releases per Month","#c4b5fd"), use_container_width=True)
    else:
        st.info("No data yet.")

st.divider()

# ═══════════════════════════════════════════════════════════════════════════════
# PRESS RELEASES TABLE
# ═══════════════════════════════════════════════════════════════════════════════
section_title(_P_LIST, "Press Releases", "#0077b6")

search = st.text_input("Search", placeholder="Filter press releases by title…", label_visibility="collapsed")
display = (
    filtered[filtered["title"].str.contains(search, case=False, na=False)]
    if search else filtered.copy()
)

total_pages = max(1, math.ceil(len(display) / PAGE_SIZE))
page_num    = st.number_input("Page", min_value=1, max_value=total_pages, value=1, step=1)
start, end  = (page_num-1)*PAGE_SIZE, page_num*PAGE_SIZE
page_data   = display.iloc[start:end]

st.caption(f"Showing {start+1}–{min(end,len(display))} of {len(display)} results  (page {page_num}/{total_pages})")

for _, row in page_data.iterrows():
    url   = row.get("url","")
    title = row.get("title","—")
    d     = row.get("date")
    date_str = d.strftime("%d %b %Y") if pd.notna(d) else "—"
    desc  = str(row.get("description") or "")
    desc_short = (desc[:150]+"…") if len(desc)>150 else desc
    pod_badge = (
        f'{_svg(_P_MIC,11,"#c2410c")} Podcast'
        if row.get("podcast_count",0) else ""
    )
    st.markdown(
        f'
'
        f'
'
        f'{title}'
        f'
'
        f'{date_str}{pod_badge}'
        f'

'
        f'{"
" + desc_short + "
" if desc_short else ""}'
        f'
',
        unsafe_allow_html=True,
    )

# ═══════════════════════════════════════════════════════════════════════════════
# PODCASTS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
section_title(_P_MIC, "Podcasts", "#d97706")

if not df_pod.empty:
    lang_filter = st.radio("Language", ["All","English","Arabic"], horizontal=True)
    df_show = df_pod.copy()
    if lang_filter == "English":
        df_show = df_show[df_show["language"]=="en"]
    elif lang_filter == "Arabic":
        df_show = df_show[df_show["language"]=="ar"]

    pod_q = st.text_input("Search podcasts", placeholder="Filter by title…", label_visibility="collapsed")
    if pod_q:
        df_show = df_show[df_show["title"].str.contains(pod_q, case=False, na=False)]

    st.caption(f"Showing {len(df_show)} of {len(df_pod)} episodes")

    for _, pod in df_show.head(50).iterrows():
        with st.expander(f"{pod.get('title','—')}  ·  {pod.get('date','')}"):
            ca, cb = st.columns([3,1])
            with ca:
                if pod.get("pr_title"):
                    st.markdown(f"**Linked PR:** {pod['pr_title']}")
                if pod.get("url"):
                    st.markdown(f"[Open on RSS.com]({pod['url']})")
                if pod.get("audio_url"):
                    st.audio(pod["audio_url"])
            with cb:
                if pod.get("duration"):
                    st.metric("Duration", pod["duration"])
                st.metric("Saved", f"AED {COST_PER_PODCAST_OLD_AED:,}")

# ═══════════════════════════════════════════════════════════════════════════════
# VISITOR ANALYTICS
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
_P_EYE   = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
_P_USERS = '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
section_title(_P_EYE, "Dashboard Views", "#7c3aed")

vstats = db.get_visitor_stats()
v1, v2, v3 = st.columns(3)
with v1:
    st.markdown(
        f'
'
        f'
{_svg(_P_USERS,13,"#7c3aed")}Total Sessions
'
        f'
{vstats["total"]:,}
'
        f'
unique visitors since launch
'
        f'
', unsafe_allow_html=True,
    )
with v2:
    st.markdown(
        f'
'
        f'
{_svg(_P_EYE,13,"#0077b6")}Today
'
        f'
{vstats["today"]:,}
'
        f'
{today.strftime("%d %B %Y")}
'
        f'
', unsafe_allow_html=True,
    )
with v3:
    last_visit = vstats["recent"][0]["visited_at"][:16].replace("T", " ") if vstats["recent"] else "—"
    st.markdown(
        f'
'
        f'
{_svg(_P_CLK,13,"#16a34a")}Last Visit
'
        f'
{last_visit}
'
        f'
UTC time
'
        f'
', unsafe_allow_html=True,
    )

st.markdown("
", unsafe_allow_html=True)

if vstats["by_day"]:
    df_visits = pd.DataFrame([dict(r) for r in vstats["by_day"]])
    df_visits.columns = ["date", "visits"]
    df_visits = df_visits.sort_values("date")
    fig_v = px.bar(df_visits, x="date", y="visits",
        title="Daily Views (last 30 days)",
        color_discrete_sequence=["#a78bfa"],
    )
    fig_v.update_layout(
        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
        font_color="#0f172a", margin=dict(t=36,b=10,l=10,r=10),
        xaxis=dict(showgrid=False, title=""),
        yaxis=dict(gridcolor="#f1f5f9", title="Sessions"),
        height=220,
    )
    st.plotly_chart(fig_v, use_container_width=True)

with st.expander("Recent visits"):
    if vstats["recent"]:
        df_rec = pd.DataFrame([dict(r) for r in vstats["recent"]])
        df_rec.columns = ["Visited At (UTC)", "User Agent"]
        df_rec["Visited At (UTC)"] = df_rec["Visited At (UTC)"].str[:19].str.replace("T"," ")
        st.dataframe(df_rec, use_container_width=True, hide_index=True)
    else:
        st.info("No visits recorded yet.")

# ═══════════════════════════════════════════════════════════════════════════════
# SCRAPE LOG
# ═══════════════════════════════════════════════════════════════════════════════
st.divider()
with st.expander("Scrape history"):
    if scrape_log:
        st.dataframe(
            pd.DataFrame(scrape_log)[["ran_at","prs_found","pods_found","status","notes"]],
            use_container_width=True,
        )
    else:
        st.info("No scrape runs recorded yet.")

st.markdown(
    '
'
    'DEWA PR Tracker · Playwright · SQLite · Streamlit
',
    unsafe_allow_html=True,
)

-section_title(_P_EYE, "Dashboard Views", "#7c3aed")

-        f'<div class="kpi-card purple" style="height:auto;padding:16px 18px">'

-        f'
{_svg(_P_USERS,13,"#7c3aed")}Total Sessions
'

-        f'
unique visitors since launch
'

-        f'</div>', unsafe_allow_html=True,

-        f'<div class="kpi-card blue" style="height:auto;padding:16px 18px">'

-        f'
{today.strftime("%d %B %Y")}
'

-    last_visit = vstats["recent"][0]["visited_at"][:16].replace("T", " ") if vstats["recent"] else "—"

-        f'<div class="kpi-card green" style="height:auto;padding:16px 18px">'

-    df_visits = pd.DataFrame([dict(r) for r in vstats["by_day"]])

-    df_visits.columns = ["date", "visits"]

-    df_visits = df_visits.sort_values("date")

-    fig_v = px.bar(df_visits, x="date", y="visits",

-        title="Daily Views (last 30 days)",

-        color_discrete_sequence=["#a78bfa"],

-        paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",

-        xaxis=dict(showgrid=False, title=""),

-        yaxis=dict(gridcolor="#f1f5f9", title="Sessions"),

-    st.plotly_chart(fig_v, use_container_width=True)

-        df_rec = pd.DataFrame([dict(r) for r in vstats["recent"]])

-        df_rec.columns = ["Visited At (UTC)", "User Agent"]

-        df_rec["Visited At (UTC)"] = df_rec["Visited At (UTC)"].str[:19].str.replace("T"," ")

-        st.dataframe(df_rec, use_container_width=True, hide_index=True)

-        st.info("No visits recorded yet.")


Bash

pkill -f "streamlit run dashboard.py" 2>/dev/null; sleep 1
source venv/bin/activate && streamlit run dashboard.py &
sleep 5 && curl -s -o /dev/null -w "%{http_code}" http://localhost:8501

Restart dashboard


  You can now view your Streamlit app in your browser.

  URL: http://0.0.0.0:8501

  For better performance, install the Watchdog module:

  $ xcode-select --install
  $ pip install watchdog

200


Done — Dashboard Views section is fully removed. Live at **https://khalids-mac-mini.tailbd7b77.ts.net**


I want to create a unique tailscale url for the press release things
and another one for the https://khalids-mac-mini.tailbd7b77.ts.net/course-dashboard.html because when i referesh this it replace it with the new podcast


The issue is both share the same Tailscale URL and Streamlit intercepts everything. The fix is **path-based routing** — Tailscale Serve can direct different paths to different backends, and Streamlit supports a `baseUrlPath` so it works at a subpath.

Plan:
- `https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker` → DEWA Dashboard
- `https://khalids-mac-mini.tailbd7b77.ts.net/course-dashboard.html` → Your HTML file (served by a lightweight static server)

Let me check the current setup first:


tailscale serve status 2>/dev/null
echo "---"
ls /Users/khalidbinkalban/course-dashboard.html 2>/dev/null && echo "HTML file exists"
lsof -i :8080 2>/dev/null | head -5

Check current serve config and files


# Funnel on:
#     - https://khalids-mac-mini.tailbd7b77.ts.net

https://khalids-mac-mini.tailbd7b77.ts.net (Funnel on)
|-- / proxy http://127.0.0.1:8501

---
/Users/khalidbinkalban/course-dashboard.html
HTML file exists
COMMAND   PID            USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
node    34712 khalidbinkalban   16u  IPv6 0x14048527d73cceb5      0t0  TCP localhost:62452->localhost:http-alt (ESTABLISHED)
node    34712 khalidbinkalban   18u  IPv6 0x55a82b6171fb4c63      0t0  TCP localhost:62453->localhost:http-alt (ESTABLISHED)
Python  93281 khalidbinkalban    5u  IPv6 0x4c7b8a2eed0873cb      0t0  TCP *:http-alt (LISTEN)
Python  93281 khalidbinkalban    6u  IPv6 0xcc45d83a840314e6      0t0  TCP khalids-mac-mini.tailbd7b77.ts.net:http-alt->khalids-mac-mini.tailbd7b77.ts.net:50650 (ESTABLISHED)


# Reset current funnel/serve config
tailscale funnel reset 2>/dev/null
tailscale serve reset 2>/dev/null
sleep 1
echo "Reset done"

Reset Tailscale serve config


Reset done


Now update Streamlit config to serve at `/pr-tracker`:


Edit

[server]
port = 8501
address = "0.0.0.0"
headless = true

[server]
port = 8501
address = "0.0.0.0"
headless = true
baseUrlPath = "pr-tracker"


The file /Users/khalidbinkalban/Projects/dewa-pr-tracker/.streamlit/config.toml has been updated successfully.

[server]
port = 8501
address = "0.0.0.0"
headless = true
baseUrlPath = "pr-tracker"

[theme]
base = "light"
primaryColor = "#0077b6"
backgroundColor = "#f8fafc"
secondaryBackgroundColor = "#ffffff"
textColor = "#0f172a"
font = "sans serif"

[server]
port = 8501
address = "0.0.0.0"
headless = true

[browser]
gatherUsageStats = false

port = 8501
address = "0.0.0.0"
headless = true
+baseUrlPath = "pr-tracker"
[browser]
gatherUsageStats = false


Now start a static file server on port 8090 for the HTML file, restart Streamlit, then configure path routing:


Bash

# Kill existing Streamlit
pkill -f "streamlit run dashboard.py" 2>/dev/null
sleep 1

# Static server for course-dashboard.html on port 8090
pkill -f "http.server 8090" 2>/dev/null
cd /Users/khalidbinkalban && python3 -m http.server 8090 --bind 127.0.0.1 &>/tmp/static-server.log &
sleep 1
curl -s -o /dev/null -w "Static server: %{http_code}\n" http://localhost:8090/course-dashboard.html

# Restart Streamlit with new baseUrlPath
cd /Users/khalidbinkalban/Projects/dewa-pr-tracker
source venv/bin/activate && streamlit run dashboard.py &
sleep 6
curl -s -o /dev/null -w "Streamlit: %{http_code}\n" http://localhost:8501/pr-tracker

Start both services


Static server: 200

  You can now view your Streamlit app in your browser.

  URL: http://0.0.0.0:8501/pr-tracker

  For better performance, install the Watchdog module:

  $ xcode-select --install
  $ pip install watchdog

Streamlit: 301


Both running. Now configure Tailscale path routing and re-enable Funnel:


# Route /pr-tracker/* → Streamlit
tailscale serve --https=443 /pr-tracker proxy http://localhost:8501

# Route /course-dashboard.html → static server
tailscale serve --https=443 /course-dashboard.html proxy http://localhost:8090/course-dashboard.html

# Enable public Funnel for both paths
tailscale funnel --https=443 on

sleep 1
tailscale serve status

Configure path-based routing in Tailscale


Error: invalid number of arguments (3)
2026/04/04 10:34:43 try `tailscale serve --help` for usage info
Error: invalid number of arguments (3)
2026/04/04 10:34:43 try `tailscale serve --help` for usage info
error: failed apply web serve: non-localhost target "http://on" must include a scheme

2026/04/04 10:34:43 try `tailscale funnel --help` for usage info
No serve config


Check Tailscale serve syntax


Serve content and local servers on your tailnet

USAGE
  tailscale serve <target>
  tailscale serve status [--json]
  tailscale serve reset

Tailscale Serve enables you to share a local server securely within your tailnet.

To share a local server on the internet, use `tailscale funnel`

<target> can be a file, directory, text, or most commonly the location to a service running on the
local machine. The location to the location service can be expressed as a port number (e.g., 3000),
a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http://localhost:3000/foo).
On Unix-like systems, you can also specify a Unix domain socket (e.g., unix:/tmp/myservice.sock).

EXAMPLES
  - Expose an HTTP server running at 127.0.0.1:3000 in the foreground:
    $ tailscale serve 3000

  - Expose an HTTP server running at 127.0.0.1:3000 in the background:
    $ tailscale serve --bg 3000

  - Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443
    $ tailscale serve https+insecure://localhost:8443

  - Expose a service listening on a Unix socket (Linux/macOS/BSD only):
    $ tailscale serve unix:/var/run/myservice.sock

For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases

SUBCOMMANDS
  status      View current serve configuration
  reset       Reset current serve config
  drain       Drain a service from the current node
  clear       Remove all config for a service
  advertise   Advertise this node as a service proxy to the tailnet
  get-config  Get service configuration to save to a file
  set-config  Define service configuration from a file


tailscale serve get-config 2>&1
echo "---"
tailscale funnel --help 2>&1 | head -30

Check config format and funnel help


2026/04/04 10:34:53 must specify either --service=svc:<service-name> or --all
---
Serve content and local servers on the internet

USAGE
  tailscale funnel <target>
  tailscale funnel status [--json]
  tailscale funnel reset

Funnel enables you to share a local server on the internet using Tailscale.

To share only within your tailnet, use `tailscale serve`

<target> can be a file, directory, text, or most commonly the location to a service running on the
local machine. The location to the location service can be expressed as a port number (e.g., 3000),
a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http://localhost:3000/foo).
On Unix-like systems, you can also specify a Unix domain socket (e.g., unix:/tmp/myservice.sock).

EXAMPLES
  - Expose an HTTP server running at 127.0.0.1:3000 in the foreground:
    $ tailscale funnel 3000

  - Expose an HTTP server running at 127.0.0.1:3000 in the background:
    $ tailscale funnel --bg 3000

  - Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443
    $ tailscale funnel https+insecure://localhost:8443

  - Expose a service listening on a Unix socket (Linux/macOS/BSD only):
    $ tailscale funnel unix:/var/run/myservice.sock

For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases


This version of Tailscale doesn't support path-based routing via CLI. I'll write a lightweight Python reverse proxy that handles both paths on a single port, then expose that via Funnel:


Write

"""
Lightweight reverse proxy — single entry point for Tailscale Funnel.

Routes:
  /pr-tracker/*          → Streamlit  (localhost:8501)
  /course-dashboard.html → static HTML file
  /                      → redirect to /pr-tracker
"""
import http.server
import urllib.request
import urllib.error
import os
import sys

STREAMLIT_PORT  = 8501
STATIC_HTML     = os.path.expanduser("~/course-dashboard.html")
PROXY_PORT      = 8090

class Router(http.server.BaseHTTPRequestHandler):

    def log_message(self, fmt, *args):
        print(f"[proxy] {self.address_string()} - {fmt % args}")

    # ── helpers ───────────────────────────────────────────────────────────────

    def _send(self, code, content_type, body: bytes):
        self.send_response(code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _redirect(self, location):
        self.send_response(302)
        self.send_header("Location", location)
        self.end_headers()

    def _proxy_to_streamlit(self):
        """Forward request to Streamlit and stream the response back."""
        target = f"http://localhost:{STREAMLIT_PORT}{self.path}"
        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in ("host", "connection")}
        headers["Host"] = f"localhost:{STREAMLIT_PORT}"

        # Read body for POST/PUT
        body = None
        length = int(self.headers.get("Content-Length", 0))
        if length:
            body = self.rfile.read(length)

        req = urllib.request.Request(target, data=body, headers=headers,
                                     method=self.command)
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                self.send_response(resp.status)
                for k, v in resp.headers.items():
                    if k.lower() in ("connection", "transfer-encoding"):
                        continue
                    self.send_header(k, v)
                self.end_headers()
                self.wfile.write(resp.read())
        except urllib.error.HTTPError as e:
            body = e.read()
            self.send_response(e.code)
            self.end_headers()
            self.wfile.write(body)
        except Exception as exc:
            msg = f"Proxy error: {exc}".encode()
            self._send(502, "text/plain", msg)

    # ── routing ───────────────────────────────────────────────────────────────

    def _handle(self):
        path = self.path.split("?")[0]

        if path == "/" or path == "":
            self._redirect("/pr-tracker")

        elif path == "/course-dashboard.html":
            if os.path.exists(STATIC_HTML):
                with open(STATIC_HTML, "rb") as f:
                    body = f.read()
                self._send(200, "text/html; charset=utf-8", body)
            else:
                self._send(404, "text/plain", b"course-dashboard.html not found")

        elif path.startswith("/pr-tracker") or path.startswith("/_stcore") \
                or path.startswith("/static") or path.startswith("/stream"):
            self._proxy_to_streamlit()

        else:
            # Fall through to Streamlit for everything else
            # (Streamlit assets, websocket upgrade handled by the browser directly)
            self._proxy_to_streamlit()

    def do_GET(self):    self._handle()
    def do_POST(self):   self._handle()
    def do_HEAD(self):   self._handle()
    def do_OPTIONS(self): self._handle()

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else PROXY_PORT
    server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Router)
    print(f"[proxy] Listening on http://127.0.0.1:{port}")
    print(f"[proxy]  /pr-tracker           → Streamlit :{STREAMLIT_PORT}")
    print(f"[proxy]  /course-dashboard.html → {STATIC_HTML}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[proxy] Stopped.")


File created successfully at: /Users/khalidbinkalban/Projects/dewa-pr-tracker/proxy.py

"""
Lightweight reverse proxy — single entry point for Tailscale Funnel.

Routes:
  /pr-tracker/*          → Streamlit  (localhost:8501)
  /course-dashboard.html → static HTML file
  /                      → redirect to /pr-tracker
"""
import http.server
import urllib.request
import urllib.error
import os
import sys

STREAMLIT_PORT  = 8501
STATIC_HTML     = os.path.expanduser("~/course-dashboard.html")
PROXY_PORT      = 8090

class Router(http.server.BaseHTTPRequestHandler):

    def log_message(self, fmt, *args):
        print(f"[proxy] {self.address_string()} - {fmt % args}")

    # ── helpers ───────────────────────────────────────────────────────────────

    def _send(self, code, content_type, body: bytes):
        self.send_response(code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _redirect(self, location):
        self.send_response(302)
        self.send_header("Location", location)
        self.end_headers()

    def _proxy_to_streamlit(self):
        """Forward request to Streamlit and stream the response back."""
        target = f"http://localhost:{STREAMLIT_PORT}{self.path}"
        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in ("host", "connection")}
        headers["Host"] = f"localhost:{STREAMLIT_PORT}"

        # Read body for POST/PUT
        body = None
        length = int(self.headers.get("Content-Length", 0))
        if length:
            body = self.rfile.read(length)

        req = urllib.request.Request(target, data=body, headers=headers,
                                     method=self.command)
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                self.send_response(resp.status)
                for k, v in resp.headers.items():
                    if k.lower() in ("connection", "transfer-encoding"):
                        continue
                    self.send_header(k, v)
                self.end_headers()
                self.wfile.write(resp.read())
        except urllib.error.HTTPError as e:
            body = e.read()
            self.send_response(e.code)
            self.end_headers()
            self.wfile.write(body)
        except Exception as exc:
            msg = f"Proxy error: {exc}".encode()
            self._send(502, "text/plain", msg)

    # ── routing ───────────────────────────────────────────────────────────────

    def _handle(self):
        path = self.path.split("?")[0]

        if path == "/" or path == "":
            self._redirect("/pr-tracker")

        elif path == "/course-dashboard.html":
            if os.path.exists(STATIC_HTML):
                with open(STATIC_HTML, "rb") as f:
                    body = f.read()
                self._send(200, "text/html; charset=utf-8", body)
            else:
                self._send(404, "text/plain", b"course-dashboard.html not found")

        elif path.startswith("/pr-tracker") or path.startswith("/_stcore") \
                or path.startswith("/static") or path.startswith("/stream"):
            self._proxy_to_streamlit()

        else:
            # Fall through to Streamlit for everything else
            # (Streamlit assets, websocket upgrade handled by the browser directly)
            self._proxy_to_streamlit()

    def do_GET(self):    self._handle()
    def do_POST(self):   self._handle()
    def do_HEAD(self):   self._handle()
    def do_OPTIONS(self): self._handle()

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else PROXY_PORT
    server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Router)
    print(f"[proxy] Listening on http://127.0.0.1:{port}")
    print(f"[proxy]  /pr-tracker           → Streamlit :{STREAMLIT_PORT}")
    print(f"[proxy]  /course-dashboard.html → {STATIC_HTML}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[proxy] Stopped.")


Bash

# Kill old static server and proxy if any
pkill -f "http.server 8090" 2>/dev/null
pkill -f "proxy.py" 2>/dev/null
sleep 1

# Start proxy on 8090
cd /Users/khalidbinkalban/Projects/dewa-pr-tracker
python3 proxy.py 8090 &>/tmp/proxy.log &
sleep 2

# Test routes
echo "Testing routes..."
curl -s -o /dev/null -w "/ (redirect):              %{http_code}\n" http://localhost:8090/
curl -s -o /dev/null -w "/course-dashboard.html:    %{http_code}\n" http://localhost:8090/course-dashboard.html
curl -s -o /dev/null -w "/pr-tracker:               %{http_code}\n" http://localhost:8090/pr-tracker

Start proxy and test all routes


Testing routes...
/ (redirect):              302
/course-dashboard.html:    200
/pr-tracker:               200


All routes working. Now point Tailscale Funnel to the proxy:


tailscale funnel reset 2>/dev/null; sleep 1
tailscale funnel --bg 8090
sleep 2
tailscale funnel status

Point Tailscale Funnel to proxy port 8090


Available on the internet:

https://khalids-mac-mini.tailbd7b77.ts.net/
|-- proxy http://127.0.0.1:8090

Funnel started and running in the background.
To disable the proxy, run: tailscale funnel --https=443 off

# Funnel on:
#     - https://khalids-mac-mini.tailbd7b77.ts.net

https://khalids-mac-mini.tailbd7b77.ts.net (Funnel on)
|-- / proxy http://127.0.0.1:8090


Now update the start script to launch everything together:


Write

#!/bin/bash
# DEWA PR Tracker — Start all services
cd "$(dirname "$0")"
source venv/bin/activate

# Kill any existing instances
pkill -f "streamlit run dashboard.py" 2>/dev/null
pkill -f "proxy.py" 2>/dev/null
sleep 1

# 1. Start Streamlit (port 8501, base path /pr-tracker)
streamlit run dashboard.py &
sleep 4

# 2. Start reverse proxy (port 8090)
python3 proxy.py 8090 &>/tmp/proxy.log &
sleep 2

echo ""
echo "  ─────────────────────────────────────────────────────────"
echo "  DEWA PR Tracker"
echo "  Share URL:  https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker"
echo ""
echo "  Course Dashboard"
echo "  Share URL:  https://khalids-mac-mini.tailbd7b77.ts.net/course-dashboard.html"
echo "  ─────────────────────────────────────────────────────────"
echo ""

open https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker

# Keep script alive so Ctrl+C stops everything
wait


The file /Users/khalidbinkalban/Projects/dewa-pr-tracker/start.sh has been updated successfully.

#!/bin/bash
# DEWA PR Tracker — Start all services
cd "$(dirname "$0")"
source venv/bin/activate

# Kill any existing instances
pkill -f "streamlit run dashboard.py" 2>/dev/null
pkill -f "proxy.py" 2>/dev/null
sleep 1

# 1. Start Streamlit (port 8501, base path /pr-tracker)
streamlit run dashboard.py &
sleep 4

# 2. Start reverse proxy (port 8090)
python3 proxy.py 8090 &>/tmp/proxy.log &
sleep 2

echo ""
echo "  ─────────────────────────────────────────────────────────"
echo "  DEWA PR Tracker"
echo "  Share URL:  https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker"
echo ""
echo "  Course Dashboard"
echo "  Share URL:  https://khalids-mac-mini.tailbd7b77.ts.net/course-dashboard.html"
echo "  ─────────────────────────────────────────────────────────"
echo ""

open https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker

# Keep script alive so Ctrl+C stops everything
wait

+pkill -f "streamlit run dashboard.py" 2>/dev/null

+# 1. Start Streamlit (port 8501, base path /pr-tracker)

+python3 proxy.py 8090 &>/tmp/proxy.log &

+echo "  ─────────────────────────────────────────────────────────"

-echo "  ─────────────────────────────────────────────────"

-echo "  Local:    http://localhost:8501"

-echo "  Team URL: https://khalids-mac-mini.tailbd7b77.ts.net"

+echo "  Share URL:  https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker"

+echo "  Share URL:  https://khalids-mac-mini.tailbd7b77.ts.net/course-dashboard.html"

+# Keep script alive so Ctrl+C stops everything

#!/bin/bash
# DEWA PR Tracker — Start dashboard
cd "$(dirname "$0")"
source venv/bin/activate

echo ""
echo "  DEWA PR Tracker"
echo "  ─────────────────────────────────────────────────"
echo "  Local:    http://localhost:8501"
echo "  Team URL: https://khalids-mac-mini.tailbd7b77.ts.net"
echo "  ─────────────────────────────────────────────────"
echo ""

open http://localhost:8501

streamlit run dashboard.py \
  --server.port 8501 \
  --server.address 0.0.0.0 \
  --server.headless true \
  --browser.gatherUsageStats false


Done. Your two permanent URLs:

| What | URL |
|---|---|
| **DEWA PR Tracker** | `https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker` |
| **Course Dashboard** | `https://khalids-mac-mini.tailbd7b77.ts.net/course-dashboard.html` |

They are fully independent — refreshing one never affects the other. The root `/` auto-redirects to `/pr-tracker`.

To start everything from now on:
```bash
cd ~/Projects/dewa-pr-tracker && ./start.sh
```


https://khalids-mac-mini.tailbd7b77.ts.net/pr-tracker
not working


Bash

cat /tmp/proxy.log 2>/dev/null | tail -20
echo "---"
curl -v http://localhost:8090/pr-tracker 2>&1 | tail -20
echo "---"
ps aux | grep -E "streamlit|proxy" | grep -v grep

Check proxy logs and process status


[proxy] 127.0.0.1 - "GET /_stcore/host-config HTTP/1.1" 404 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/stream HTTP/1.1" 400 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/health HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/host-config HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /course-dashboard.html/_stcore/health HTTP/1.1" 404 -
[proxy] 127.0.0.1 - "GET /course-dashboard.html/_stcore/host-config HTTP/1.1" 404 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/stream HTTP/1.1" 400 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/health HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/host-config HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/stream HTTP/1.1" 400 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/health HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/host-config HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/stream HTTP/1.1" 400 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/host-config HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/health HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/stream HTTP/1.1" 400 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/health HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /pr-tracker/_stcore/host-config HTTP/1.1" 304 -
[proxy] 127.0.0.1 - "GET /_stcore/health HTTP/1.1" 404 -
Kit/537.36 (KHTML,�---
      href="./static/media/SourceSansVF-Upright.ttf.BsWL4Kly.woff2"
      as="font"
      type="font/woff2"
      crossorigin
    />

    <title>Streamlit</title>

    <link rel="stylesheet" crossorigin href="./static/css/index.CIiu7Ygf.css">

    You need to enable JavaScript to run this app.

---
khalidbinkalban  87649   1.1  0.1 411491280  20208   ??  SN   10:34AM   0:00.95 /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python /Users/khalidbinkalban/Projects/dewa-pr-tracker/venv/bin/streamlit run dashboard.py
khalidbinkalban  88366   0.2  0.1 411245680  12976   ??  SN   10:35AM   0:00.64 /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python proxy.py 8090
khalidbinkalban  33381   0.0  0.1 426978144  11264   ??  S     9:08AM   0:00.97 /usr/libexec/networkserviceproxy
khalidbinkalban  16497   0.0  0.0 412102144   3728   ??  S    Sat11AM   1:12.95 /Applications/Docker.app/Contents/Resources/cli-plugins/docker-agent serve api --listen unix:///Users/khalidbinkalban/Library/Containers/com.docker.docker/Data/docker-agent.sock --models-gateway https://ai-backend-service.docker.com/proxy?origin=desktop&desktopVersion=4.66.1&gordonTag=v6 docker/gordon:v6 --pull-interval 60
khalidbinkalban    596   0.0  0.0 426965696   3664   ??  S    25Mar26   1:00.36 /usr/libexec/containermanagerd --runmode=agent --user-container-mode=current --bundle-container-mode=proxy --system-container-mode=none


The issue is WebSockets — Streamlit uses them for live updates and my simple proxy can't handle the upgrade. I need a proper proxy. Let me check what's available:


which caddy 2>/dev/null && echo "caddy found"
which nginx 2>/dev/null && echo "nginx found"
which brew 2>/dev/null && echo "brew found"

Check available reverse proxy tools


caddy not found
nginx not found
/opt/homebrew/bin/brew
brew found


Install Caddy (handles WebSockets natively)


Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).
==> Caveats
zsh completions have been installed to:
  /opt/homebrew/share/zsh/site-functions


Write

:8090 {
    # DEWA PR Tracker (Streamlit — WebSocket aware)
    handle /pr-tracker* {
        reverse_proxy localhost:8501
    }

    # Course Dashboard (static HTML file)
    handle /course-dashboard.html {
        root * /Users/khalidbinkalban
        file_server
    }

    # Root → redirect to PR tracker
    handle / {
        redir /pr-tracker 302
    }

    # Streamlit internal assets
    handle /_stcore* {
        reverse_proxy localhost:8501
    }

    handle /static* {
        reverse_proxy localhost:8501
    }
}


File created successfully at: /Users/khalidbinkalban/Projects/dewa-pr-tracker/Caddyfile

:8090 {
    # DEWA PR Tracker (Streamlit — WebSocket aware)
    handle /pr-tracker* {
        reverse_proxy localhost:8501
    }

    # Course Dashboard (static HTML file)
    handle /course-dashboard.html {
        root * /Users/khalidbinkalban
        file_server
    }

    # Root → redirect to PR tracker
    handle / {
        redir /pr-tracker 302
    }

    # Streamlit internal assets
    handle /_stcore* {
        reverse_proxy localhost:8501
    }

    handle /static* {
        reverse_proxy localhost:8501
    }
}


Bash

cd /Users/khalidbinkalban/Projects/dewa-pr-tracker

# Stop old proxy
pkill -f "proxy.py" 2>/dev/null
pkill -f "http.server 8090" 2>/dev/null
pkill -f "caddy run" 2>/dev/null
sleep 1

# Start Caddy
caddy run --config Caddyfile &>/tmp/caddy.log &
sleep 3

# Test routes
echo "Testing routes..."
curl -s -o /dev/null -w "/                        → %{http_code}\n" http://localhost:8090/
curl -s -o /dev/null -w "/pr-tracker              → %{http_code}\n" http://localhost:8090/pr-tracker
curl -s -o /dev/null -w "/course-dashboard.html   → %{http_code}\n" http://localhost:8090/course-dashboard.html
curl -s -o /dev/null -w "/pr-tracker/_stcore/health → %{http_code}\n" http://localhost:8090/pr-tracker/_stcore/health

Start Caddy and test all routes including WebSocket health endpoint


Testing routes...
/                        → 200
/pr-tracker              → 301
/course-dashboard.html   → 200
/pr-tracker/_stcore/health → 200


All routes working, including the WebSocket health check. Verify Tailscale Funnel is still on 8090:


Confirm Funnel config