sashank1989's picture
Upload app.py with huggingface_hub
cb8f97e verified
Raw
History Blame Contribute Delete
35.6 kB
"""
JIRA AI Sprint Dashboard β€” Streamlit App
IP (Indirect Procurement) project β€” Ford Jira
Deploy: Streamlit Community Cloud (free) from GitHub
"""
import os
import io
import streamlit as st
import requests
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
from datetime import datetime, timedelta
from base64 import b64encode
from functools import reduce
from urllib.parse import quote as urlquote
# ── Page config ──────────────────────────────────────────────
st.set_page_config(
page_title="IP Sprint Dashboard",
page_icon="πŸ“Š",
layout="wide",
initial_sidebar_state="expanded",
)
# ── Jira credentials (env vars for Cloud Run, st.secrets for local) ──
JIRA_BASE = os.environ.get("JIRA_BASE_URL") or st.secrets["jira"]["base_url"]
JIRA_EMAIL = os.environ.get("JIRA_EMAIL") or st.secrets["jira"]["email"]
JIRA_TOKEN = os.environ.get("JIRA_API_TOKEN") or st.secrets["jira"]["api_token"]
PROJECT_KEY = "IP"
SP_FIELD = "customfield_10040"
TEAM_LABELS = [
"IndirectMDM", "I2P", "EIIM", "IMEU", "IMNA", "S2R",
"SAPIntegration", "SNO-Aurora-Data-and-Env-Mgt",
"SNO-Aurora-I2P-IT-L2", "SNO-Global-IERPAP-Support",
]
DONE_STATUSES = ["Done", "Closed Complete"]
AUTH_HEADER = {
"Authorization": f"Basic {b64encode(f'{JIRA_EMAIL}:{JIRA_TOKEN}'.encode()).decode()}",
"Content-Type": "application/json",
}
# ── Jira API helpers ─────────────────────────────────────────
def jira_get(endpoint, agile=False):
prefix = "rest/agile/1.0" if agile else "rest/api/3"
r = requests.get(f"{JIRA_BASE}/{prefix}/{endpoint}", headers=AUTH_HEADER, timeout=30)
r.raise_for_status()
return r.json()
@st.cache_data(ttl=300, show_spinner="Fetching active sprint...")
def get_active_sprint():
boards = jira_get(f"board?projectKeyOrId={PROJECT_KEY}&maxResults=50", agile=True)
for b in boards.get("values", []):
try:
sprints = jira_get(f"board/{b['id']}/sprint?state=active&maxResults=5", agile=True)
if sprints.get("values"):
return sprints["values"][0]
except Exception:
continue
return None
@st.cache_data(ttl=300, show_spinner="Fetching sprint issues...")
def get_sprint_issues(sprint_id):
all_issues = []
start_at = 0
fields = f"summary,status,assignee,priority,issuetype,{SP_FIELD},created,updated,labels,parent"
while True:
data = jira_get(
f"sprint/{sprint_id}/issue?maxResults=100&startAt={start_at}&fields={fields}",
agile=True,
)
all_issues.extend(data.get("issues", []))
start_at += 100
if start_at >= data.get("total", 0):
break
return all_issues
@st.cache_data(ttl=300, show_spinner="Fetching today's changes...")
def get_today_changes(sprint_id):
today = datetime.now().strftime("%Y-%m-%d")
all_issues = []
start_at = 0
while True:
jql = requests.utils.quote(
f'project = {PROJECT_KEY} AND sprint = {sprint_id} AND updated >= "{today}" ORDER BY updated DESC'
)
data = jira_get(
f"search/jql?jql={jql}&maxResults=100&startAt={start_at}"
f"&fields=summary,status,assignee,updated,labels&expand=changelog"
)
all_issues.extend(data.get("issues", []))
start_at += 100
if data.get("isLast", True):
break
return all_issues
# ── Data processing ──────────────────────────────────────────
def issues_to_df(issues):
rows = []
for iss in issues:
f = iss["fields"]
epic_key = ""
epic_name = ""
if f.get("parent"):
epic_key = f["parent"].get("key", "")
if f["parent"].get("fields", {}).get("summary"):
epic_name = f["parent"]["fields"]["summary"]
sp = float(f.get(SP_FIELD) or 0)
labels = f.get("labels") or []
team = "No Team"
for tl in TEAM_LABELS:
if tl in labels:
team = tl
break
status = f["status"]["name"]
cat = "Other"
if status in DONE_STATUSES:
cat = "Done"
elif status == "In Progress":
cat = "In Progress"
elif status == "Ready for acceptance":
cat = "Review"
elif status in ("To Do", "New"):
cat = "To Do"
elif status == "Canceled":
cat = "Canceled"
rows.append({
"Key": iss["key"],
"Summary": f.get("summary", ""),
"Status": status,
"Category": cat,
"Assignee": f["assignee"]["displayName"] if f.get("assignee") else "Unassigned",
"Priority": f["priority"]["name"] if f.get("priority") else "None",
"Type": f["issuetype"]["name"],
"SP": sp,
"Team": team,
"Labels": "; ".join(labels),
"Epic": epic_key,
"Epic Name": epic_name,
"Created": f.get("created", "")[:10],
"Updated": f.get("updated", "")[:16].replace("T", " "),
})
return pd.DataFrame(rows)
def build_burndown(df, sprint):
start = pd.to_datetime(sprint["startDate"]).replace(tzinfo=None).normalize()
end = pd.to_datetime(sprint["endDate"]).replace(tzinfo=None).normalize()
today = pd.Timestamp.now().normalize()
last_day = min(today, end)
total_sp = df["SP"].sum()
total_days = (end - start).days
# daily done / planned lookups
daily_done = {}
daily_planned = {}
for _, row in df.iterrows():
sp = row["SP"]
if sp > 0:
created = pd.to_datetime(row["Created"]).normalize()
added = max(created, start)
k = added.strftime("%Y-%m-%d")
daily_planned[k] = daily_planned.get(k, 0) + sp
if row["Category"] == "Done" and sp > 0 and row["Updated"]:
done_d = pd.to_datetime(row["Updated"]).normalize()
if done_d < start:
done_d = start
k = done_d.strftime("%Y-%m-%d")
daily_done[k] = daily_done.get(k, 0) + sp
dates, ideal, actual, planned = [], [], [], []
cum_done = 0
cum_planned = 0
day = start
idx = 0
while day <= end:
ds = day.strftime("%Y-%m-%d")
label = day.strftime("%b %d")
dates.append(label)
cum_planned += daily_planned.get(ds, 0)
ideal_val = round(total_sp - (total_sp * idx / total_days), 1) if total_days > 0 else 0
ideal.append(ideal_val)
if day <= last_day:
cum_done += daily_done.get(ds, 0)
actual.append(round(total_sp - cum_done, 1))
planned.append(cum_planned)
else:
actual.append(None)
planned.append(None)
day += timedelta(days=1)
idx += 1
return dates, ideal, actual, planned, total_sp
# ── Main app ─────────────────────────────────────────────────
def main():
# ── Global CSS: pill-shaped tabs ─────────────────────────
_pill_css = '<style>.stTabs [data-baseweb="tab-list"]{gap:8px;border-bottom:none !important;}.stTabs [data-baseweb="tab"]{background:#1e293b !important;border:1px solid #334155 !important;border-radius:20px !important;padding:8px 20px !important;color:#94a3b8 !important;font-weight:600 !important;}.stTabs [aria-selected="true"]{background:#1e40af !important;color:white !important;border-color:#1e40af !important;}.stTabs [data-baseweb="tab-highlight"]{display:none !important;}.stTabs [data-baseweb="tab-border"]{display:none !important;}</style>'
st.markdown(_pill_css, unsafe_allow_html=True)
# Fetch data
sprint = get_active_sprint()
if not sprint:
st.error("No active sprint found in IP project.")
return
issues = get_sprint_issues(sprint["id"])
df = issues_to_df(issues)
changes_issues = get_today_changes(sprint["id"])
changed_today_count = len(changes_issues)
# Sprint info
s_name = sprint["name"]
s_start = sprint["startDate"][:10]
s_end = sprint["endDate"][:10]
days_left = max(0, (pd.to_datetime(s_end).date() - datetime.now().date()).days)
# ── Sidebar filters ──────────────────────────────────────
st.sidebar.title("Filters")
st.sidebar.caption(f"Last updated: {datetime.now().strftime('%b %d, %Y %H:%M')} UTC")
st.sidebar.markdown('<p style="font-size:12px;color:#94a3b8;margin-top:-10px;" id="local-time"></p><script>document.getElementById("local-time").textContent="Local: "+new Date().toLocaleString(undefined,{dateStyle:"medium",timeStyle:"short",timeZoneName:"short"});</script>', unsafe_allow_html=True)
teams = ["All"] + sorted(df["Team"].unique().tolist())
sel_team = st.sidebar.selectbox("Team", teams)
assignees = ["All"] + sorted(df["Assignee"].unique().tolist())
sel_assignee = st.sidebar.selectbox("Assignee", assignees)
statuses = ["All"] + sorted(df["Category"].unique().tolist())
sel_status = st.sidebar.selectbox("Status Category", statuses)
epics = ["All"] + sorted(df[df["Epic Name"] != ""]["Epic Name"].unique().tolist())
sel_epic = st.sidebar.selectbox("Epic", epics)
# Apply filters
fdf = df.copy()
if sel_team != "All":
fdf = fdf[fdf["Team"] == sel_team]
if sel_assignee != "All":
fdf = fdf[fdf["Assignee"] == sel_assignee]
if sel_status != "All":
fdf = fdf[fdf["Category"] == sel_status]
if sel_epic != "All":
fdf = fdf[fdf["Epic Name"] == sel_epic]
# ── Sidebar: Export & Email ───────────────────────────────
st.sidebar.markdown("---")
st.sidebar.markdown("**Export**")
# Active filter summary
active_filters = []
if sel_team != "All":
active_filters.append(f"Team: {sel_team}")
if sel_assignee != "All":
active_filters.append(f"Assignee: {sel_assignee}")
if sel_status != "All":
active_filters.append(f"Status: {sel_status}")
if sel_epic != "All":
active_filters.append(f"Epic: {sel_epic}")
filter_desc = ", ".join(active_filters) if active_filters else "All Data (no filters)"
# Compute stats for email/export summary
exp_total = len(fdf)
exp_done = len(fdf[fdf["Category"] == "Done"])
exp_sp = fdf["SP"].sum()
exp_done_sp = fdf[fdf["Category"] == "Done"]["SP"].sum()
exp_ip_sp = fdf[fdf["Category"] == "In Progress"]["SP"].sum()
exp_rem_sp = exp_sp - exp_done_sp - exp_ip_sp
exp_comp = round(exp_done_sp / exp_sp * 100, 1) if exp_sp > 0 else 0
# ── Download (single button, format selector) ──
dl_format = st.sidebar.selectbox("Download Format", ["CSV", "Excel"], key="dl_fmt")
export_cols = ["Key", "Summary", "Status", "Category", "Assignee", "SP", "Priority", "Type", "Team", "Epic", "Epic Name", "Created", "Updated"]
if dl_format == "CSV":
csv_data = fdf[export_cols].to_csv(index=False).encode("utf-8")
st.sidebar.download_button("Download", csv_data, file_name=f"sprint-{s_name}-{datetime.now().strftime('%Y%m%d')}.csv", mime="text/csv", key="dl_btn")
else:
excel_buf = io.BytesIO()
with pd.ExcelWriter(excel_buf, engine="openpyxl") as writer:
fdf[export_cols].to_excel(writer, index=False, sheet_name="Sprint Issues")
summary_rows = [("Sprint", s_name), ("Period", f"{s_start} to {s_end}"), ("Filters", filter_desc), ("Total Issues", exp_total), ("Done Issues", exp_done), ("Total SP", exp_sp), ("Done SP", exp_done_sp), ("In Progress SP", exp_ip_sp), ("Remaining SP", exp_rem_sp), ("Completion %", f"{exp_comp}%"), ("Dashboard URL", "https://fordai-sprint-dashboard.hf.space")]
pd.DataFrame(summary_rows, columns=["Metric", "Value"]).to_excel(writer, index=False, sheet_name="Summary")
user_summary = fdf.groupby("Assignee").agg(Planned=("SP", "sum"), Done=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "Done"].sum()), InProgress=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "In Progress"].sum())).reset_index()
user_summary["Remaining"] = user_summary["Planned"] - user_summary["Done"] - user_summary["InProgress"]
user_summary.sort_values("Planned", ascending=False).to_excel(writer, index=False, sheet_name="SP by User")
st.sidebar.download_button("Download", excel_buf.getvalue(), file_name=f"sprint-{s_name}-{datetime.now().strftime('%Y%m%d')}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", key="dl_btn")
# ── Ford header banner with Refresh ─────────────────────────
hdr_left, hdr_right = st.columns([6, 1])
with hdr_left:
ford_logo = '<svg width="80" height="32" viewBox="0 0 200 80"><ellipse cx="100" cy="40" rx="96" ry="38" fill="#003478" stroke="#5b8ec2" stroke-width="3"/><text x="100" y="55" text-anchor="middle" fill="white" font-family="\'Book Antiqua\',Palatino,serif" font-size="44" font-style="italic" font-weight="bold">Ford</text></svg>'
header_html = '<div style="display:flex;align-items:center;gap:20px;padding:16px 24px;background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);border:1px solid #334155;border-radius:12px;margin-bottom:4px;">'
header_html += ford_logo
header_html += '<div style="flex:1;"><div style="font-size:24px;font-weight:700;color:#e2e8f0;letter-spacing:0.5px;">JIRA Sprint Dashboard</div>'
header_html += '<div style="font-size:14px;color:#60a5fa;font-weight:500;margin-top:2px;">Indirect Procurement</div></div>'
header_html += '</div>'
st.markdown(header_html, unsafe_allow_html=True)
with hdr_right:
st.markdown('<div style="height:20px;"></div>', unsafe_allow_html=True)
if st.button("πŸ”„ Refresh", use_container_width=True):
st.cache_data.clear()
st.rerun()
# ── KPI data ──────────────────────────────────────────────
total_sp = fdf["SP"].sum()
done_sp = fdf[fdf["Category"] == "Done"]["SP"].sum()
remaining_sp = total_sp - done_sp
done_issues = len(fdf[fdf["Category"] == "Done"])
in_progress = len(fdf[fdf["Category"] == "In Progress"])
completion = round((done_sp / total_sp * 100), 1) if total_sp > 0 else 0
# ── 4 KPI cards in ONE row ───────────────────────────────
kpi_css = '<style>.kpi-row{display:flex;gap:12px;margin-bottom:12px}.kpi-card{flex:1;background:#1e293b;border:1px solid #334155;border-radius:10px;padding:16px 20px}.kpi-label{color:#94a3b8;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px}.kpi-value{font-size:28px;font-weight:700;margin:2px 0}.kpi-sub{color:#64748b;font-size:12px}</style>'
kpi_html = kpi_css
kpi_html += '<div class="kpi-row">'
kpi_html += f'<div class="kpi-card"><div class="kpi-label">SPRINT</div><div class="kpi-value" style="color:#22c55e;">{s_name}</div><div class="kpi-sub">{s_start} &rarr; {s_end}</div></div>'
kpi_html += f'<div class="kpi-card"><div class="kpi-label">DAYS REMAINING</div><div class="kpi-value" style="color:#22c55e;">{days_left}</div><div class="kpi-sub">Sprint deadline</div></div>'
kpi_html += f'<div class="kpi-card"><div class="kpi-label">TOTAL ISSUES</div><div class="kpi-value" style="color:#22c55e;">{len(fdf)}</div><div class="kpi-sub">{done_issues} done &bull; {completion}% complete</div></div>'
kpi_html += f'<div class="kpi-card"><div class="kpi-label">CHANGED TODAY</div><div class="kpi-value" style="color:#a855f7;">{changed_today_count}</div><div class="kpi-sub">Issues updated today</div></div>'
kpi_html += '</div>'
st.markdown(kpi_html, unsafe_allow_html=True)
# ── Global search bar ─────────────────────────────────────
srch_col1, srch_col2, srch_col3 = st.columns([5, 1, 1])
with srch_col1:
search = st.text_input("Search", placeholder="Search by key, summary, or assignee...", label_visibility="collapsed", key="global_search")
with srch_col2:
st.button("πŸ” Search", use_container_width=True, type="primary")
with srch_col3:
email_subject = f"Sprint Report: {s_name} - Indirect Procurement"
mailto_url = f"mailto:?subject={urlquote(email_subject)}"
st.markdown(f'<a href="{mailto_url}" target="_blank" style="display:inline-block;width:100%;text-align:center;padding:10px 0;background:#7c3aed;color:white;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">πŸ“§ Email Results</a>', unsafe_allow_html=True)
# Apply search filter
sdf = fdf.copy()
if search:
mask = sdf["Key"].str.contains(search, case=False, na=False) | sdf["Summary"].str.contains(search, case=False, na=False) | sdf["Assignee"].str.contains(search, case=False, na=False)
sdf = sdf[mask]
# ── Tabs ──────────────────────────────────────────────────
tab1, tab2, tab3, tab4 = st.tabs(["πŸ“ˆ Overview", "πŸ“Š Story Points", "πŸ“‹ All Issues", "πŸ”„ Changes Today"])
# ── Tab 1: Overview ───────────────────────────────────────
with tab1:
col_left, col_right = st.columns([1, 1])
# ── Left: Status Distribution ────────────────────────
with col_left:
status_counts = fdf.groupby("Status").size().reset_index(name="Count").sort_values("Count", ascending=False)
total_issues = status_counts["Count"].sum()
status_colors = {
"Done": "#22c55e", "Closed Complete": "#16a34a",
"To Do": "#eab308", "In Progress": "#3b82f6",
"Ready for acceptance": "#f59e0b", "New": "#06b6d4",
"Closed": "#64748b", "Accepted": "#64748b",
"Canceled": "#ef4444", "On Hold": "#64748b",
"Work in progress": "#64748b", "Closed Incomplete": "#64748b",
"Resolved": "#64748b",
}
# Build HTML bar chart β€” no indentation to avoid markdown code blocks
bars = []
for _, row in status_counts.iterrows():
s = row["Status"]
cnt = int(row["Count"])
pct = round(cnt / total_issues * 100) if total_issues > 0 else 0
bar_w = max(pct, 1)
color = status_colors.get(s, "#64748b")
bars.append(
f'<div style="display:flex;align-items:center;margin-bottom:6px;gap:8px;">'
f'<div style="width:160px;color:#cbd5e1;font-size:13px;flex-shrink:0;">{s}</div>'
f'<div style="flex:1;background:#0f172a;border-radius:4px;height:24px;">'
f'<div style="width:{bar_w}%;background:{color};height:100%;border-radius:4px;"></div></div>'
f'<div style="width:50px;text-align:right;color:#e2e8f0;font-weight:700;font-size:15px;">{cnt}</div>'
f'<div style="width:40px;text-align:right;color:#94a3b8;font-size:13px;">{pct}%</div></div>'
)
html = '<div style="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;">'
html += '<div style="color:#60a5fa;font-size:13px;font-weight:600;letter-spacing:1px;margin-bottom:16px;">STATUS DISTRIBUTION</div>'
html += ''.join(bars)
html += '</div>'
st.markdown(html, unsafe_allow_html=True)
# ── Right: Sprint Completion + Burndown ──────────────
with col_right:
# Sprint completion donut
done_count = len(fdf[fdf["Category"] == "Done"])
comp_pct = round(done_count / len(fdf) * 100) if len(fdf) > 0 else 0
fig_donut = go.Figure(go.Pie(
values=[done_count, len(fdf) - done_count],
labels=["Done", "Remaining"],
hole=0.75,
marker=dict(colors=["#f59e0b", "#1e293b"]),
textinfo="none",
hovertemplate="%{label}: %{value}<extra></extra>",
))
fig_donut.update_layout(
showlegend=False,
height=240,
margin=dict(t=40, b=10, l=20, r=20),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
annotations=[
dict(text=f"<b>{comp_pct}%</b>", x=0.5, y=0.55, font=dict(size=36, color="#f59e0b"), showarrow=False),
dict(text=f"{done_count} of {len(fdf)} issues completed", x=0.5, y=-0.05, font=dict(size=13, color="#94a3b8"), showarrow=False),
],
title=dict(text="SPRINT COMPLETION", font=dict(size=13, color="#60a5fa"), x=0.01),
)
st.plotly_chart(fig_donut, use_container_width=True)
# Burndown chart
dates, ideal, actual, planned_vals, total_sp_all = build_burndown(fdf, sprint)
# Find today index for vertical marker
today_label = pd.Timestamp.now().strftime("%b %d")
fig_burn = go.Figure()
# Ideal line (gray dashed)
fig_burn.add_trace(go.Scatter(
x=dates, y=ideal, name="Ideal",
line=dict(dash="dash", color="#666", width=2),
))
# Planned line (orange, filled)
fig_burn.add_trace(go.Scatter(
x=dates, y=planned_vals, name="Planned",
line=dict(color="#f59e0b", width=3),
fill="tozeroy", fillcolor="rgba(245,158,11,0.1)",
))
# Actual line (blue, dashed with markers)
fig_burn.add_trace(go.Scatter(
x=dates, y=actual, name="Actual",
line=dict(color="#3b82f6", width=3, dash="dot"),
mode="lines+markers", marker=dict(size=5),
fill="tozeroy", fillcolor="rgba(59,130,246,0.08)",
))
# Add "Today" vertical line (manual shape for categorical x-axis)
if today_label in dates:
today_idx = dates.index(today_label)
fig_burn.add_shape(
type="line", x0=today_idx, x1=today_idx, y0=0, y1=1,
xref="x", yref="paper",
line=dict(color="#f59e0b", width=1, dash="dash"),
)
fig_burn.add_annotation(
x=today_label, y=0, yref="paper", text="Today",
showarrow=False, yshift=-18, font=dict(color="#ef4444", size=12),
)
# Annotate latest planned and actual values
last_actual = next((v for v in reversed(actual) if v is not None), None)
last_planned = next((v for v in reversed(planned_vals) if v is not None), None)
last_idx = next((i for i in range(len(actual) - 1, -1, -1) if actual[i] is not None), 0)
if last_planned is not None:
fig_burn.add_annotation(x=dates[last_idx], y=last_planned, text=f"<b>{last_planned:g} SP</b>",
showarrow=False, xshift=50, font=dict(color="#f59e0b", size=12))
if last_actual is not None:
fig_burn.add_annotation(x=dates[last_idx], y=last_actual, text=f"<b>{last_actual:g} SP</b>",
showarrow=False, xshift=50, font=dict(color="#3b82f6", size=12))
# Compute "behind by X SP vs ideal"
behind_sp = 0
if last_actual is not None and ideal:
ideal_now = ideal[last_idx] if last_idx < len(ideal) else 0
behind_sp = round(last_actual - ideal_now, 1)
fig_burn.update_layout(
title=dict(text="BURNDOWN CHART", font=dict(size=13, color="#60a5fa"), x=0.01),
height=350,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
xaxis=dict(showgrid=False, color="#94a3b8"),
yaxis=dict(showgrid=True, gridcolor="#1e293b", color="#94a3b8"),
legend=dict(orientation="h", yanchor="bottom", y=-0.25, x=0.5, xanchor="center",
font=dict(size=12)),
margin=dict(t=40, b=60, l=50, r=60),
)
st.plotly_chart(fig_burn, use_container_width=True)
if behind_sp > 0:
st.markdown(f'<p style="text-align:center;color:#ef4444;font-weight:600;">Behind by {behind_sp:g} SP vs ideal</p>', unsafe_allow_html=True)
elif behind_sp < 0:
st.markdown(f'<p style="text-align:center;color:#22c55e;font-weight:600;">Ahead by {abs(behind_sp):g} SP vs ideal</p>', unsafe_allow_html=True)
# ── Tab 2: Story Points ─────────────────────────────────────
with tab2:
# Compute per-user SP data
user_sp = fdf.groupby("Assignee").agg(
PlannedSP=("SP", "sum"),
DoneSP=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "Done"].sum()),
InProgressSP=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "In Progress"].sum()),
).reset_index()
user_sp["RemainingSP"] = user_sp["PlannedSP"] - user_sp["DoneSP"] - user_sp["InProgressSP"]
user_sp["SPpct"] = (user_sp["DoneSP"] / user_sp["PlannedSP"] * 100).fillna(0).round(0).astype(int)
user_sp = user_sp.sort_values("PlannedSP", ascending=False)
sp_left, sp_right = st.columns([3, 2])
# ── Left: SP by User table ───────────────────────────
with sp_left:
# Build HTML table rows β€” single-line to avoid Streamlit code-block rendering
table_rows = []
for _, row in user_sp.iterrows():
p = row["PlannedSP"]
d = row["DoneSP"]
ip = row["InProgressSP"]
rem = row["RemainingSP"]
pct = row["SPpct"]
d_w = round(d / p * 100) if p > 0 else 0
ip_w = round(ip / p * 100) if p > 0 else 0
pct_color = "#22c55e" if pct >= 75 else ("#f59e0b" if pct >= 40 else "#7c83ff")
tr = (
f'<tr style="border-bottom:1px solid #1e293b;">'
f'<td style="padding:10px 8px;color:#e2e8f0;font-weight:600;font-size:13px;max-width:150px;">{row["Assignee"]}</td>'
f'<td style="padding:10px 4px;width:50px;"><div style="width:8px;height:28px;border-radius:3px;background:linear-gradient(to top,#22c55e {d_w}%,#3b82f6 {d_w}% {d_w+ip_w}%,#334155 {d_w+ip_w}%);"></div></td>'
f'<td style="padding:10px 8px;color:#e2e8f0;font-weight:700;font-size:15px;text-align:center;">{p:g}</td>'
f'<td style="padding:10px 8px;color:#22c55e;font-size:14px;text-align:center;">{d:g}</td>'
f'<td style="padding:10px 8px;color:#7c83ff;font-size:14px;text-align:center;">{ip:g}</td>'
f'<td style="padding:10px 8px;color:#94a3b8;font-size:14px;text-align:center;">{rem:g}</td>'
f'<td style="padding:10px 8px;color:{pct_color};font-weight:600;font-size:14px;text-align:center;">{pct}%</td>'
f'</tr>'
)
table_rows.append(tr)
# Build full table as single-line concatenated HTML
sp_html = '<div style="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;">'
sp_html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">'
sp_html += '<span style="color:#60a5fa;font-size:13px;font-weight:600;letter-spacing:1px;">STORY POINTS BY USER</span></div>'
sp_html += '<div style="max-height:550px;overflow-y:auto;">'
sp_html += '<table style="width:100%;border-collapse:collapse;"><thead>'
sp_html += '<tr style="border-bottom:2px solid #334155;">'
sp_html += '<th style="text-align:left;padding:8px;color:#94a3b8;font-size:11px;font-weight:600;letter-spacing:0.5px;">USER</th>'
sp_html += '<th style="padding:8px;color:#94a3b8;font-size:11px;font-weight:600;">SP<br>BAR</th>'
sp_html += '<th style="text-align:center;padding:8px;color:#94a3b8;font-size:11px;font-weight:600;">PLANNED<br>SP</th>'
sp_html += '<th style="text-align:center;padding:8px;color:#94a3b8;font-size:11px;font-weight:600;">DONE<br>SP</th>'
sp_html += '<th style="text-align:center;padding:8px;color:#94a3b8;font-size:11px;font-weight:600;">IN PROGRESS<br>SP</th>'
sp_html += '<th style="text-align:center;padding:8px;color:#94a3b8;font-size:11px;font-weight:600;">REMAINING<br>SP</th>'
sp_html += '<th style="text-align:center;padding:8px;color:#94a3b8;font-size:11px;font-weight:600;">SP %</th>'
sp_html += '</tr></thead><tbody>'
sp_html += ''.join(table_rows)
sp_html += '</tbody></table></div></div>'
st.markdown(sp_html, unsafe_allow_html=True)
# ── Right: Story Points Summary ──────────────────────
with sp_right:
all_total_sp = fdf["SP"].sum()
all_done_sp = fdf[fdf["Category"] == "Done"]["SP"].sum()
all_ip_sp = fdf[fdf["Category"] == "In Progress"]["SP"].sum()
all_rem_sp = all_total_sp - all_done_sp - all_ip_sp
sp_comp = round(all_done_sp / all_total_sp * 100) if all_total_sp > 0 else 0
done_pct = round(all_done_sp / all_total_sp * 100) if all_total_sp > 0 else 0
ip_pct = round(all_ip_sp / all_total_sp * 100) if all_total_sp > 0 else 0
# Build SP Summary as single-line concatenated HTML
sum_html = '<div style="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:32px;text-align:center;">'
sum_html += '<div style="color:#60a5fa;font-size:13px;font-weight:600;letter-spacing:1px;margin-bottom:20px;text-align:left;">STORY POINTS SUMMARY</div>'
sum_html += f'<div style="font-size:56px;font-weight:700;color:#e2e8f0;margin:10px 0 4px;">{all_total_sp:g}</div>'
sum_html += '<div style="color:#94a3b8;font-size:14px;margin-bottom:30px;">Total Story Points</div>'
sum_html += '<div style="display:flex;justify-content:center;gap:40px;margin-bottom:28px;">'
sum_html += f'<div><div style="font-size:32px;font-weight:700;color:#22c55e;">{all_done_sp:g}</div><div style="color:#94a3b8;font-size:12px;">Done SP</div></div>'
sum_html += f'<div><div style="font-size:32px;font-weight:700;color:#3b82f6;">{all_ip_sp:g}</div><div style="color:#94a3b8;font-size:12px;">In Progress SP</div></div>'
sum_html += f'<div><div style="font-size:32px;font-weight:700;color:#94a3b8;">{all_rem_sp:g}</div><div style="color:#94a3b8;font-size:12px;">Remaining SP</div></div>'
sum_html += '</div>'
sum_html += f'<div style="background:#334155;border-radius:8px;height:28px;overflow:hidden;margin:0 20px 8px;"><div style="display:flex;height:100%;"><div style="width:{done_pct}%;background:#22c55e;"></div><div style="width:{ip_pct}%;background:#3b82f6;"></div></div></div>'
sum_html += f'<div style="display:flex;justify-content:space-between;margin:0 20px 4px;color:#94a3b8;font-size:12px;"><span>0%</span><span style="font-weight:600;color:#e2e8f0;">{sp_comp}% Complete</span><span>100%</span></div>'
sum_html += '<div style="display:flex;justify-content:center;gap:24px;margin-top:20px;font-size:12px;color:#94a3b8;">'
sum_html += '<span><span style="display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:2px;margin-right:4px;"></span>Done</span>'
sum_html += '<span><span style="display:inline-block;width:10px;height:10px;background:#3b82f6;border-radius:2px;margin-right:4px;"></span>In Progress</span>'
sum_html += '<span><span style="display:inline-block;width:10px;height:10px;background:#64748b;border-radius:2px;margin-right:4px;"></span>Remaining</span>'
sum_html += '</div></div>'
st.markdown(sum_html, unsafe_allow_html=True)
# ── Tab 3: All Issues ─────────────────────────────────────
with tab3:
st.subheader(f"All Issues ({len(sdf)})")
st.dataframe(
sdf[["Key", "Summary", "Status", "Assignee", "SP", "Priority", "Type", "Team", "Epic Name", "Updated"]],
use_container_width=True,
hide_index=True,
height=600,
column_config={
"Key": st.column_config.TextColumn("Key", width="small"),
"SP": st.column_config.NumberColumn("SP", format="%.1f"),
},
)
# ── Tab 4: Today's Changes ────────────────────────────────
with tab4:
change_rows = []
today_date = datetime.now().date()
for iss in changes_issues:
f = iss["fields"]
if iss.get("changelog"):
for h in iss["changelog"].get("histories", []):
hist_date = datetime.fromisoformat(h["created"].replace("Z", "+00:00")).date()
if hist_date != today_date:
continue
author = h.get("author", {}).get("displayName", "Unknown")
time_str = h["created"][11:16]
for item in h.get("items", []):
from_val = item.get("fromString") or "(empty)"
to_val = item.get("toString") or "(empty)"
change_rows.append({
"Time": time_str,
"Key": iss["key"],
"Summary": f.get("summary", "")[:60],
"Field": item.get("field", ""),
"From": from_val[:80],
"To": to_val[:80],
"Author": author,
})
st.subheader(f"Today's Changes ({len(change_rows)} records)")
if change_rows:
cdf = pd.DataFrame(change_rows).sort_values("Time", ascending=False)
st.dataframe(cdf, use_container_width=True, hide_index=True, height=600)
else:
st.info("No changes recorded today.")
if __name__ == "__main__":
main()