Team tab: June 2026 = 0 baseline, calendar grid + split charts to match Overview
Starting Followers is now a fixed, human-confirmed anchor (everyone started this work June 1, 2026 with 0 followers) instead of borrowing the Page's own unrelated BASELINE_DATE or the export's meaningless ~365-day window edge. Lives in team_exports.TEAM_BASELINE_DATE/TEAM_BASELINE_FOLLOWERS so both the dashboard and the downloadable report use the same anchor. Rebuilt the chart section to mirror the Overview tab's actual layout: a static Follower Gains calendar grid (reuses the Page's own _monthly_summary/_month_box_* helpers, renamed columns to fit) + New Followers per Day bar chart side by side, then separate Impressions and Daily Engagement Rate line cards instead of one combined dual-axis chart. Daily CTR and Page Views (Desktop/Mobile) are left out entirely - personal-profile exports have no click or device-level data at all, so those two Overview cards have no real equivalent here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c6baad4f1
commit
0ed6e6c457
2 files changed
+83
-34
No files matched your search
@@ -2735,18 +2735,29 @@ def _team_mark_posts(fig, engagement_df, top_posts_df):
|
||||
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)
|
||||
return fig
|
||||
def _team_calendar_grid(followers_daily_df, top_posts_df):
|
||||
"""Static version of the Overview/Followers tabs' "Follower Gains — Calendar" —
|
||||
same month-box coloring (_monthly_summary/_month_box_style/_month_box_children),
|
||||
just without the click-to-expand day tiles, which would need their own per-person
|
||||
callback wiring (those are registered once at app-build time, not per dropdown
|
||||
selection) to do properly. Reuses the Page's own helpers by renaming this export's
|
||||
columns to match what they expect (Date/Total followers, Created date)."""
|
||||
fts_like = followers_daily_df.rename(columns={"New followers": "Total followers"})
|
||||
posts_like = top_posts_df.rename(columns={"Post Publish Date": "Created date"}) \
|
||||
if not top_posts_df.empty else top_posts_df
|
||||
years = _available_years(fts_like, posts_like)
|
||||
if not years:
|
||||
return html.Div("No follower data.", className="text-muted")
|
||||
year = years[0]
|
||||
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(fts_like, posts_like, year)
|
||||
boxes = [html.Div(_month_box_children(monthly_net, monthly_posts, months_with_data, m),
|
||||
style=_month_box_style(monthly_net, months_with_data, max_gain, m, selected=False))
|
||||
for m in range(1, 13)]
|
||||
return html.Div([
|
||||
html.Div(str(year), style={"fontWeight": "800", "fontSize": "0.85rem", "marginBottom": "4px"}),
|
||||
html.Div(boxes, style={"display": "flex", "gap": "6px"}),
|
||||
calendar_legend(),
|
||||
])
|
||||
|
||||
|
||||
def _team_followers_figure(followers_daily_df):
|
||||
@@ -2755,6 +2766,25 @@ def _team_followers_figure(followers_daily_df):
|
||||
return fig
|
||||
|
||||
|
||||
def _team_impressions_figure(engagement_df, top_posts_df):
|
||||
fig = px.line(engagement_df, x="Date", y="Impressions", template=TMPL)
|
||||
fig.update_traces(line=dict(color=BLUE, width=2))
|
||||
_team_mark_posts(fig, engagement_df, top_posts_df)
|
||||
return fig
|
||||
|
||||
|
||||
def _team_engagement_rate_figure(engagement_df):
|
||||
"""Engagements / Impressions per day, as a % — the personal-profile analog of the
|
||||
Page's own Daily Engagement Rate card. Days with 0 impressions are left out rather
|
||||
than shown as a misleading 0% (no reach that day, not zero engagement quality)."""
|
||||
rated = engagement_df[engagement_df["Impressions"] > 0].copy()
|
||||
rated["Eng. Rate"] = rated["Engagements"] / rated["Impressions"] * 100
|
||||
fig = px.line(rated, x="Date", y="Eng. Rate", template=TMPL)
|
||||
fig.update_traces(line=dict(color=GREEN, width=2))
|
||||
fig.update_layout(yaxis=dict(title="Eng. Rate (%)"))
|
||||
return fig
|
||||
|
||||
|
||||
def _team_top_posts_table(top_posts_df):
|
||||
if top_posts_df.empty:
|
||||
return html.Div("No posts in this window.", className="text-muted")
|
||||
@@ -2795,13 +2825,14 @@ def _build_person_dashboard(name, data):
|
||||
window_label = discovery.get("date_range", "")
|
||||
window_start, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "")
|
||||
|
||||
# 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)
|
||||
# team_exports.TEAM_BASELINE_DATE/TEAM_BASELINE_FOLLOWERS - everyone started this
|
||||
# work in June 2026 at 0 followers (confirmed directly), not the Page's own
|
||||
# BASELINE_DATE (unrelated to any individual) and not the export's own ~365-day
|
||||
# rolling window edge.
|
||||
baseline_ts = pd.Timestamp(team_exports.TEAM_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
|
||||
starting_followers = team_exports.TEAM_BASELINE_FOLLOWERS
|
||||
baseline_label = baseline_ts.strftime("%b %d, %Y")
|
||||
best_eng_pct, avg_eng_pct, rated_post_count = _team_engagement_rate_stats(data["top_posts"])
|
||||
|
||||
@@ -2822,11 +2853,18 @@ def _build_person_dashboard(name, data):
|
||||
f"Source: {', '.join(data['source_files'])} — window {window_label}",
|
||||
style={"fontSize": "0.72rem", "color": "#6b7a8d", "marginBottom": "0.75rem"})
|
||||
|
||||
calendar_row = dbc.Row([
|
||||
dbc.Col(section("Follower Gains — Calendar",
|
||||
_team_calendar_grid(data["followers_daily"], data["top_posts"])), md=8),
|
||||
dbc.Col(section("New Followers per Day",
|
||||
G(_team_followers_figure(data["followers_daily"]), style={"height": "240px"})), md=4),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
charts_row = dbc.Row([
|
||||
dbc.Col(section("Daily Impressions & Engagements",
|
||||
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),
|
||||
dbc.Col(section("Impressions",
|
||||
G(_team_impressions_figure(data["engagement"], data["top_posts"]), style={"height": "220px"})), md=6),
|
||||
dbc.Col(section("Daily Engagement Rate",
|
||||
G(_team_engagement_rate_figure(data["engagement"]), style={"height": "220px"})), md=6),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
posts_row = dbc.Row([
|
||||
@@ -2841,7 +2879,7 @@ def _build_person_dashboard(name, data):
|
||||
_team_demographics_table(data["content_demographics"])), md=6),
|
||||
], className="gx-3")
|
||||
|
||||
return html.Div([stat_row, source_note, charts_row, posts_row, demo_row])
|
||||
return html.Div([stat_row, source_note, calendar_row, charts_row, posts_row, demo_row])
|
||||
|
||||
|
||||
_TEAM_EMPTY_PLACEHOLDER = html.Div("No data yet", className="text-muted text-center",
|
||||
@@ -2870,9 +2908,14 @@ def _build_empty_person_dashboard(name):
|
||||
"fill in these boxes automatically on the next refresh - no code change needed.",
|
||||
color="info", style={"marginBottom": "0.75rem"})
|
||||
|
||||
calendar_row = dbc.Row([
|
||||
dbc.Col(section("Follower Gains — Calendar", _TEAM_EMPTY_PLACEHOLDER), md=8),
|
||||
dbc.Col(section("New Followers per Day", _TEAM_EMPTY_PLACEHOLDER), md=4),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
charts_row = dbc.Row([
|
||||
dbc.Col(section("Daily Impressions & Engagements", _TEAM_EMPTY_PLACEHOLDER), md=6),
|
||||
dbc.Col(section("Daily New Followers", _TEAM_EMPTY_PLACEHOLDER), md=6),
|
||||
dbc.Col(section("Impressions", _TEAM_EMPTY_PLACEHOLDER), md=6),
|
||||
dbc.Col(section("Daily Engagement Rate", _TEAM_EMPTY_PLACEHOLDER), md=6),
|
||||
], className="gx-3 mb-3")
|
||||
|
||||
posts_row = dbc.Row([
|
||||
@@ -2884,7 +2927,7 @@ def _build_empty_person_dashboard(name):
|
||||
dbc.Col(section("Content Demographics (who engages)", _TEAM_EMPTY_PLACEHOLDER), md=6),
|
||||
], className="gx-3")
|
||||
|
||||
return html.Div([stat_row, source_note, charts_row, posts_row, demo_row])
|
||||
return html.Div([stat_row, source_note, calendar_row, charts_row, posts_row, demo_row])
|
||||
|
||||
|
||||
@app.callback(
|
||||
@@ -2918,7 +2961,7 @@ def _download_team_report(_n_clicks, name):
|
||||
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)
|
||||
docx_bytes = team_exports.build_person_report_docx(name, data)
|
||||
filename = f"LinkedIn_Report_{name.replace(' ', '_')}.docx"
|
||||
return dcc.send_bytes(lambda buf: buf.write(docx_bytes), filename=filename)
|
||||
|
||||
|
||||
+14
-8
@@ -35,6 +35,14 @@ TEAM_MEMBERS = [
|
||||
"Mckinley Black",
|
||||
]
|
||||
|
||||
# Personal-profile baseline, distinct from the Page's own app.BASELINE_DATE (2026-06-16) -
|
||||
# that's when Page-level tracking began, unrelated to any individual's own history.
|
||||
# Mckinley confirmed directly: everyone started this work in June 2026 with 0 followers,
|
||||
# so this is a fixed, human-confirmed anchor the same way BASELINE_FOLLOWERS is for the
|
||||
# Page, not something derived from the export's own ~365-day rolling window edge.
|
||||
TEAM_BASELINE_DATE = "2026-06-01"
|
||||
TEAM_BASELINE_FOLLOWERS = 0
|
||||
|
||||
|
||||
def _is_personal_export(fname, name):
|
||||
lower = fname.lower()
|
||||
@@ -167,22 +175,20 @@ def _engagement_rate_stats(top_posts_df):
|
||||
return rated["rate"].max(), rated["rate"].mean(), len(rated)
|
||||
|
||||
|
||||
def build_person_report_docx(name, data, baseline_date):
|
||||
def build_person_report_docx(name, data):
|
||||
"""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."""
|
||||
that OOXML-shading logic here. Starting/New Followers use TEAM_BASELINE_DATE/
|
||||
TEAM_BASELINE_FOLLOWERS (everyone started this work in June 2026 at 0, confirmed
|
||||
directly), 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)
|
||||
baseline_ts = pd.Timestamp(TEAM_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
|
||||
starting_followers = TEAM_BASELINE_FOLLOWERS
|
||||
baseline_label = baseline_ts.strftime("%b %d, %Y")
|
||||
best_eng, avg_eng, rated_count = _engagement_rate_stats(data["top_posts"])
|
||||
|
||||
|
||||
Reference in new issue
Block a user