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:
1 parent
38101bde6f
commit
0c6baad4f1
2 files changed
+130
-8
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user