Team tab: real KPI row matching Overview's style, repost/original post markers

Replaces the ad-hoc stat cards with the same kpi() component the Overview tab
uses (Starting/Total/New Followers, Total Impressions, Members Reached, Best
Post Real Eng., Avg. Eng. Rate) - same visual language across the dashboard,
and "New Followers" in the window is the growth KPI that was missing.

The daily Impressions/Engagements chart now overlays star/diamond markers on
each post's publish day, split by Original vs Repost (mirroring the Page's
own mark_posts() star overlay) - the one thing this export doesn't give you
anywhere else as a labeled field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 11:00:28 +01:00
1 parent 514e21952d
commit 38101bde6f
1 file changed
+75 -21
+75 -21
View File
@@ -2682,20 +2682,60 @@ def _switch_to_insights_page(pathname, selected_month):
return classes + [content, hero_title, _hero_subtitle_children(active), _hero_actions(active, month_value)]
def _team_stat_tile(value, label):
return dbc.Col(dbc.Card(dbc.CardBody([
html.Div(str(value), style={"fontSize": "1.8rem", "fontWeight": "800", "color": BLUE}),
html.Div(label, style={"fontSize": "0.78rem", "color": "#6b7a8d"}),
]), className="shadow-sm text-center"), md=3)
def _team_kpi_row(tiles):
"""Same flex-row-of-kpi()-cards pattern as the Overview tab's own KPI row
(Starting/Total/New Followers, Total Impressions, Best Post Real Eng., Avg.
Eng. Rate) - same component, same visual language, just personal-profile
metrics instead of Page-level ones."""
return html.Div([html.Div(t, style={"flex": "1", "minWidth": "0"}) for t in tiles],
style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"})
def _team_engagement_figure(engagement_df):
def _team_engagement_rate_stats(top_posts_df):
"""(best_pct, avg_pct, post_count) - engagement rate per post is Engagements /
Impressions, the same definition as the Page's own Avg. Eng. Rate tile. None for
either figure if there's no post with nonzero impressions to compute a rate from."""
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 _team_mark_posts(fig, engagement_df, top_posts_df):
"""Same star-marker-on-publish-day idea as the Page's own mark_posts(), split into
two traces (Original vs Repost) since that distinction - not available anywhere
else in this export - is the whole point of tracking it here."""
if top_posts_df.empty:
return fig
day_impressions = engagement_df.set_index("Date")["Impressions"]
matched = top_posts_df[top_posts_df["Post Publish Date"].isin(engagement_df["Date"])]
specs = [(False, "Original post", "star", BLUE), (True, "Repost", "diamond", ORANGE)]
for is_repost, label, symbol, color in specs:
subset = matched[matched["Is Repost"] == is_repost]
if subset.empty:
continue
fig.add_trace(go.Scatter(
x=subset["Post Publish Date"],
y=[day_impressions.get(d, 0) for d in subset["Post Publish Date"]],
mode="markers",
marker=dict(symbol=symbol, size=13, color=color, line=dict(width=1, color="white")),
name=label,
hovertemplate=f"<b>{label} — " + "%{x|%b %d, %Y}</b><extra></extra>",
))
return fig
def _team_engagement_figure(engagement_df, top_posts_df):
fig = go.Figure()
fig.add_trace(go.Scatter(x=engagement_df["Date"], y=engagement_df["Impressions"],
name="Impressions", mode="lines", line=dict(color=BLUE, width=2)))
fig.add_trace(go.Scatter(x=engagement_df["Date"], y=engagement_df["Engagements"],
name="Engagements", mode="lines", line=dict(color=ORANGE, width=2),
yaxis="y2"))
_team_mark_posts(fig, engagement_df, top_posts_df)
fig.update_layout(yaxis=dict(title="Impressions"),
yaxis2=dict(title="Engagements", overlaying="y", side="right"),
template=TMPL)
@@ -2745,21 +2785,32 @@ def _team_demographics_table(demo_df):
def _build_person_dashboard(name, data):
discovery = data["discovery"]
total_engagements = int(data["engagement"]["Engagements"].sum())
stat_row = dbc.Row([
_team_stat_tile(discovery.get("Impressions", 0), "Impressions"),
_team_stat_tile(discovery.get("Members reached", 0), "Members Reached"),
_team_stat_tile(data["followers_total"], "Total Followers"),
_team_stat_tile(total_engagements, "Engagements"),
], className="gx-3 mb-3")
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())
starting_followers = data["followers_total"] - new_followers_window
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("Total Followers", f"{data['followers_total']:,}", f"as of {window_end}", TEAL),
kpi("New Followers", f"{new_followers_window:,}", window_label),
kpi("Total Impressions", f"{discovery.get('Impressions', 0):,}", window_label),
kpi("Members Reached", f"{discovery.get('Members reached', 0):,}", window_label),
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 "—",
"raw, personal profile (organic)", TEAL),
])
source_note = html.Div(
f"Source: {', '.join(data['source_files'])} — window {discovery.get('date_range', '')}",
f"Source: {', '.join(data['source_files'])} — window {window_label}",
style={"fontSize": "0.72rem", "color": "#6b7a8d", "marginBottom": "0.75rem"})
charts_row = dbc.Row([
dbc.Col(section("Daily Impressions & Engagements",
G(_team_engagement_figure(data["engagement"]), style={"height": "260px"})), md=6),
G(_team_engagement_figure(data["engagement"], data["top_posts"]), style={"height": "260px"})), md=6),
dbc.Col(section("Daily New Followers",
G(_team_followers_figure(data["followers_daily"]), style={"height": "260px"})), md=6),
], className="gx-3 mb-3")
@@ -2788,12 +2839,15 @@ def _build_empty_person_dashboard(name):
table, demographics cards) so every team member's tab looks consistent before
their first export lands, instead of a lone text alert - each box just shows a
"No data yet" placeholder in place of real content."""
stat_row = dbc.Row([
_team_stat_tile("—", "Impressions"),
_team_stat_tile("—", "Members Reached"),
_team_stat_tile("—", "Total Followers"),
_team_stat_tile("—", "Engagements"),
], className="gx-3 mb-3")
stat_row = _team_kpi_row([
kpi("Starting Followers", "—", "", "#999"),
kpi("Total Followers", "—", "", TEAL),
kpi("New Followers", "—", ""),
kpi("Total Impressions", "—", ""),
kpi("Members Reached", "—", ""),
kpi("Best Post Real Eng.", "—", "", GREEN),
kpi("Avg. Eng. Rate", "—", "", TEAL),
])
source_note = dbc.Alert(
f"No personal LinkedIn export data for {name} yet. Drop their "