Team tab: match Overview's exact 8-tile KPI row, fix baseline date, move report button to hero

Three corrections against the Overview tab's real KPI row:
- Tile set now matches exactly (Total Page Views / Total Clicks included as
  "not available for personal profiles" rather than silently substituting
  Members Reached in their place).
- Starting/New Followers now anchor to the same BASELINE_DATE (Jun 16, 2026)
  the Overview tab uses, not the export's own ~365-day rolling window edge -
  that was showing a meaningless "as of last October" date. Passed into
  build_person_report_docx() as a parameter (team_exports.py stays free of
  any app.py import, same rule report.py already follows).
- Download Report button moved from the tab's own row into the hero banner's
  upper-right actions slot, same spot the Followers tab's Directory/Study
  links live - the actual "upper right" of the page, not just of the tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 11:06:37 +01:00
1 parent 38101bde6f
commit 0c6baad4f1
2 files changed
+130 -8

No files matched your search

+42 -8
View File
@@ -2492,6 +2492,13 @@ def _hero_actions(active, month_value=None):
]
if active in _REPORT_NAV_ACTIVE_KEYS:
return _report_section_nav(active, month_value)
if active == "team":
return [
html.Button("Download Report (.docx)", id="team-download-btn", n_clicks=0,
disabled=False, style={**_REPORT_HERO_LINK_STYLE, "border": "1px solid rgba(255,255,255,0.35)",
"cursor": "pointer"}),
dcc.Download(id="team-download"),
]
return []
_SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR)
@@ -2788,16 +2795,23 @@ def _build_person_dashboard(name, data):
window_label = discovery.get("date_range", "")
window_start, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "")
new_followers_window = int(data["followers_daily"]["New followers"].sum())
# Same BASELINE_DATE anchor as the Overview tab's own Starting/New Followers tiles,
# not the export's own ~365-day rolling window edge - keeps the two tabs comparable
# instead of each person showing a different, meaningless "started a year ago" date.
baseline_ts = pd.Timestamp(BASELINE_DATE)
since_baseline = data["followers_daily"][data["followers_daily"]["Date"] >= baseline_ts]
new_followers_window = int(since_baseline["New followers"].sum())
starting_followers = data["followers_total"] - new_followers_window
baseline_label = baseline_ts.strftime("%b %d, %Y")
best_eng_pct, avg_eng_pct, rated_post_count = _team_engagement_rate_stats(data["top_posts"])
stat_row = _team_kpi_row([
kpi("Starting Followers", f"{starting_followers:,}", f"as of {window_start}", "#999"),
kpi("Starting Followers", f"{starting_followers:,}", f"as of {baseline_label}", "#999"),
kpi("Total Followers", f"{data['followers_total']:,}", f"as of {window_end}", TEAL),
kpi("New Followers", f"{new_followers_window:,}", window_label),
kpi("New Followers", f"{new_followers_window:,}", f"{baseline_label} – {window_end}"),
kpi("Total Impressions", f"{discovery.get('Impressions', 0):,}", window_label),
kpi("Members Reached", f"{discovery.get('Members reached', 0):,}", window_label),
kpi("Total Page Views", "—", "not available for personal profiles"),
kpi("Total Clicks", "—", "not available for personal profiles"),
kpi("Best Post Real Eng.", f"{best_eng_pct:.2f}%" if best_eng_pct is not None else "—",
f"across {rated_post_count} posts", GREEN),
kpi("Avg. Eng. Rate", f"{avg_eng_pct:.2f}%" if avg_eng_pct is not None else "—",
@@ -2844,7 +2858,8 @@ def _build_empty_person_dashboard(name):
kpi("Total Followers", "—", "", TEAL),
kpi("New Followers", "—", ""),
kpi("Total Impressions", "—", ""),
kpi("Members Reached", "—", ""),
kpi("Total Page Views", "—", "not available for personal profiles"),
kpi("Total Clicks", "—", "not available for personal profiles"),
kpi("Best Post Real Eng.", "—", "", GREEN),
kpi("Avg. Eng. Rate", "—", "", TEAL),
])
@@ -2874,6 +2889,7 @@ def _build_empty_person_dashboard(name):
@app.callback(
Output("team-content-area", "children"),
Output("team-download-btn", "disabled"),
Input("team-member-select", "value"),
)
def _render_team_tab(name):
@@ -2882,11 +2898,29 @@ def _render_team_tab(name):
get the same box layout via _build_empty_person_dashboard rather than a lone
text alert, so the tab looks consistent across all five people before and after
data lands. No code change needed when a person's first export arrives -
team_exports.load_person_data() picks it up automatically by filename."""
team_exports.load_person_data() picks it up automatically by filename. The
Download Report button is disabled whenever there's nothing real to send yet."""
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data:
return _build_empty_person_dashboard(name)
return _build_person_dashboard(name, data)
return _build_empty_person_dashboard(name), True
return _build_person_dashboard(name, data), False
@app.callback(
Output("team-download", "data"),
Input("team-download-btn", "n_clicks"),
State("team-member-select", "value"),
prevent_initial_call=True,
)
def _download_team_report(_n_clicks, name):
"""Button is disabled (see _render_team_tab) whenever load_person_data() would
return None, so this always has real data to build from."""
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data:
raise PreventUpdate
docx_bytes = team_exports.build_person_report_docx(name, data, BASELINE_DATE)
filename = f"LinkedIn_Report_{name.replace(' ', '_')}.docx"
return dcc.send_bytes(lambda buf: buf.write(docx_bytes), filename=filename)
@app.callback(
+88
View File
@@ -17,10 +17,15 @@ person has submitted an export yet; load_person_data() returns None for anyone w
hasn't, and app.py renders a placeholder for that case.
"""
import io
import os
import re
import pandas as pd
from docx import Document
from docx.oxml.ns import qn
import report
TEAM_MEMBERS = [
"Peter Gaal",
@@ -150,3 +155,86 @@ def load_person_data(name, data_dir, upload_dir):
"content_demographics": _parse_demographics(latest, "CONTENT DEMOGRAPHICS"),
"source_files": [os.path.basename(fp) for fp in files],
}
def _engagement_rate_stats(top_posts_df):
if top_posts_df.empty:
return None, None, 0
rated = top_posts_df[top_posts_df["Impressions"] > 0].copy()
if rated.empty:
return None, None, 0
rated["rate"] = rated["Engagements"] / rated["Impressions"] * 100
return rated["rate"].max(), rated["rate"].mean(), len(rated)
def build_person_report_docx(name, data, baseline_date):
"""A send-to-the-person summary of their own analyzed LinkedIn data - Mckinley's
own review, not a raw dump of the export file. Reuses report._docx_table/_docx_shade
(the same table styling as the Page-level monthly report) rather than duplicating
that OOXML-shading logic here. baseline_date is app.py's BASELINE_DATE, passed in
rather than imported so this stays free of any app.py dependency (same reasoning
as report.py's own "no import-time dependency on app.py" rule) - Starting/New
Followers use that same fixed anchor as the Overview tab's own tiles, not the
export's own ~365-day rolling window edge."""
discovery = data["discovery"]
window_label = discovery.get("date_range", "")
_, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "")
baseline_ts = pd.Timestamp(baseline_date)
since_baseline = data["followers_daily"][data["followers_daily"]["Date"] >= baseline_ts]
new_followers_window = int(since_baseline["New followers"].sum())
starting_followers = data["followers_total"] - new_followers_window
baseline_label = baseline_ts.strftime("%b %d, %Y")
best_eng, avg_eng, rated_count = _engagement_rate_stats(data["top_posts"])
doc = Document()
zoom_el = doc.settings.element.find(qn("w:zoom"))
if zoom_el is not None and zoom_el.get(qn("w:percent")) is None:
zoom_el.set(qn("w:percent"), "100")
doc.add_heading(f"{name} — LinkedIn Personal Profile Report", level=0)
doc.add_paragraph(f"Window: {window_label}")
doc.add_heading("Summary", level=1)
report._docx_table(doc, [
["Metric", "Value", "Note"],
["Starting Followers", f"{starting_followers:,}", f"as of {baseline_label}"],
["Total Followers", f"{data['followers_total']:,}", f"as of {window_end}"],
["New Followers", f"{new_followers_window:,}", f"{baseline_label} – {window_end}"],
["Total Impressions", f"{discovery.get('Impressions', 0):,}", window_label],
["Members Reached", f"{discovery.get('Members reached', 0):,}", window_label],
["Best Post Real Eng.", f"{best_eng:.2f}%" if best_eng is not None else "—",
f"across {rated_count} posts"],
["Avg. Eng. Rate", f"{avg_eng:.2f}%" if avg_eng is not None else "—", "raw, organic"],
])
doc.add_heading("Posts This Window", level=1)
posts = data["top_posts"]
if posts.empty:
doc.add_paragraph("No posts in this window.")
else:
rows = [["Date", "Type", "Impressions", "Engagements", "Link"]]
for _, r in posts.iterrows():
post_type = "Repost" if r["Is Repost"] else ("Original" if r["Is Repost"] is False else "Unknown")
rows.append([
r["Post Publish Date"].strftime("%b %d, %Y"),
post_type,
int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—",
int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—",
r["Post URL"],
])
report._docx_table(doc, rows)
for heading, key in [("Audience Demographics (who sees it)", "audience_demographics"),
("Content Demographics (who engages)", "content_demographics")]:
doc.add_heading(heading, level=1)
demo = data[key]
if demo.empty:
doc.add_paragraph("No data.")
continue
rows = [["Category", "Value", "%"]]
rows += [[r["Top Demographics"], r["Value"], f"{r['Percentage']:.0f}%"] for _, r in demo.iterrows()]
report._docx_table(doc, rows)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()