Build real Team tab parsing from LinkedIn's personal Aggregate Analytics export

Replaces the empty placeholder with actual parsing of the "AggregateAnalytics_
<Name>_<start>_<end>.xlsx" personal-profile export (DISCOVERY, ENGAGEMENT, TOP
POSTS, FOLLOWERS, and both DEMOGRAPHICS sheets). Weekly submissions are a new
~365-day trailing window each time, so the daily series (engagement, followers)
merge across every file on disk the same way the Page's own Followers export
does - newest file wins on an overlapping date.

Reposts vs original posts are distinguished from the post URL itself
(.../share-<id> vs .../ugcPost-<id>), since LinkedIn's "Engagements" column
doesn't separate them - this was the open design question about measuring
repost/comment activity, and the data turns out to answer it directly.

Verified against Mckinley's own first real export (2026-10-30): Impressions,
Followers, and Engagements all cross-check against the file's own DISCOVERY
summary row. The other four team members still show the "no export yet"
placeholder until their first Monday submission lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 10:23:59 +01:00
1 parent 436e52254a
commit db4587f75e
2 files changed
+246 -22

No files matched your search

+107 -8
View File
@@ -2682,23 +2682,122 @@ 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_engagement_figure(engagement_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"))
fig.update_layout(yaxis=dict(title="Impressions"),
yaxis2=dict(title="Engagements", overlaying="y", side="right"),
template=TMPL)
return fig
def _team_followers_figure(followers_daily_df):
fig = px.bar(followers_daily_df, x="Date", y="New followers", template=TMPL)
fig.update_traces(marker_color=GREEN)
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")
rows = top_posts_df.copy()
rows["Post"] = rows.apply(
lambda r: html.A("View post ↗", href=r["Post URL"], target="_blank", rel="noopener noreferrer"),
axis=1)
rows["Type"] = rows["Is Repost"].map({True: "Repost", False: "Original"}).fillna("Unknown")
rows["Post Publish Date"] = rows["Post Publish Date"].dt.strftime("%b %d, %Y")
table_rows = [
html.Tr([
html.Td(r["Post Publish Date"]),
html.Td(dbc.Badge(r["Type"], color="secondary" if r["Type"] == "Repost" else "primary")),
html.Td(int(r["Impressions"]) if pd.notna(r["Impressions"]) else "—"),
html.Td(int(r["Engagements"]) if pd.notna(r["Engagements"]) else "—"),
html.Td(r["Post"]),
]) for _, r in rows.iterrows()
]
return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Date", "Type", "Impressions", "Engagements", "Link"]])),
html.Tbody(table_rows),
], bordered=True, size="sm", style={"fontSize": "0.85rem"})
def _team_demographics_table(demo_df):
if demo_df.empty:
return html.Div("No data.", className="text-muted")
rows = [html.Tr([html.Td(r["Top Demographics"]), html.Td(r["Value"]), html.Td(f"{r['Percentage']:.0f}%")])
for _, r in demo_df.iterrows()]
return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Category", "Value", "%"]])),
html.Tbody(rows),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
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")
source_note = html.Div(
f"Source: {', '.join(data['source_files'])} — window {discovery.get('date_range', '')}",
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),
dbc.Col(section("Daily New Followers",
G(_team_followers_figure(data["followers_daily"]), style={"height": "260px"})), md=6),
], className="gx-3 mb-3")
posts_row = dbc.Row([
dbc.Col(section("Posts This Window — Originals vs Reposts",
_team_top_posts_table(data["top_posts"])), md=12),
], className="gx-3 mb-3")
demo_row = dbc.Row([
dbc.Col(section("Audience Demographics (who sees it)",
_team_demographics_table(data["audience_demographics"])), md=6),
dbc.Col(section("Content Demographics (who engages)",
_team_demographics_table(data["content_demographics"])), md=6),
], className="gx-3")
return html.Div([stat_row, source_note, charts_row, posts_row, demo_row])
@app.callback(
Output("team-content-area", "children"),
Input("team-member-select", "value"),
)
def _render_team_tab(name):
"""Starts empty for everyone - personal-profile exports begin December 2026 (see
team_exports.py). Shows the same clean "nothing yet" treatment as the Engagement
Response tab's theme/conversation cards until a person's real data lands."""
data = team_exports.exports_for(name)
"""Mckinley's own export landed 2026-10-30 (see team_exports.py); Aleksandra,
Daniel, Niklas, and Peter are still pending their first Monday submission.
Shows the same clean "nothing yet" treatment as the Engagement Response tab
until a given person's first export lands - no code change needed when it does,
team_exports.load_person_data() picks it up automatically by filename."""
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data:
return dbc.Alert(
f"No personal LinkedIn export data for {name} yet - this starts December 2026. "
"Add an entry to team_exports.TEAM_EXPORTS as each person's export data comes in, "
"no other code changes needed for it to show up here.",
f"No personal LinkedIn export data for {name} yet. Drop their "
f"\"AggregateAnalytics_{name}_...\" export into the project folder and it will "
"show up here automatically on the next refresh - no code change needed.",
color="info",
)
return html.Div(f"Export data for {name}: {data}")
return _build_person_dashboard(name, data)
@app.callback(
+139 -14
View File
@@ -1,15 +1,27 @@
"""Per-team-member personal LinkedIn export data, starting December 2026. The Page-level
exports (Content/Followers/Visitors/Competitors) only cover the TXproducts Company Page -
this is for each team member's own personal profile activity (their own post performance,
follower growth, etc.), tracked separately starting when that analysis begins.
"""Per-team-member personal LinkedIn export data, starting with Mckinley's own export
on 2026-10-30 (Aleksandra, Daniel, Niklas, Peter to follow, submitted every Monday).
Empty until December 2026 - add each person's export data here as it starts coming in,
no other code changes needed for it to show up in the Team tab. The exact per-person
schema isn't fixed yet (personal-profile exports aren't the same shape as the Page's own
exports), so this starts as a simple hand-maintained dict keyed by name; replace a
person's entry with whatever structure their real export data turns out to need.
The Page-level exports (Content/Followers/Visitors/Competitors) only cover the
TXproducts Company Page - this parses each team member's own "Aggregate Analytics"
export instead (LinkedIn's personal-profile analytics download: DISCOVERY, ENGAGEMENT,
TOP POSTS, FOLLOWERS, AUDIENCE DEMOGRAPHICS, CONTENT DEMOGRAPHICS sheets), named
"AggregateAnalytics_<Full Name>_<start>_<end>.xlsx". Each weekly submission is a new
~365-day trailing window that heavily overlaps the previous one, so the daily series
(ENGAGEMENT, FOLLOWERS) are merged across every file on disk the same way the Page's
own Followers export is - newest file wins on an overlapping date - while the
point-in-time snapshots (TOP POSTS, both DEMOGRAPHICS sheets, the running follower
total) always come from the single newest file.
TEAM_MEMBERS is the roster shown in the Team tab's dropdown regardless of whether a
person has submitted an export yet; load_person_data() returns None for anyone who
hasn't, and app.py renders a placeholder for that case.
"""
import os
import re
import pandas as pd
TEAM_MEMBERS = [
"Peter Gaal",
"Daniel Schmidt",
@@ -18,10 +30,123 @@ TEAM_MEMBERS = [
"Mckinley Black",
]
# "Peter Gaal": {"exports": [...], "notes": "..."},
TEAM_EXPORTS = {
}
def _is_personal_export(fname, name):
lower = fname.lower()
if lower.startswith("~$") or lower.startswith("."):
return False
if not lower.startswith("aggregateanalytics_"):
return False
if not lower.endswith((".xlsx", ".xls")):
return False
return name.lower() in lower
def exports_for(name):
return TEAM_EXPORTS.get(name)
def _find_person_files(name, data_dir, upload_dir):
candidates = []
for folder in (data_dir, upload_dir):
if not os.path.isdir(folder):
continue
for fname in os.listdir(folder):
if _is_personal_export(fname, name):
candidates.append(os.path.join(folder, fname))
return sorted(set(candidates), key=os.path.getmtime)
def _parse_discovery(fp):
raw = pd.read_excel(fp, sheet_name="DISCOVERY", header=None)
out = {"date_range": raw.iloc[0, 1] if len(raw) else None}
for i in range(1, len(raw)):
key, val = raw.iloc[i, 0], raw.iloc[i, 1]
if pd.notna(key):
out[key] = int(val)
return out
def _parse_engagement(fp):
df = pd.read_excel(fp, sheet_name="ENGAGEMENT")
df["Date"] = pd.to_datetime(df["Date"])
df["Impressions"] = pd.to_numeric(df["Impressions"])
df["Engagements"] = pd.to_numeric(df["Engagements"])
return df
def _parse_followers(fp):
raw = pd.read_excel(fp, sheet_name="FOLLOWERS", header=None)
total = int(raw.iloc[0, 1])
daily = pd.read_excel(fp, sheet_name="FOLLOWERS", header=2)
daily["Date"] = pd.to_datetime(daily["Date"])
daily["New followers"] = pd.to_numeric(daily["New followers"])
return total, daily
_POST_TYPE_RE = re.compile(r"-(share|ugcpost)-\d+-", re.I)
def _post_is_repost(url):
"""LinkedIn's own URL convention: .../posts/<slug>-share-<id>-<code> is a repost
of someone else's content, .../posts/<slug>-ugcPost-<id>-<code> is an original
post - the only way to tell them apart, since "Engagements" doesn't split out
which activity type a post is."""
m = _POST_TYPE_RE.search(url or "")
return m.group(1).lower() == "share" if m else None
def _parse_top_posts(fp):
raw = pd.read_excel(fp, sheet_name="TOP POSTS", header=None)
body = raw.iloc[3:]
eng = body.iloc[:, [0, 1, 2]].dropna(how="all")
eng.columns = ["Post URL", "Post Publish Date", "Engagements"]
imp = body.iloc[:, [4, 5, 6]].dropna(how="all")
imp.columns = ["Post URL", "Post Publish Date", "Impressions"]
merged = pd.merge(eng, imp[["Post URL", "Impressions"]], on="Post URL", how="outer")
if merged.empty:
return merged
merged["Post Publish Date"] = pd.to_datetime(merged["Post Publish Date"])
merged["Engagements"] = pd.to_numeric(merged["Engagements"])
merged["Impressions"] = pd.to_numeric(merged["Impressions"])
merged["Is Repost"] = merged["Post URL"].apply(_post_is_repost)
return merged.sort_values("Post Publish Date", ascending=False).reset_index(drop=True)
def _parse_demographics(fp, sheet_name):
df = pd.read_excel(fp, sheet_name=sheet_name)
df["Percentage"] = pd.to_numeric(df["Percentage"].astype(str).str.rstrip("%"))
return df
def load_person_data(name, data_dir, upload_dir):
files = _find_person_files(name, data_dir, upload_dir)
if not files:
return None
eng_frames, fol_frames = [], []
for fp in files:
mtime = os.path.getmtime(fp)
e = _parse_engagement(fp)
e["_src"] = mtime
eng_frames.append(e)
_, daily = _parse_followers(fp)
daily["_src"] = mtime
fol_frames.append(daily)
engagement = (pd.concat(eng_frames)
.sort_values("_src").drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"]).sort_values("Date").reset_index(drop=True))
followers_daily = (pd.concat(fol_frames)
.sort_values("_src").drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"]).sort_values("Date").reset_index(drop=True))
latest = files[-1]
followers_total, _ = _parse_followers(latest)
return {
"discovery": _parse_discovery(latest),
"engagement": engagement,
"followers_daily": followers_daily,
"followers_total": followers_total,
"top_posts": _parse_top_posts(latest),
"audience_demographics": _parse_demographics(latest, "AUDIENCE DEMOGRAPHICS"),
"content_demographics": _parse_demographics(latest, "CONTENT DEMOGRAPHICS"),
"source_files": [os.path.basename(fp) for fp in files],
}