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:
1 parent
514e21952d
commit
38101bde6f
1 file changed
+75
-21
@@ -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 "
|
||||
|
||||
Reference in new issue
Block a user