Add Reports tab: monthly campaign progress reports

New "Reports" tab for the 3-month LinkedIn campaign, combining dashboard
data with the Follower Directory rather than a standalone HTML/PDF
workflow. For each campaign month it shows KPI tiles (including a new
Monthly Growth Rate, this month's net new followers as a percentage of
the followers already on the page at the start of that month, resets
every month against its own starting count), the follower growth chart,
every post published that month, an Audience Composition breakdown
sourced live from the Follower Directory (sector/seniority/country, both
this month's cohort and the all-time snapshot), and curated monthly
narrative notes (features shipped, data quality fixes, Directory
updates, outstanding items). Reuses the existing reportlab PDF pipeline,
extended with the same sections, and mirrored in the static exporter
with live-only controls disabled.

New files: monthly_report_notes.py (per-month curated narrative,
July populated), follower_directory_data.py (parses the Directory's
DATA array directly so numbers can't drift from the hand-maintained
source of truth).

Also fixes three pre-existing bugs found while building this: average
engagement rate was displaying 100x too small (a 0-1 fraction shown
as-is instead of scaled to a percentage), the Sector/Seniority/Country
breakdown tables were silently truncated below their own stated totals
(a hardcoded top-5 display cap plus a separate top-10 country cap
inside composition_stats itself), and the static exporter had no
support for dbc.Table, dropping the <table> tag entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-08-05 09:56:19 +02:00
1 parent 8d54dde8d1
commit f9f27401db
6 files changed
+511 -31

No files matched your search

+219 -8
View File
@@ -12,11 +12,18 @@ import pandas as pd
import report
import growth
import visitor_history
import monthly_report_notes
import follower_directory_data
# Overridable via env var so the same code runs unmodified in Docker/internal deployments —
# defaults to this machine's path for local dev.
DATA_DIR = os.environ.get("LINKEDIN_DATA_DIR", "/Users/mckinleyblack/Documents")
UPLOAD_DIR = os.path.join(DATA_DIR, "linkedin_analytics")
ASSETS_DIR = os.path.join(UPLOAD_DIR, "assets")
# Loaded once at startup, not re-parsed per callback — re-run the app to pick up new
# assets/follower_directory.html entries, same as every other data source here.
DIRECTORY_DATA = follower_directory_data.load_directory(ASSETS_DIR)
# LinkedIn's export only gives daily gains, never an absolute follower total. This is
# the one manual anchor point: the count going into the very first day this export
@@ -1347,6 +1354,7 @@ def refresh_data():
TAB_CONTENT_MAP = {
"overview": overview, "content": content_tab, "followers": followers_tab,
"visitors": visitors_tab, "competitors": competitors_tab, "growth": growth_tab,
"reports": reports_tab,
}
@@ -2023,6 +2031,46 @@ def build_growth_tab():
], fluid=True, className="pb-3")
def _reports_month_options():
"""Only months with curated narrative notes are selectable here, not every month
with export data — this tab is about the campaign's written progress reports, not
a generic timeframe filter (that's what the sidebar's PDF control already does)."""
import calendar as calmod
opts = []
for key in sorted(monthly_report_notes.NOTES.keys()):
year, month = (int(p) for p in key.split("-"))
opts.append({"label": f"{calmod.month_name[month]} {year}", "value": key})
return opts
_REPORTS_MONTH_OPTIONS = _reports_month_options()
_REPORTS_DEFAULT_MONTH = _REPORTS_MONTH_OPTIONS[-1]["value"] if _REPORTS_MONTH_OPTIONS else None
reports_tab = dbc.Container([
html.Div([
html.Div([
html.Label("Reporting Month", style={"fontSize": "0.75rem", "fontWeight": "600",
"marginBottom": "4px", "display": "block"}),
dcc.Dropdown(
id="reports-month-select",
options=_REPORTS_MONTH_OPTIONS, value=_REPORTS_DEFAULT_MONTH,
clearable=False, searchable=False, style={"width": "260px"},
),
]),
html.Div([
html.Button("Download PDF", id="reports-download-btn", n_clicks=0,
style={"padding": "8px 16px",
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
"color": "white", "border": "none", "borderRadius": "8px",
"fontSize": "0.8rem", "fontWeight": "700", "cursor": "pointer"}),
dcc.Download(id="reports-download"),
]),
], style={"display": "flex", "justifyContent": "space-between", "alignItems": "flex-end",
"marginBottom": "1.25rem"}),
html.Div(id="reports-content-area"),
], fluid=True, className="pb-3")
refresh_data()
@@ -2040,6 +2088,7 @@ NAV_ITEMS = [
("visitors", "Visitors", visitors_tab),
("competitors", "Competitors", competitors_tab),
("growth", "Growth", growth_tab),
("reports", "Reports", reports_tab),
]
TAB_CONTENT_MAP = {key: content for key, _, content in NAV_ITEMS}
@@ -2050,6 +2099,7 @@ TAB_HERO = {
"visitors": ("Visitors", "Page views and unique visitors to the TXproducts LinkedIn Page, broken down by device and section."),
"competitors": ("Competitors", "Benchmarking TXproducts against tracked competitor pages on LinkedIn."),
"growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."),
"reports": ("Reports", "Monthly written progress reports for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory."),
}
GROWTH_METRIC_EXPLAINER = html.Div([
@@ -2505,6 +2555,18 @@ def _sync_content_charts(selected_month):
)
def _build_report_ctx():
"""Shared by the sidebar's generic PDF control and the Reports tab, so both build
the exact same ctx_data dict for report.build_report_pdf/compute_period_stats."""
return {
"D": D, "posts": posts, "comp_ordered": comp_ordered,
"current_total_followers": current_total_followers,
"daily_followers_figure": daily_followers_figure,
"std_fn": _std, "TMPL": TMPL, "FORMAT_COLORS": FORMAT_COLORS,
"growth_snapshot": GROWTH_SNAPSHOT, "directory_data": DIRECTORY_DATA,
}
@app.callback(
Output("report-download", "data"),
Input("generate-report-btn", "n_clicks"),
@@ -2512,19 +2574,168 @@ def _sync_content_charts(selected_month):
prevent_initial_call=True,
)
def _generate_report(_n_clicks, timeframe):
ctx_data = {
"D": D, "posts": posts, "comp_ordered": comp_ordered,
"current_total_followers": current_total_followers,
"daily_followers_figure": daily_followers_figure,
"std_fn": _std, "TMPL": TMPL, "FORMAT_COLORS": FORMAT_COLORS,
"growth_snapshot": GROWTH_SNAPSHOT,
}
pdf_bytes = report.build_report_pdf(ctx_data, timeframe)
pdf_bytes = report.build_report_pdf(_build_report_ctx(), timeframe)
label = next((o["label"] for o in report.report_month_options(D["fts"]) if o["value"] == timeframe), timeframe)
safe_label = label.replace(" ", "_")
filename = f"TXproducts_LinkedIn_Report_{safe_label}.pdf"
return dcc.send_bytes(lambda buf: buf.write(pdf_bytes), filename=filename)
def _narrative_section(title, items):
if not items:
return html.Div()
return html.Div([
html.H5(title, style={"marginTop": "18px", "marginBottom": "8px", "color": "#1b3a5c"}),
html.Ul([html.Li(item, style={"marginBottom": "4px"}) for item in items],
style={"fontSize": "0.85rem", "paddingLeft": "1.1rem"}),
])
def _composition_table(counter, header_left, top_n=None):
"""No truncation by default, sector/seniority/function are a small, fixed taxonomy
(see project memory's classification categories), and country is uncapped by
composition_stats() for the same reason, so showing everything keeps the table's
own sum matching the cohort total instead of silently dropping tail categories."""
items = counter.most_common(top_n) if hasattr(counter, "most_common") else list(counter)[:top_n]
return dbc.Table([
html.Thead(html.Tr([html.Th(header_left), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in items]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
def _reports_posts_table(posts_f):
"""Every post in the period, listed chronologically, not just a top-N by engagement.
A monthly campaign report should show the full picture of what was published, since
the point is a complete record, not a highlights reel."""
if not len(posts_f):
return html.Div("No posts published in this period.", className="text-muted")
all_posts = posts_f.sort_values("Created date")
return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in
["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]])),
html.Tbody([
html.Tr([
html.Td(str(r["Short title"])[:60]),
html.Td(r["Created date"].strftime("%d/%m/%Y")),
html.Td(r["Content Type"]),
html.Td(f"{int(r['Impressions']):,}"),
html.Td(f"{r['Eng %']:.1f}%"),
html.Td(f"{r['CTR %']:.1f}%"),
]) for _, r in all_posts.iterrows()
]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
@app.callback(
Output("reports-content-area", "children"),
Input("reports-month-select", "value"),
)
def _render_reports_tab(month_value):
if not month_value:
return dbc.Alert("No campaign months have been written up yet.", color="info")
stats = report.compute_period_stats(_build_report_ctx(), month_value)
notes = monthly_report_notes.NOTES.get(month_value)
fts_f, posts_f = stats["fts_f"], stats["posts_f"]
kpi_row = html.Div([
html.Div(kpi("Total Followers", f"{current_total_followers:,}", "as of latest export"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("New Followers", f"{stats['new_followers']:,}", stats["period_label"]),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Posts Published", f"{stats['posts_published']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Impressions", f"{stats['total_impressions']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Page Views", f"{stats['total_page_views']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Best Post Eng.", f"{stats['best_eng']:.1f}%"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Avg. Eng. Rate", f"{stats['avg_eng']:.2f}%" if stats["avg_eng"] else "—"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi(
"Monthly Growth Rate",
f"{stats['monthly_growth_rate']:+.1f}%" if stats["monthly_growth_rate"] is not None else "—",
(f"+{stats['new_followers']:,} vs {stats['start_of_month_followers']:,} at start "
f"of {stats['period_label']}") if stats["start_of_month_followers"] is not None else "",
color=GREEN if (stats["monthly_growth_rate"] or 0) >= 0 else "#d43f3a",
), style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"})
chart_card = dbc.Card([
dbc.CardHeader("Follower Growth"),
dbc.CardBody(G(daily_followers_figure(fts_f, posts_f), style={"height": "280px"}) if not fts_f.empty
else html.Div("No follower data for this period.", className="text-muted")),
], className="mb-3 shadow-sm")
posts_card = dbc.Card([
dbc.CardHeader(f"All Posts, {stats['period_label']}"),
dbc.CardBody(_reports_posts_table(posts_f)),
], className="mb-3 shadow-sm")
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value)
all_time = follower_directory_data.composition_stats(DIRECTORY_DATA)
if cohort["total"]:
composition_body = [
html.P([
f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}. "
f"Of those, ",
html.Strong(f"{cohort['total']} have been identified and profiled"),
f" in the Follower Directory so far (a separate, hand-maintained audit of who our "
f"followers are, not a second follower count), out of {all_time['total']} audited "
f"across the whole page to date.",
], style={"fontSize": "0.85rem"}),
html.Div([
html.Div(_composition_table(cohort["sector"], "Sector (this month)"),
style={"flex": "1", "minWidth": "220px"}),
html.Div(_composition_table(cohort["seniority"], "Seniority (this month)"),
style={"flex": "1", "minWidth": "220px"}),
html.Div(_composition_table(cohort["country"], "Country (this month)"),
style={"flex": "1", "minWidth": "220px"}),
], style={"display": "flex", "gap": "1rem", "flexWrap": "wrap"}),
]
else:
composition_body = [html.P(
f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}, "
f"but none have been identified and profiled in the Follower Directory yet.",
style={"fontSize": "0.85rem"})]
composition_card = dbc.Card([
dbc.CardHeader("Audience Composition (Follower Directory Coverage)"),
dbc.CardBody(composition_body + [
html.P("The Follower Directory is a hand-maintained audit that lags real LinkedIn follows "
"and gets corrected after the fact as more profiles are screenshotted and "
"cross-referenced, so this is a coverage figure that can still grow in later "
"updates, not a final count.", className="text-muted",
style={"fontSize": "0.75rem", "fontStyle": "italic", "marginTop": "10px"}),
]),
], className="mb-3 shadow-sm")
if notes:
narrative_card = dbc.Card([
dbc.CardHeader("Monthly Notes"),
dbc.CardBody([
_narrative_section("Features / Fixes Shipped", notes.get("features_shipped", [])),
_narrative_section("Data Quality Issues Found and Fixed", notes.get("data_quality", [])),
_narrative_section("Follower Study / Directory Updates", notes.get("follower_study", [])),
_narrative_section("Outstanding", notes.get("outstanding", [])),
]),
], className="mb-3 shadow-sm")
else:
narrative_card = dbc.Alert("Notes for this month have not been written yet.", color="warning")
return html.Div([kpi_row, chart_card, posts_card, composition_card, narrative_card])
@app.callback(
Output("reports-download", "data"),
Input("reports-download-btn", "n_clicks"),
State("reports-month-select", "value"),
prevent_initial_call=True,
)
def _download_reports_pdf(_n_clicks, month_value):
ctx_data = _build_report_ctx()
ctx_data["monthly_notes"] = monthly_report_notes.NOTES.get(month_value)
pdf_bytes = report.build_report_pdf(ctx_data, month_value)
label = next((o["label"] for o in _REPORTS_MONTH_OPTIONS if o["value"] == month_value), month_value)
filename = f"TXproducts_LinkedIn_Report_{label.replace(' ', '_')}.pdf"
return dcc.send_bytes(lambda buf: buf.write(pdf_bytes), filename=filename)
if __name__ == "__main__":
app.run(debug=False, port=8051)
+24 -3
View File
@@ -118,6 +118,15 @@ def render(comp):
cls = type(comp).__name__
module = type(comp).__module__
# --- Reports tab content area: empty in the live layout (filled by a callback), so
# bake in the one selectable month's rendered output directly. Switching months
# needs the live tool (see the disabled dropdown below), same rationale as every
# other live-only control in this exporter.
if getattr(comp, "id", None) == "reports-content-area":
if A._REPORTS_DEFAULT_MONTH:
return render(A._render_reports_tab(A._REPORTS_DEFAULT_MONTH))
return render(dbc.Alert("No campaign months have been written up yet.", color="info"))
# --- Plotly graphs ---
if isinstance(comp, dcc.Graph):
return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None))
@@ -131,10 +140,13 @@ def render(comp):
style = style_dict_to_css(getattr(comp, "style", None))
extra_class = getattr(comp, "className", "") or ""
cid = getattr(comp, "id", "")
if cid == "report-timeframe-select":
if cid in ("report-timeframe-select", "reports-month-select"):
title = ("Switching months needs the live tool, not available in this snapshot."
if cid == "reports-month-select" else
"PDF generation needs the live tool, not available in this snapshot.")
return (f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" '
f'style="{style};opacity:0.5;cursor:not-allowed" disabled '
f'title="PDF generation needs the live tool, not available in this snapshot.">{opts}</select>')
f'title="{title}">{opts}</select>')
return f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" style="{style}" onchange="window.__onSelect && window.__onSelect(this)">{opts}</select>'
# --- Stores/Download: no static equivalent needed (Download requires a live backend) ---
@@ -184,6 +196,15 @@ def render(comp):
md = getattr(comp, "md", None)
colcls = f"col-md-{md}" if md else "col"
return f'<div class="{colcls} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Table":
table_classes = "table"
if getattr(comp, "bordered", False):
table_classes += " table-bordered"
if getattr(comp, "striped", False):
table_classes += " table-striped"
if getattr(comp, "size", None):
table_classes += f" table-{comp.size}"
return f'<table class="{table_classes} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</table>'
if cls == "Card":
return f'<div class="card {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "CardHeader":
@@ -236,7 +257,7 @@ def render(comp):
if cls == "Hr":
return f'<hr style="{style}"{class_attr}/>'
if cls == "Button":
if cid == "generate-report-btn":
if cid in ("generate-report-btn", "reports-download-btn"):
disabled_style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
"opacity": "0.5", "cursor": "not-allowed"})
return (f'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled '
+49
View File
@@ -0,0 +1,49 @@
"""Reads audience composition straight from assets/follower_directory.html's DATA array,
so the dashboard and PDF reports show the same numbers as the hand-maintained Follower
Study/Directory instead of a second, independently hand-computed set that can drift out
of sync with it.
Deliberately has zero import-time dependency on app.py, same reasoning as report.py's
module docstring: app.py runs as "__main__", so importing it here would re-execute it as
a second, still-mid-import copy.
"""
import json
import os
import re
from collections import Counter
def load_directory(assets_dir):
"""Parses the `const DATA = [...];` array out of follower_directory.html into a list
of dicts. Returns an empty list if the file is missing rather than raising, since a
fresh checkout or a stripped-down deployment may not have it yet."""
path = os.path.join(assets_dir, "follower_directory.html")
if not os.path.exists(path):
return []
text = open(path, encoding="utf-8").read()
match = re.search(r"const DATA = (\[.*?\]);", text, re.S)
if not match:
return []
return json.loads(match.group(1))
def composition_stats(data, month_value=None, top_countries=None):
"""Sector/seniority/function/country breakdowns, either over the whole audited set
(month_value=None) or restricted to the entries whose `followedSort` matches
month_value (e.g. "2026-07"), i.e. that month's own follower cohort as profiled so
far. A month's count only ever grows as later audit passes catch up, it is a
coverage figure, not a final one.
top_countries defaults to unbounded (None = every country shown): this was
previously hardcoded to 10, which silently dropped tail countries from a monthly
cohort's breakdown while the "total" count kept counting them, the table's own sum
fell short of the total it was supposed to explain (caught when adding Sabine E.
Herlitschka's July correction pushed the cohort to 11 distinct countries)."""
rows = data if month_value is None else [d for d in data if d.get("followedSort") == month_value]
return {
"total": len(rows),
"sector": Counter(d.get("sector", "Unknown") for d in rows),
"seniority": Counter(d.get("seniority", "Unknown") for d in rows),
"function": Counter(d.get("function", "Unknown") for d in rows),
"country": Counter(d.get("country") or "Unknown/blank" for d in rows).most_common(top_countries),
}
+66
View File
@@ -0,0 +1,66 @@
"""Curated, hand-written narrative for each month of the 3-month LinkedIn campaign
(July, August, September 2026, plus an end-of-campaign report). Growth numbers are
computed live from the exports elsewhere (see report.compute_period_stats), this file
only holds the "why", which lives in git history and project context, not in any
dataframe. Keyed by the same "YYYY-MM" strings report.report_month_options() produces.
Add August's and September's entries here as those months are written up, no other
code changes are needed for the Reports tab or PDF to pick them up.
"""
NOTES = {
"2026-07": {
"features_shipped": [
"Initial dashboard build (Jul 21): Overview, Content, Followers, Visitors, "
"Competitors, and Growth tabs, weekly-pace tracking, PDF report generation, "
"data upload with live refresh, and a static HTML export builder for sharing "
"via NextCloud.",
"Posts table made scrollable instead of growing indefinitely, and the file "
"finder hardened to ignore non-export files sitting in the same folder (Jul 22).",
"Follower Directory and Follower Study linked directly from the Followers tab "
"hero banner (Jul 31).",
"Followers by Country map expanded from 14 hardcoded country centroids to a "
"near-complete world table, with a console warning for any country still "
"missing one (Jul 31).",
"Calendar month drill-down tiles, a follower-breakdown expand toggle, a CTR "
"explainer, and manual post title overrides added across the Followers and "
"Content tabs (Jul 31).",
],
"data_quality": [
"File finder no longer crashes when a stray non-export file, a manual roster "
"or an Office lock file, sits in the same folder as the real LinkedIn exports.",
"Country map no longer silently drops a country if it is missing a centroid, "
"it now warns in the console instead, and the on-screen country count is "
"computed from the same rollup as the map so the two can never disagree.",
"Visitors category breakdown (Location, Seniority, Company size) is no longer "
"lost month to month. LinkedIn only reports a category once it clears a "
"per-export minimum-visits threshold, so a single-snapshot approach was "
"silently losing real categories that dipped below the threshold one week and "
"reappeared the next. Replaced with an append-only running-total log "
"(visitor_history.py).",
"Conversion Rate chart no longer silently drops days with zero tracked "
"visitors but a real follower change (LinkedIn lets people follow a Page "
"directly from a feed post, with no page visit at all). Every day now shows, "
"with those days flagged instead of hidden.",
],
"follower_study": [
"Grew and was actively reclassified throughout July as new followers were "
"screenshotted and profiled by hand.",
"A seniority and sector classification fix landed Jul 31 (backup preserved as "
"follower_directory_BACKUP-before-student-sector-fix.html), plus several "
"individual profile confirmations (Dominique Elser, Anna Bujak, Emma Xia, "
"Marten Edwards, Mudabar Ahmad Mubashar, Martin Schroter) that corrected "
"taglines into real titles and employers.",
"Not yet brought under git version control during July itself, that happened "
"Aug 3 and falls outside this report.",
],
"outstanding": [
"The end-of-week report explaining every dashboard chart to the bosses was "
"due Fri Jul 31, status needs confirming with the user before marking this "
"done.",
"The conversion-rate weekly-bucket and 3 to 7 day match-window widening "
"discussed Jul 28, to make the follow-attribution pattern more convincing "
"across multiple weeks, is still conceptual, not implemented.",
],
},
}
+151 -20
View File
@@ -20,6 +20,8 @@ from reportlab.lib.units import cm
from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle, Paragraph,
Spacer, Image, HRFlowable)
import follower_directory_data
NAVY = colors.HexColor("#1b3a5c")
LIGHT_GRAY = colors.HexColor("#f0f4f8")
TEXT_GRAY = colors.HexColor("#6b7a8d")
@@ -62,19 +64,12 @@ def _fig_to_image(fig, width_cm=17, height_px=300, width_px=900):
return Image(buf, width=width_cm * cm, height=width_cm * cm * aspect)
def build_report_pdf(ctx, timeframe_value="all"):
"""ctx is a dict bundling everything the report needs from the live app:
D, posts, comp_ordered, current_total_followers, daily_followers_figure (the
figure-builder function), std_fn (the _std styling helper), TMPL, FORMAT_COLORS."""
def compute_period_stats(ctx, timeframe_value="all"):
"""Filters D/posts down to timeframe_value and computes the headline numbers shared
by the PDF report and the live Reports tab, so both always show the same figures
instead of two independently-computed versions drifting apart."""
D = ctx["D"]
posts = ctx["posts"]
comp_ordered = ctx["comp_ordered"]
current_total_followers = ctx["current_total_followers"]
daily_followers_figure = ctx["daily_followers_figure"]
std_fn = ctx["std_fn"]
tmpl = ctx["TMPL"]
format_colors = ctx["FORMAT_COLORS"]
growth_snapshot = ctx["growth_snapshot"]
fts_f = _filtered(D["fts"], "Date", timeframe_value)
posts_f = _filtered(posts, "Created date", timeframe_value)
@@ -87,7 +82,84 @@ def build_report_pdf(ctx, timeframe_value="all"):
posts_published = len(posts_f)
best_eng = float(posts_f["Eng %"].max()) if len(posts_f) else 0.0
avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float)
avg_eng = float(avg_eng_series.mean()) if not avg_eng_series.dropna().empty else 0.0
# "Engagement rate (total)" is stored as a 0-1 fraction (confirmed against raw export
# data, max observed 5.9 i.e. 590%), not already a percentage like "Eng %"/"CTR %" —
# scale to percentage-point form so the "%" already in every display string is correct.
avg_eng = float(avg_eng_series.mean()) * 100 if not avg_eng_series.dropna().empty else 0.0
# Monthly Growth Rate: this month's net new followers as a percentage of the
# cumulative total the page already had at the start of that month, resets every
# month against its own starting count rather than a single fixed campaign baseline
# (e.g. July: 22 new / 85 at start of July = +25.9%). Only defined for a specific
# "YYYY-MM" timeframe_value, not "all" or a whole year, since "start of the period"
# is only a clean concept for a single calendar month.
start_of_month_followers = None
monthly_growth_rate = None
if timeframe_value and timeframe_value != "all" and not timeframe_value.startswith("year-"):
year, month = (int(p) for p in timeframe_value.split("-"))
first_day = pd.Timestamp(year=year, month=month, day=1)
prior = D["fts"][D["fts"]["Date"] < first_day]
if not prior.empty:
start_of_month_followers = int(prior["Cumulative Followers"].iloc[-1])
if start_of_month_followers:
monthly_growth_rate = (new_followers / start_of_month_followers) * 100
return {
"fts_f": fts_f, "posts_f": posts_f, "cts_f": cts_f, "vts_f": vts_f,
"new_followers": new_followers, "total_impressions": total_impressions,
"total_page_views": total_page_views, "posts_published": posts_published,
"best_eng": best_eng, "avg_eng": avg_eng,
"start_of_month_followers": start_of_month_followers,
"monthly_growth_rate": monthly_growth_rate,
"period_label": _period_label(timeframe_value, D["fts"]),
}
def _bullets(items, style):
return [Paragraph(f"&bull;&nbsp; {item}", style) for item in items]
def _counter_rows(counter, header_left, top_n=None):
"""A Counter (or list of (key, count) pairs, for the `country` field
composition_stats() returns) as reportlab Table rows, header included.
No truncation by default, sector/seniority/function are a small fixed taxonomy and
country is uncapped by composition_stats() for the same reason, so the table's own
sum matches the cohort total instead of silently dropping the tail categories."""
items = counter.most_common(top_n) if hasattr(counter, "most_common") else list(counter)[:top_n]
return [[header_left, "Count"]] + [[str(k), str(v)] for k, v in items]
def build_report_pdf(ctx, timeframe_value="all"):
"""ctx is a dict bundling everything the report needs from the live app:
D, posts, comp_ordered, current_total_followers, daily_followers_figure (the
figure-builder function), std_fn (the _std styling helper), TMPL, FORMAT_COLORS,
growth_snapshot, and optionally monthly_notes (a curated per-month narrative dict,
see monthly_report_notes.py) and directory_data (the parsed Follower Directory,
see follower_directory_data.py). monthly_notes/directory_data are only rendered
when monthly_notes is present, so "All Time"/year-level reports are unaffected."""
D = ctx["D"]
posts = ctx["posts"]
comp_ordered = ctx["comp_ordered"]
current_total_followers = ctx["current_total_followers"]
daily_followers_figure = ctx["daily_followers_figure"]
std_fn = ctx["std_fn"]
tmpl = ctx["TMPL"]
format_colors = ctx["FORMAT_COLORS"]
growth_snapshot = ctx["growth_snapshot"]
monthly_notes = ctx.get("monthly_notes")
directory_data = ctx.get("directory_data") or []
period = compute_period_stats(ctx, timeframe_value)
fts_f, posts_f, cts_f = period["fts_f"], period["posts_f"], period["cts_f"]
new_followers = period["new_followers"]
total_impressions = period["total_impressions"]
total_page_views = period["total_page_views"]
posts_published = period["posts_published"]
best_eng = period["best_eng"]
avg_eng = period["avg_eng"]
monthly_growth_rate = period["monthly_growth_rate"]
start_of_month_followers = period["start_of_month_followers"]
period_label = period["period_label"]
styles = getSampleStyleSheet()
title_style = ParagraphStyle("TXTitle", parent=styles["Title"], textColor=NAVY, fontSize=20, spaceAfter=2)
@@ -96,8 +168,6 @@ def build_report_pdf(ctx, timeframe_value="all"):
body_style = ParagraphStyle("TXBody", parent=styles["Normal"], fontSize=9, textColor=colors.HexColor("#333333"))
note_style = ParagraphStyle("TXNote", parent=styles["Normal"], fontSize=8, textColor=TEXT_GRAY, spaceBefore=4)
period_label = _period_label(timeframe_value, D["fts"])
story = []
story.append(Paragraph("TXproducts, LinkedIn Performance Report", title_style))
story.append(Paragraph(
@@ -110,12 +180,13 @@ def build_report_pdf(ctx, timeframe_value="all"):
story.append(Paragraph("Key Metrics", h2_style))
kpi_data = [
["Total Followers\n(as of today)", "New Followers", "Posts Published", "Total Impressions",
"Total Page Views", "Best Post Eng.", "Avg. Eng. Rate"],
"Total Page Views", "Best Post Eng.", "Avg. Eng. Rate", "Monthly Growth Rate"],
[f"{current_total_followers:,}", f"{new_followers:,}", f"{posts_published:,}",
f"{total_impressions:,}", f"{total_page_views:,}", f"{best_eng:.1f}%",
f"{avg_eng:.2f}%" if avg_eng else "—"],
f"{avg_eng:.2f}%" if avg_eng else "—",
f"{monthly_growth_rate:+.1f}%" if monthly_growth_rate is not None else "—"],
]
kpi_table = Table(kpi_data, colWidths=[2.6 * cm] * 7)
kpi_table = Table(kpi_data, colWidths=[2.25 * cm] * 8)
kpi_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
@@ -130,6 +201,14 @@ def build_report_pdf(ctx, timeframe_value="all"):
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))
story.append(kpi_table)
if monthly_growth_rate is not None:
story.append(Paragraph(
f"Monthly Growth Rate: this month's net new followers as a percentage of the "
f"followers already on the page at the start of the month. {period_label} started "
f"at {start_of_month_followers:,} and gained {new_followers:,}, "
f"<b>{monthly_growth_rate:+.1f}%</b>. Resets every month against that month's own "
f"starting count, not a single fixed baseline for the whole campaign.",
note_style))
# --- Weekly growth pace ---
story.append(Paragraph("Weekly Growth Pace", h2_style))
@@ -172,6 +251,52 @@ def build_report_pdf(ctx, timeframe_value="all"):
f"unreachable target permanently.",
note_style))
# --- Monthly narrative sections (only when curated notes exist for this period) ---
if monthly_notes:
story.append(Paragraph("Features / Fixes Shipped", h2_style))
story.extend(_bullets(monthly_notes.get("features_shipped", []), body_style))
story.append(Paragraph("Data Quality Issues Found and Fixed", h2_style))
story.extend(_bullets(monthly_notes.get("data_quality", []), body_style))
story.append(Paragraph("Audience Composition (Follower Directory Coverage)", h2_style))
cohort = follower_directory_data.composition_stats(directory_data, timeframe_value)
all_time = follower_directory_data.composition_stats(directory_data)
if cohort["total"]:
story.append(Paragraph(
f"LinkedIn recorded {new_followers} new followers in {period_label}. Of those, "
f"<b>{cohort['total']} have been identified and profiled</b> in the Follower "
f"Directory so far (a separate, hand-maintained audit of who our followers are, "
f"not a second follower count), out of {all_time['total']} audited across the "
f"whole page to date.", body_style))
for field, label in (("sector", "Sector (this month)"),
("seniority", "Seniority (this month)"),
("country", "Country (this month)")):
comp_table = Table(_counter_rows(cohort[field], label), colWidths=[9 * cm, 3 * cm])
comp_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]))
story.append(comp_table)
story.append(Spacer(1, 6))
else:
story.append(Paragraph(
f"LinkedIn recorded {new_followers} new followers in {period_label}, but none "
f"have been identified and profiled in the Follower Directory yet.", body_style))
story.append(Paragraph(
"The Follower Directory is a hand-maintained audit that lags real LinkedIn "
"follows and gets corrected after the fact as more profiles are screenshotted "
"and cross-referenced, so this is a coverage figure that can still grow in "
"later updates, not a final count.", note_style))
story.append(Paragraph("Follower Study / Directory Updates", h2_style))
story.extend(_bullets(monthly_notes.get("follower_study", []), body_style))
story.append(Paragraph("Outstanding", h2_style))
story.extend(_bullets(monthly_notes.get("outstanding", []), body_style))
# --- Follower growth chart ---
if not fts_f.empty:
story.append(Paragraph("Follower Growth", h2_style))
@@ -179,12 +304,18 @@ def build_report_pdf(ctx, timeframe_value="all"):
fig.update_layout(showlegend=True, font=dict(size=14))
story.append(_fig_to_image(fig))
# --- Top posts table ---
# --- Posts table: every post for a monthly campaign report (monthly_notes present),
# otherwise the top 5 by engagement, same as before, for the generic sidebar PDF
# control where "All Time"/a whole year could mean far too many rows to list in full.
if monthly_notes:
story.append(Paragraph(f"All Posts, {period_label}", h2_style))
shown_posts = posts_f.sort_values("Created date")
else:
story.append(Paragraph("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), h2_style))
shown_posts = posts_f.sort_values("Eng %", ascending=False).head(5)
if len(posts_f):
top_posts = posts_f.sort_values("Eng %", ascending=False).head(5)
rows = [["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]]
for _, r in top_posts.iterrows():
for _, r in shown_posts.iterrows():
rows.append([
Paragraph(str(r["Short title"])[:60], body_style),
r["Created date"].strftime("%d/%m/%Y"),
+2
View File
@@ -4,3 +4,5 @@ plotly>=5.0
pandas>=1.3
openpyxl>=3.0
xlrd>=2.0
reportlab>=4.0
kaleido>=0.2