From f9f27401dbd4a456120afbac5153f3049680caa4 Mon Sep 17 00:00:00 2001
From: beantownbabe <156287695+beantownbabe@users.noreply.github.com>
Date: Wed, 5 Aug 2026 09:56:19 +0200
Subject: [PATCH] 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
tag entirely.
Co-Authored-By: Claude Sonnet 5
---
app.py | 227 +++++++++++++++++++++++++++++++++++--
build_static_export.py | 27 ++++-
follower_directory_data.py | 49 ++++++++
monthly_report_notes.py | 66 +++++++++++
report.py | 173 ++++++++++++++++++++++++----
requirements.txt | 2 +
6 files changed, 512 insertions(+), 32 deletions(-)
create mode 100644 follower_directory_data.py
create mode 100644 monthly_report_notes.py
diff --git a/app.py b/app.py
index 01f2965..c98804c 100644
--- a/app.py
+++ b/app.py
@@ -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)
diff --git a/build_static_export.py b/build_static_export.py
index 20ffc49..e639a09 100644
--- a/build_static_export.py
+++ b/build_static_export.py
@@ -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'')
+ f'title="{title}">{opts}')
return f''
# --- 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'
{children_html}
'
+ 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'
{children_html}
'
if cls == "Card":
return f'
{children_html}
'
if cls == "CardHeader":
@@ -236,7 +257,7 @@ def render(comp):
if cls == "Hr":
return f''
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'