diff --git a/app.py b/app.py
index 47f6e1b..7a04410 100644
--- a/app.py
+++ b/app.py
@@ -2191,7 +2191,7 @@ reports_tab = dbc.Container([
]),
html.Div(id="reports-title", style={"flex": "1", "textAlign": "center", "padding": "0 1rem"}),
html.Div([
- html.Button("Download PDF", id="reports-download-btn", n_clicks=0,
+ html.Button("Download Report (.docx)", 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",
@@ -2725,8 +2725,8 @@ 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."""
+ """Builds the ctx_data dict shared by report.build_report_docx/build_report_pdf/
+ compute_period_stats, so every consumer reads the same underlying data."""
return {
"D": D, "posts": posts, "comp_ordered": comp_ordered,
"current_total_followers": current_total_followers,
@@ -4484,10 +4484,10 @@ def _download_reports_pdf(_n_clicks, month_value):
return no_update
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)
+ docx_bytes = report.build_report_docx(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)
+ filename = f"TXproducts_LinkedIn_Report_{label.replace(' ', '_')}.docx"
+ return dcc.send_bytes(lambda buf: buf.write(docx_bytes), filename=filename)
if __name__ == "__main__":
diff --git a/build_static_export.py b/build_static_export.py
index 0d55450..f6e678e 100644
--- a/build_static_export.py
+++ b/build_static_export.py
@@ -287,7 +287,7 @@ def render(comp):
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'')
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f''
diff --git a/report.py b/report.py
index 2bb89ed..6366689 100644
--- a/report.py
+++ b/report.py
@@ -20,6 +20,10 @@ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle, Paragraph,
Spacer, Image, HRFlowable)
+from docx import Document
+from docx.shared import Cm, Pt
+from docx.oxml.ns import qn
+from docx.oxml import OxmlElement
import follower_directory_data
import engagement_audit
@@ -168,6 +172,68 @@ def _rich_text_to_html(parts):
return "".join(out)
+def _docx_shade(cell, hex_color):
+ """python-docx has no cell-background property, Word's shading is buried in the
+ cell's own XML — this appends the element that does it."""
+ shd = OxmlElement("w:shd")
+ shd.set(qn("w:val"), "clear")
+ shd.set(qn("w:color"), "auto")
+ shd.set(qn("w:fill"), hex_color)
+ cell._tc.get_or_add_tcPr().append(shd)
+
+
+def _docx_table(doc, rows, col_widths_cm=None, header_bg="F0F4F8", excluded_rows=None,
+ striped_bg=None, font_size=8):
+ """rows[0] is the header. excluded_rows is a set of 1-indexed data-row positions
+ (matching the reportlab tables' own convention elsewhere in this file) that get
+ struck through and pale-red shaded, same treatment as the PDF/live dashboard use
+ for a repost/non-campaign post — visible, not silently dropped, but clearly marked."""
+ excluded_rows = excluded_rows or set()
+ table = doc.add_table(rows=0, cols=len(rows[0]))
+ table.style = "Table Grid"
+ for i, row_data in enumerate(rows):
+ row = table.add_row()
+ is_header = i == 0
+ is_excluded = i in excluded_rows
+ for j, val in enumerate(row_data):
+ cell = row.cells[j]
+ run = cell.paragraphs[0].add_run(str(val))
+ run.font.size = Pt(font_size)
+ if is_header:
+ run.bold = True
+ _docx_shade(cell, header_bg)
+ elif is_excluded:
+ run.font.strike = True
+ _docx_shade(cell, "FBDCDC")
+ elif striped_bg and i % 2 == 0:
+ _docx_shade(cell, striped_bg)
+ if col_widths_cm:
+ for j, w in enumerate(col_widths_cm):
+ if j < len(row.cells):
+ row.cells[j].width = Cm(w)
+ return table
+
+
+def _docx_chart(doc, fig, width_cm=16, height_px=300, width_px=900):
+ buf = io.BytesIO()
+ fig.write_image(buf, format="png", width=width_px, height=height_px, scale=2)
+ buf.seek(0)
+ doc.add_picture(buf, width=Cm(width_cm))
+
+
+def _docx_rich_text(doc, parts, bold_color=None):
+ """Same (text, is_bold) tuple format as _rich_text_to_html (see monthly_report_notes.py),
+ rendered as runs in a single paragraph instead of reportlab HTML markup."""
+ p = doc.add_paragraph()
+ for text, bold in parts:
+ if bold == "br":
+ p.add_run().add_break()
+ else:
+ run = p.add_run(text)
+ run.bold = bool(bold)
+ return p
+
+
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.
@@ -771,3 +837,383 @@ def build_report_pdf(ctx, timeframe_value="all"):
doc.build(story)
buf.seek(0)
return buf.getvalue()
+
+
+def build_report_docx(ctx, timeframe_value="all"):
+ """Editable Word version of build_report_pdf, same ctx/timeframe_value, same data
+ (compute_period_stats is shared, nothing recomputed differently), same sections in
+ the same order — just python-docx's Document/paragraphs/tables instead of
+ reportlab's fixed-layout Flowables, so a reader isn't fighting page breaks to fix a
+ typo or reflow a table. Kept in this same module rather than a separate one so the
+ two can never quietly drift out of sync with each other's data."""
+ D = ctx["D"]
+ posts = ctx["posts"]
+ comp_ordered = ctx["comp_ordered"]
+ 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 []
+ july_register_ratings = ctx.get("july_register_ratings") or []
+ repost_dates = ctx.get("repost_dates") or set()
+ non_campaign_dates = ctx.get("non_campaign_dates") or set()
+ excluded_dates = repost_dates | non_campaign_dates
+
+ 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"]
+ end_of_month_followers = period["end_of_month_followers"]
+ period_label = period["period_label"]
+
+ doc = Document()
+ # python-docx's own default template omits w:zoom's required w:percent attribute
+ # (every Document() has this, not something this function introduces) — added here
+ # so the file passes strict OOXML validation instead of relying on Word's silent
+ # auto-repair.
+ zoom_el = doc.settings.element.find(qn("w:zoom"))
+ if zoom_el is not None and zoom_el.get(qn("w:percent")) is None:
+ zoom_el.set(qn("w:percent"), "100")
+ for section in doc.sections:
+ section.top_margin = Cm(1.5)
+ section.bottom_margin = Cm(1.5)
+ section.left_margin = Cm(1.8)
+ section.right_margin = Cm(1.8)
+
+ title_text = (f"TXproducts — {period_label} End of Month Campaign Report" if monthly_notes
+ else "TXproducts, LinkedIn Performance Report")
+ doc.add_heading(title_text, level=0)
+ sub = doc.add_paragraph()
+ sub.add_run(
+ f"Period: {period_label} | Generated {pd.Timestamp.now().strftime('%B %d, %Y')} | "
+ f"Data as of {D['last_updated'].strftime('%B %d, %Y')}"
+ ).italic = True
+
+ # --- KPI summary table ---
+ doc.add_heading("Key Metrics", level=1)
+ kpi_rows = [
+ ["Month Start", "New Followers", "Total Followers (month end)", "Posts Published",
+ "Total Impressions", "Total Page Views", "Best Post Real Eng.", "Avg. Eng. Rate", "Monthly Growth Rate"],
+ [f"{start_of_month_followers:,}" if start_of_month_followers is not None else "—",
+ f"{new_followers:,}",
+ f"{end_of_month_followers:,}" if end_of_month_followers is not None else "—",
+ f"{posts_published:,}",
+ f"{total_impressions:,}", f"{total_page_views:,}",
+ f"{best_eng:.1f}%" if best_eng is not None else "Pending audit",
+ f"{avg_eng:.2f}%" if avg_eng else "—",
+ f"{monthly_growth_rate:+.1f}%" if monthly_growth_rate is not None else "—"],
+ ]
+ _docx_table(doc, kpi_rows, font_size=9)
+ if monthly_growth_rate is not None:
+ note = doc.add_paragraph()
+ note.add_run(
+ 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:,}, "
+ )
+ note.add_run(f"{monthly_growth_rate:+.1f}%").bold = True
+ note.add_run(". Resets every month against that month's own starting count, not a "
+ "single fixed baseline for the whole campaign.")
+
+ # --- Section abstracts ---
+ if monthly_notes:
+ doc.add_heading("This Month's Sections", level=1)
+ core_message_abstract = monthly_notes.get("core_message_abstract")
+ if core_message_abstract:
+ doc.add_heading("Core Message", level=2)
+ _docx_rich_text(doc, core_message_abstract)
+ roadmap_abstract = monthly_notes.get("roadmap_abstract")
+ if roadmap_abstract:
+ doc.add_heading("Campaign Roadmap", level=2)
+ _docx_rich_text(doc, roadmap_abstract)
+ doc.add_heading("Follower Study", level=2)
+ doc.add_paragraph(
+ "Narrative analysis of who's following us: key findings, growth timeline, "
+ "seniority/function/sector/geography breakdowns, and notable organizational "
+ "clusters. Hand-maintained alongside the Directory, not computed from it.")
+
+ # --- Weekly growth pace (generic sidebar PDF control only, not the monthly report) ---
+ if not monthly_notes:
+ doc.add_heading("Weekly Growth Pace", level=1)
+ on_track = growth_snapshot["on_track"]
+ status_label = "Building History" if on_track is None else ("On Track" if on_track else "Behind")
+ weekly_pct = growth_snapshot["week_to_date_pct"]
+ target_pct = growth_snapshot["rolling_best_weekly_pct"]
+ objective_rows = [
+ ["This Week", f"Target (best of {growth_snapshot['rolling_best_weeks']} wks)", "Pace Status"],
+ [f"{weekly_pct:+.2f}%" if weekly_pct is not None else "—",
+ f"{target_pct:+.2f}%" if target_pct is not None else "—", status_label],
+ ]
+ _docx_table(doc, objective_rows, font_size=9)
+ doc.add_paragraph(
+ f"Target is a rolling personal best, the highest trailing-7-day growth rate seen "
+ f"in the last {growth_snapshot['rolling_best_weeks']} weeks, not a fixed "
+ f"percentage. It rises automatically as performance improves, but a one-off spike "
+ f"ages out of the window after {growth_snapshot['rolling_best_weeks']} weeks "
+ f"rather than setting an unreachable target permanently.")
+
+ # --- Audience Composition: Sector ---
+ if monthly_notes:
+ doc.add_heading("Audience Composition (Follower Directory Coverage)", level=1)
+ cohort = follower_directory_data.composition_stats(directory_data, timeframe_value)
+ if cohort["total"]:
+ _docx_table(doc, _counter_rows(cohort["sector"], "Sector (this month)"), font_size=8)
+ else:
+ doc.add_paragraph(
+ f"LinkedIn recorded {new_followers} new followers in {period_label}, but none "
+ f"have been identified and profiled in the Follower Directory yet.")
+
+ # --- Follower growth chart ---
+ if not fts_f.empty:
+ doc.add_heading("Follower Growth", level=1)
+ fig = daily_followers_figure(fts_f, posts_f)
+ fig.update_layout(showlegend=True, font=dict(size=14))
+ _docx_chart(doc, fig)
+
+ # --- Posts table ---
+ if monthly_notes:
+ doc.add_heading(f"All Posts, {period_label}", level=1)
+ shown_posts = posts_f.sort_values("Created date")
+ else:
+ doc.add_heading("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), level=1)
+ shown_posts = posts_f.sort_values("Real Eng %", ascending=False).head(5)
+ if len(posts_f):
+ rows = [["Post", "Date", "Type", "Impressions", "CTR %", "Real Eng %"]]
+ excluded_rows = set()
+ for i, (_, r) in enumerate(shown_posts.iterrows(), start=1):
+ real_eng = r["Real Eng %"]
+ is_excluded = r["Created date"].strftime("%Y-%m-%d") in excluded_dates
+ if is_excluded:
+ excluded_rows.add(i)
+ rows.append([
+ str(r["Short title"])[:60], r["Created date"].strftime("%d/%m/%Y"), r["Content Type"],
+ f"{int(r['Impressions']):,}", f"{r['CTR %']:.1f}%",
+ "Excluded" if is_excluded else (f"{real_eng:.1f}%" if pd.notna(real_eng) else "Pending audit"),
+ ])
+ _docx_table(doc, rows, excluded_rows=excluded_rows, striped_bg="F0F4F8", font_size=8)
+ else:
+ doc.add_paragraph("No posts published in this period.")
+
+ # --- Content format performance ---
+ if len(posts_f):
+ doc.add_heading("Impressions by Content Format", level=1)
+ fmt_imp = (posts_f.groupby("Content Type", as_index=False)["Impressions"].sum().sort_values("Impressions"))
+ fig2 = std_fn(px.bar(
+ fmt_imp, x="Impressions", y="Content Type", orientation="h",
+ template=tmpl, color="Content Type", color_discrete_map=format_colors))
+ fig2.update_layout(showlegend=False, font=dict(size=14))
+ _docx_chart(doc, fig2, height_px=220)
+
+ # --- Competitor snapshot ---
+ doc.add_heading("Competitor Snapshot", level=1)
+ cap = doc.add_paragraph()
+ cap.add_run("Cumulative export snapshot, not specific to the selected period.").italic = True
+ comp_rows = [["Page", "New Followers", "Posts", "Comments", "Comments/day", "Reactions"]]
+ for _, r in comp_ordered.iterrows():
+ label = r["Page"] + (" (You)" if r["Page"] == "TXproducts" else "")
+ comp_rows.append([label, r["New Followers"], r["Posts"], r["Comments"], r["Comments per day"], r["Reactions"]])
+ _docx_table(doc, comp_rows, striped_bg="F0F4F8", font_size=8)
+
+ # --- Deep dives ---
+ if monthly_notes:
+ posts_fmt = posts_f[~posts_f["Created date"].dt.strftime("%Y-%m-%d").isin(repost_dates)].copy()
+ posts_fmt["Shares"] = posts_fmt["Comments"].fillna(0) + posts_fmt["Reposts"].fillna(0)
+ posts_fmt["Weekday"] = posts_fmt["Created date"].dt.day_name()
+ cohort = follower_directory_data.composition_stats(directory_data, timeframe_value)
+
+ doc.add_page_break()
+ doc.add_heading("Section Deep Dives", level=0)
+
+ doc.add_heading("Core Message — Four Step Framework (p.2)", level=1)
+ doc.add_paragraph(
+ "These four steps were used to develop the messaging. They are also the "
+ "framework for revising it: Analyze → Extract → Formulate → Validate.")
+
+ doc.add_heading("ANALYZE: Who is the target audience and what is their problem? (p.3)", level=1)
+ if cohort["total"]:
+ doc.add_paragraph("Academia/Research Institute is the largest single sector this month.")
+ _docx_table(doc, _counter_rows(cohort["sector"], "Sector (July cohort)"), font_size=7.5)
+ _docx_table(doc, _counter_rows(cohort["function"], "Function (July cohort)"), font_size=7.5)
+
+ doc.add_heading("EXTRACT: The Formula and the Final Message (p.4)", level=1)
+ doc.add_paragraph(
+ "Focusing on \"SO THAT\" from page 4's formula, as this is the part of the "
+ "message meant to speak directly to the audience.")
+ doc.add_paragraph(
+ "We seem to have something closer to an actual playbook where we clearly see "
+ "the value proposition.")
+ for item in [
+ "14/07 — introduces the problem (operation modes, frustration of waiting)",
+ "28/07 — deepens it with the Ferris wheel analogy, ends on a cliffhanger CTA",
+ "30/07 — delivers the solution, in Daniel's own voice, closing the loop",
+ ]:
+ doc.add_paragraph(item, style="List Bullet")
+ doc.add_paragraph(
+ "The trilogy of three posts brings in honesty in a way that matters for "
+ "credibility, further underlined by technical substance delivered through a "
+ "personal-voice format.")
+
+ doc.add_heading("FORMULATE: Boilerplate Register (p.5), Analytical Dimensions Methodology (p.8)", level=1)
+ reg_rows = [["Date", "Format", "Real Eng %", "Post", "Rating", "Why"]]
+ repost_row = None
+ for idx, (date, fmt, eng, post, rating, why) in enumerate(july_register_ratings, start=1):
+ if rating == "Repost":
+ repost_row = idx
+ reg_rows.append([date, fmt, eng, post[:50], rating, why])
+ _docx_table(doc, reg_rows, excluded_rows=({repost_row} if repost_row else None), font_size=7.5)
+ for dim_title, dim_text in [
+ ("Prior Knowledge and Language", "This cohort is mostly Academic and "
+ "Engineering/Technical and understands the underlying science."),
+ ("Decision Context", "Most of this cohort do not have a budget-holding role, "
+ "so they're not positioned to act on a rational business case."),
+ ("Trust and Reservations", "Emotional posts outperformed the rational ones. "
+ "Leading with a human touch to a skeptical audience wins."),
+ ]:
+ p = doc.add_paragraph()
+ p.add_run(f"{dim_title}: ").bold = True
+ p.add_run(dim_text)
+
+ doc.add_heading("VALIDATE (p.6)", level=1)
+ p = doc.add_paragraph()
+ p.add_run("Validation was tried with the July 21 poll in which ")
+ p.add_run("zero").bold = True
+ p.add_run(" people took part in.")
+ doc.add_paragraph(
+ "That points to one of two things: the question wasn't formulated to invite a "
+ "response, or this audience doesn't engage with the poll format at all.")
+
+ doc.add_heading("Campaign Roadmap — Four Stages in Sequence (p.4)", level=1)
+ p = doc.add_paragraph()
+ p.add_run("Stage 1, Traction — Find the Voice").bold = True
+ p.add_run(" (July), is the current stage: establish a consistent brand voice and "
+ "discover which posts/formats drive engagement, ahead of Demographics, "
+ "Conversion and Growth.")
+
+ doc.add_heading("Traction by Format (p.3)", level=1)
+ by_fmt = (posts_fmt.groupby("Content Type").agg(Posts=("Content Type", "size"),
+ **{"Real Avg Eng %": ("Real Eng %", "mean")}, Comments=("Comments", "sum"),
+ Reposts=("Reposts", "sum")).round(1)
+ .sort_values("Real Avg Eng %", ascending=False))
+ dates_by_fmt = (posts_fmt.sort_values("Created date").groupby("Content Type")["Created date"]
+ .apply(lambda s: ", ".join(d.strftime("%d/%m") for d in s)))
+ fmt_rows = [["Format", "Posts", "Dates", "Real Avg Eng %", "Comments", "Reposts"]] + [
+ [fmt, str(int(r["Posts"])), dates_by_fmt.get(fmt, ""),
+ f"{r['Real Avg Eng %']}%" if pd.notna(r["Real Avg Eng %"]) else "Pending audit",
+ str(int(r["Comments"])), str(int(r["Reposts"]))]
+ for fmt, r in by_fmt.iterrows()]
+ _docx_table(doc, fmt_rows, font_size=8)
+
+ doc.add_heading("Best Posting Day (p.2)", level=1)
+ by_day = (posts_fmt.groupby("Weekday").agg(**{"Real Avg Eng %": ("Real Eng %", "mean")}).round(1))
+ best_day = by_day["Real Avg Eng %"].idxmax()
+ p = doc.add_paragraph()
+ p.add_run(best_day).bold = True
+ p.add_run(" shows the highest average engagement this month.")
+ wk_ordered = posts_fmt.sort_values("Created date")
+ wk_rows = [["Date", "Weekday", "Format", "Post", "Impressions", "Clicks", "CTR %", "Real Eng %", "Likes", "Comments", "Reposts"]] + [
+ [r["Created date"].strftime("%d/%m"), r["Weekday"], r["Content Type"], str(r["Short title"])[:30],
+ f"{int(r['Impressions']):,}", str(int(r["Clicks"])), f"{r['CTR %']}%",
+ f"{r['Real Eng %']:.1f}%" if pd.notna(r["Real Eng %"]) else "Pending audit",
+ str(int(r["Likes"])), str(int(r["Comments"])), str(int(r["Reposts"]))]
+ for _, r in wk_ordered.iterrows()]
+ _docx_table(doc, wk_rows, font_size=7)
+ flag = doc.add_paragraph()
+ flag.add_run(
+ "Every single Carousel was posted on a Thursday, and every Video/Poll was "
+ "posted on a Tuesday, so this month's data can't isolate a real day-of-week "
+ "effect from a format effect. Going into August, picking a day deliberately, "
+ "independent of format, would let day-of-week actually be tested."
+ ).italic = True
+
+ doc.add_heading("Real Engagement Over Time", level=1)
+ eng_fig = px.bar(posts_fmt.sort_values("Created date"), x="Created date", y="Real Eng %",
+ color="Content Type", color_discrete_map=format_colors,
+ template=tmpl, labels={"Content Type": "Format", "Created date": ""})
+ eng_fig = std_fn(eng_fig)
+ eng_fig.update_layout(legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left",
+ x=0, font=dict(size=9), title=None))
+ eng_fig.update_xaxes(range=["2026-06-30", "2026-08-01"], tickformat="%b %d")
+ eng_fig.add_trace(go.Scatter(
+ x=posts_fmt["Created date"], y=posts_fmt["Real Eng %"], mode="markers", showlegend=False,
+ marker=dict(symbol="star", size=11, color="#E53935", line=dict(width=1, color="white")),
+ name="Post published",
+ ))
+ _docx_chart(doc, eng_fig, height_px=200, width_cm=14)
+ doc.add_paragraph(
+ "Real Eng % per Post across July (Audience-only, Team excluded), colored by "
+ "format. The month's two biggest performers (09/07, 30/07) are both "
+ "Carousels, and both stay on top under the real, audited number.")
+
+ doc.add_heading("All Posts — Traction by Format", level=1)
+ ap_fmt_rows = [["Format", "Posts", "Real Avg Eng %", "Comments + Shares"]] + [
+ [fmt, str(int(r["Posts"])),
+ f"{r['Real Avg Eng %']}%" if pd.notna(r["Real Avg Eng %"]) else "Pending audit",
+ str(int(r["Comments"] + r["Reposts"]))]
+ for fmt, r in by_fmt.iterrows()]
+ _docx_table(doc, ap_fmt_rows, font_size=8)
+
+ doc.add_heading("Top Posts by Comments + Shares", level=1)
+ top_posts = posts_fmt.sort_values("Shares", ascending=False).head(3)
+ top_rows = [["Post", "Date", "Format", "Comments", "Reposts"]] + [
+ [str(r["Short title"])[:50], r["Created date"].strftime("%d/%m/%Y"),
+ r["Content Type"], str(int(r["Comments"])), str(int(r["Reposts"]))]
+ for _, r in top_posts.iterrows()]
+ _docx_table(doc, top_rows, font_size=8)
+
+ doc.add_heading("Follower Growth — New Followers by Week", level=1)
+ fts_fmt = fts_f.copy()
+ fts_fmt["Week"] = fts_fmt["Date"].dt.isocalendar().week
+ weekly = (fts_fmt.groupby("Week").agg(**{"New Followers": ("Total followers", "sum")},
+ Start=("Date", "min"), End=("Date", "max")).reset_index())
+ week_rows = [["Week Of", "New Followers"]] + [
+ [f"{r['Start'].strftime('%b %d')} - {r['End'].strftime('%b %d')}", str(int(r["New Followers"]))]
+ for _, r in weekly.iterrows()]
+ _docx_table(doc, week_rows, font_size=8)
+ total_new = int(fts_fmt["Total followers"].sum())
+ cluster1 = int(fts_fmt[(fts_fmt["Date"] >= "2026-07-15") & (fts_fmt["Date"] <= "2026-07-17")]["Total followers"].sum())
+ cluster2 = int(fts_fmt[(fts_fmt["Date"] >= "2026-07-28") & (fts_fmt["Date"] <= "2026-07-31")]["Total followers"].sum())
+ if total_new:
+ combined_pct = round((cluster1 + cluster2) / total_new * 100, 1)
+ doc.add_paragraph(
+ f"Two short windows account for most of July's growth: Jul 15-17 "
+ f"({cluster1} followers) and Jul 28-31 ({cluster2} followers) — "
+ f"{combined_pct}% of the month's {total_new} new followers landed in "
+ f"these two stretches alone. A cohort-level pattern, not proof any single "
+ f"post caused it.")
+
+ doc.add_heading("Audience Composition — Seniority and Country", level=1)
+ if cohort["total"]:
+ _docx_table(doc, _counter_rows(cohort["seniority"], "Seniority (July cohort)"), font_size=8)
+ country_rows = [["Country (July cohort)", "Count"]] + [[str(k), str(v)] for k, v in cohort["country"]]
+ _docx_table(doc, country_rows, font_size=8)
+
+ doc.add_heading("Follower Study — Notable Contacts", level=1)
+ notable = [d for d in directory_data
+ if d.get("seniority") in ("C-Level/Founder", "Director/Head/Principal")
+ and not d.get("is_team")]
+ notable.sort(key=lambda d: (d.get("seniority") != "C-Level/Founder", d.get("name", "")))
+ doc.add_paragraph(
+ f"{len(notable)} followers across the whole Directory sit at C-Level/Founder or "
+ f"Director/Head/Principal seniority (internal team members excluded).")
+ notable_rows = [["Name", "Seniority", "Company", "Country"]] + [
+ [d.get("name", ""), d.get("seniority", ""), d.get("company", ""), d.get("country", "")]
+ for d in notable]
+ _docx_table(doc, notable_rows, striped_bg="F0F4F8", font_size=7.5)
+
+ footer = doc.add_paragraph()
+ footer.add_run(
+ "TXproducts · Internal Report · Generated automatically from LinkedIn Page Analytics exports."
+ ).italic = True
+
+ buf = io.BytesIO()
+ doc.save(buf)
+ buf.seek(0)
+ return buf.getvalue()