Spaces:
Running
Running
| """ | |
| 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() | |
| 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 | |
| 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 | |
| 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} → {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 • {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() | |