Files
LinkedIn-Analytics-Dashboard/report.py
T
beantownbabeandClaude Sonnet 5 292bd21abe Generate EOM reports as editable .docx instead of fixed-layout PDF
Mckinley's PDFs kept cutting off content mid-page with no way to fix
it short of regenerating the whole report — a fixed-layout PDF isn't
the right format for something meant to be edited. Adds
report.build_report_docx(), a python-docx equivalent of
build_report_pdf() sharing the exact same compute_period_stats() data
and section order (KPI table, section abstracts, weekly pace,
audience composition, follower growth chart, all posts table with
the same excluded-post strikethrough treatment, format performance,
competitor snapshot, and the full Section Deep Dives for a monthly
campaign report), so the two can never silently drift apart on
numbers even though the rendering layer is completely different.
Word reflows content automatically, so there's no more fixed page
grid to fight when editing.

The Reports tab's download button now produces .docx instead of PDF.
Verified via python-docx re-read (KPI values, the 10/07 repost row's
strikethrough, embedded chart images) and strict OOXML schema
validation (including fixing a `w:zoom` attribute python-docx's own
default template omits). build_report_pdf() is left in place, only
report.py's ctx docstring rewritten since the doubly-stale "sidebar's
generic PDF control" no longer exists as an actual call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 14:19:29 +02:00

1220 lines
68 KiB
Python

"""Builds a leadership-facing PDF summary of LinkedIn performance for a chosen
timeframe. Charts are rendered to PNG via kaleido and embedded as images (reportlab
has no native Plotly support).
Deliberately has zero import-time dependency on app.py — app.py is run directly
(`python3 app.py`), so it's registered in sys.modules as "__main__", not "app". If this
module did `import app`, Python wouldn't recognize that as the same module and would
re-execute app.py from scratch as a second copy, which is still mid-import when this
module's functions get called — a circular-import AttributeError. Passing everything
in as arguments avoids that entirely.
"""
import io
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
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
NAVY = colors.HexColor("#1b3a5c")
LIGHT_GRAY = colors.HexColor("#f0f4f8")
TEXT_GRAY = colors.HexColor("#6b7a8d")
def _filtered(df, date_col, value):
if not value or value == "all":
return df
if value.startswith("year-"):
year = int(value.split("-", 1)[1])
return df[df[date_col].dt.year == year]
year, month = (int(p) for p in value.split("-"))
return df[(df[date_col].dt.year == year) & (df[date_col].dt.month == month)]
def _period_label(value):
"""Friendly label for a timeframe_value, built directly from the "YYYY-MM"/"year-Y"
string itself so it works even for a future campaign month (e.g. September, selectable
ahead of time in the Reports tab) that has no export data yet."""
if not value or value == "all":
return "All Time"
if value.startswith("year-"):
return value.split("-", 1)[1]
import calendar as calmod
try:
year, month = (int(p) for p in value.split("-"))
return f"{calmod.month_name[month]} {year}"
except ValueError:
return value
def _fig_to_image(fig, width_cm=17, 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)
aspect = height_px / width_px
return Image(buf, width=width_cm * cm, height=width_cm * cm * aspect)
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"]
fts_f = _filtered(D["fts"], "Date", timeframe_value)
posts_f = _filtered(posts, "Created date", timeframe_value).copy()
cts_f = _filtered(D["cts"], "Date", timeframe_value)
vts_f = _filtered(D["vts"], "Date", timeframe_value)
new_followers = int(fts_f["Total followers"].sum()) if not fts_f.empty else 0
total_impressions = int(cts_f["Impressions (total)"].sum()) if not cts_f.empty else 0
total_page_views = int(vts_f["Total page views (total)"].sum()) if not vts_f.empty else 0
posts_published = len(posts_f)
# Real Eng % (Audience-only, Team-excluded, hand-audited — see engagement_audit.py)
# computed once here so every downstream consumer of posts_f (live app tables/
# charts and this PDF) reads the same number instead of recomputing it separately.
# None for any post that hasn't been audited yet. "Best Post Real Eng." is the max
# among audited posts only — unaudited posts can't compete on a number they don't
# have, and a period with zero audited posts reports None (shown as "Pending audit"
# by callers) rather than silently falling back to the raw, Team-inflated figure.
if len(posts_f):
posts_f["Real Eng %"] = posts_f.apply(
lambda r: engagement_audit.real_eng_pct(
r["Created date"].strftime("%d/%m"), r["Impressions"], r.get("Clicks", 0)),
axis=1)
else:
posts_f["Real Eng %"] = pd.Series(dtype=float)
audited_eng = posts_f["Real Eng %"].dropna()
best_eng = float(audited_eng.max()) if not audited_eng.empty else None
avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float)
# "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
# Total Followers, pinned to the end of this specific reporting period (start +
# this period's own new followers) — not the live/global current_total_followers,
# so a report read weeks later still shows that month's own total rather than
# whatever the count has drifted to by the time someone opens it. None whenever
# start_of_month_followers is None (same "all"/year-level cases).
end_of_month_followers = (
start_of_month_followers + new_followers if start_of_month_followers is not None else None
)
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,
"end_of_month_followers": end_of_month_followers,
"monthly_growth_rate": monthly_growth_rate,
"period_label": _period_label(timeframe_value),
}
def _bullets(items, style):
return [Paragraph(f"&bull;&nbsp; {item}", style) for item in items]
def _ascii_safe(s):
"""Strips diacritics reportlab's base-14 Helvetica can't render (e.g. Milena
Petkovic's c-acute shows as a black-box glyph, not the accented letter) — a
plain-text fallback rather than embedding a Unicode TTF for one table."""
import unicodedata
return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
def _rich_text_to_html(parts):
"""Converts the (text, is_bold) tuple format used for the Core Message/Roadmap
abstracts (see monthly_report_notes.py) into reportlab's HTML-subset markup.
bold="br" inserts a line break; text is ignored for that entry."""
out = []
for text, bold in parts:
if bold == "br":
out.append("<br/>")
elif bold:
out.append(f"<b>{text}</b>")
else:
out.append(text)
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 <w:shd> 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.
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, 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"]
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"]
styles = getSampleStyleSheet()
# The campaign-report title ("TXproducts — {Month Year} End of Month Campaign Report")
# is long enough that the default 20pt Title size wraps to two lines and crowds the
# subtitle right below it, so it gets a smaller size to stay on one line at A4 width.
title_style = ParagraphStyle("TXTitle", parent=styles["Title"], textColor=NAVY,
fontSize=16 if monthly_notes else 20, spaceAfter=2)
subtitle_style = ParagraphStyle("TXSubtitle", parent=styles["Normal"], textColor=TEXT_GRAY, fontSize=10, spaceAfter=16)
h2_style = ParagraphStyle("TXH2", parent=styles["Heading2"], textColor=NAVY, fontSize=13, spaceBefore=16, spaceAfter=8)
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)
title_text = (f"TXproducts — {period_label} End of Month Campaign Report" if monthly_notes
else "TXproducts, LinkedIn Performance Report")
story = []
story.append(Paragraph(title_text, title_style))
story.append(Paragraph(
f"Period: {period_label} &nbsp;|&nbsp; Generated {pd.Timestamp.now().strftime('%B %d, %Y')} "
f"&nbsp;|&nbsp; Data as of {D['last_updated'].strftime('%B %d, %Y')}",
subtitle_style))
story.append(HRFlowable(width="100%", color=NAVY, thickness=1.2))
# --- KPI summary table ---
story.append(Paragraph("Key Metrics", h2_style))
kpi_data = [
["Month Start", "New Followers", "Total Followers\n(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 "—"],
]
kpi_table = Table(kpi_data, colWidths=[2 * cm] * 9)
kpi_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
("FONTSIZE", (0, 0), (-1, 0), 7),
("FONTSIZE", (0, 1), (-1, 1), 12),
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("TOPPADDING", (0, 0), (-1, -1), 6),
("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))
# --- Section abstracts: short summaries for the 4 sections that have hand-authored
# framing text (Core Message, Roadmap, Follower Study, Monthly Notes). All Posts,
# Follower Growth, and Audience Composition don't have separate abstracts in the
# live Reports tab either — their real content (chart/table) follows directly below,
# same as here. Full deep-dive detail for every section is further down the report.
if monthly_notes:
story.append(Paragraph("This Month's Sections", h2_style))
core_message_abstract = monthly_notes.get("core_message_abstract")
if core_message_abstract:
story.append(Paragraph("Core Message", ParagraphStyle(
"TXH3a", parent=body_style, fontName="Helvetica-Bold", spaceBefore=6)))
story.append(Paragraph(_rich_text_to_html(core_message_abstract), body_style))
roadmap_abstract = monthly_notes.get("roadmap_abstract")
if roadmap_abstract:
story.append(Paragraph("Campaign Roadmap", ParagraphStyle(
"TXH3b", parent=body_style, fontName="Helvetica-Bold", spaceBefore=10)))
story.append(Paragraph(_rich_text_to_html(roadmap_abstract), body_style))
story.append(Paragraph("Follower Study", ParagraphStyle(
"TXH3c", parent=body_style, fontName="Helvetica-Bold", spaceBefore=10)))
story.append(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.",
body_style))
story.append(Spacer(1, 10))
# --- Weekly growth pace (only for the generic sidebar PDF control, "All Time"/a
# year/an arbitrary month, not the monthly campaign report: "this week vs. a rolling
# best weekly target" is a different cadence than a monthly retrospective, and
# whichever week happens to be "current" when the PDF is generated might not even
# fall inside the month being summarized) ---
if not monthly_notes:
story.append(Paragraph("Weekly Growth Pace", h2_style))
on_track = growth_snapshot["on_track"]
if on_track is None:
status_color, status_label = TEXT_GRAY, "Building History"
elif on_track:
status_color, status_label = colors.HexColor("#52C41A"), "On Track"
else:
status_color, status_label = colors.HexColor("#d43f3a"), "Behind"
weekly_pct = growth_snapshot["week_to_date_pct"]
target_pct = growth_snapshot["rolling_best_weekly_pct"]
objective_data = [
["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],
]
objective_table = Table(objective_data, colWidths=[5 * cm, 6 * cm, 4 * cm])
objective_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTSIZE", (0, 1), (-1, 1), 11),
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
("TEXTCOLOR", (2, 1), (2, 1), status_color),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))
story.append(objective_table)
story.append(Paragraph(
f"Target is a rolling personal best, the highest trailing-7-day growth rate seen in "
f"the last {growth_snapshot['rolling_best_weeks']} weeks, not a fixed percentage. It "
f"rises automatically as performance improves, but a one-off spike ages out of the "
f"window after {growth_snapshot['rolling_best_weeks']} weeks rather than setting an "
f"unreachable target permanently.",
note_style))
# --- Audience Composition: Sector only, matching the live Reports tab's compact
# card exactly (Seniority/Country moved to the Audience Composition deep-dive
# further down, same split as the live app). Features/Fixes Shipped, Data Quality,
# Follower Study/Directory Updates, and Outstanding moved to the Monthly Notes
# deep-dive at the end, since the live Reports tab no longer shows them here either.
if monthly_notes:
story.append(Paragraph("Audience Composition (Follower Directory Coverage)", h2_style))
cohort = follower_directory_data.composition_stats(directory_data, timeframe_value)
if cohort["total"]:
sector_table = Table(_counter_rows(cohort["sector"], "Sector (this month)"),
colWidths=[9 * cm, 3 * cm])
sector_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(sector_table)
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(Spacer(1, 10))
# --- Follower growth chart ---
if not fts_f.empty:
story.append(Paragraph("Follower Growth", h2_style))
fig = daily_followers_figure(fts_f, posts_f)
fig.update_layout(showlegend=True, font=dict(size=14))
story.append(_fig_to_image(fig))
# --- 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))
# NaN (unaudited) Real Eng % sorts last by default, so the ranking never needs
# to fall back to the raw, Team-inflated number to fill out the top 5.
shown_posts = posts_f.sort_values("Real Eng %", ascending=False).head(5)
if len(posts_f):
# A repost/non-campaign post is still listed (not silently dropped — a monthly
# report is a complete record of what was published), but struck through and
# marked "Excluded", same pale-red treatment as the register table above and
# the live Reports tab's equivalent card, instead of "Pending audit" reading
# like an unfinished audit rather than a deliberate exclusion.
posts_cell_style = ParagraphStyle("TXPostsCell", parent=body_style, fontSize=8)
rows = [["Post", "Date", "Type", "Impressions", "CTR %", "Real Eng %"]]
excluded_rows = []
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.append(i)
rows.append([
Paragraph(f"<strike>{str(r['Short title'])[:60]}</strike>", posts_cell_style),
Paragraph(f"<strike>{r['Created date'].strftime('%d/%m/%Y')}</strike>", posts_cell_style),
Paragraph(f"<strike>{r['Content Type']}</strike>", posts_cell_style),
Paragraph(f"<strike>{int(r['Impressions']):,}</strike>", posts_cell_style),
Paragraph(f"<strike>{r['CTR %']:.1f}%</strike>", posts_cell_style),
Paragraph("<strike>Excluded</strike>", posts_cell_style),
])
else:
rows.append([
Paragraph(str(r["Short title"])[:60], posts_cell_style),
r["Created date"].strftime("%d/%m/%Y"),
r["Content Type"],
f"{int(r['Impressions']):,}",
f"{r['CTR %']:.1f}%",
f"{real_eng:.1f}%" if pd.notna(real_eng) else "Pending audit",
])
posts_table = Table(rows, colWidths=[7 * cm, 2.2 * cm, 2.2 * cm, 2.4 * cm, 1.8 * cm, 1.8 * cm])
posts_style = [
("BACKGROUND", (0, 0), (-1, 0), NAVY),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTSIZE", (0, 0), (-1, -1), 8),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]
for row_idx in excluded_rows:
posts_style.append(("BACKGROUND", (0, row_idx), (-1, row_idx), colors.HexColor("#fbdcdc")))
posts_table.setStyle(TableStyle(posts_style))
story.append(posts_table)
else:
story.append(Paragraph("No posts published in this period.", body_style))
# --- Content format performance ---
if len(posts_f):
story.append(Paragraph("Impressions by Content Format", h2_style))
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))
story.append(_fig_to_image(fig2, height_px=220))
# --- Competitor snapshot ---
story.append(Paragraph("Competitor Snapshot", h2_style))
story.append(Paragraph(
"Cumulative export snapshot, not specific to the selected period.", note_style))
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"]])
comp_table = Table(comp_rows, colWidths=[5 * cm, 2.6 * cm, 2 * cm, 2.2 * cm, 2.6 * cm, 2.2 * cm])
comp_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), NAVY),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTSIZE", (0, 0), (-1, -1), 8),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
story.append(comp_table)
# --- Deep dives: full detail behind each of the 7 Reports-tab sections, in the same
# order they appear there. Mirrors app.py's *_insights_page() functions — like those,
# the register ratings/trilogy/notable-contacts content here is hand-curated for July
# specifically (see _JULY_REGISTER_RATINGS in app.py), not computed generically, so
# it needs the same re-authoring for a future month that the live pages would. ---
if monthly_notes:
# repost_dates: a reshare of someone else's post, not TXproducts' own content —
# excluded from every format/day/traction breakdown below, same as app.py's
# _reports_posts_f(). The full "All Posts" listing/count elsewhere is untouched,
# since that's a record of what was published, not a format-performance read.
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)
story.append(Spacer(1, 10))
story.append(HRFlowable(width="100%", color=NAVY, thickness=1.2))
story.append(Paragraph("Section Deep Dives", title_style))
# --- Core Message: the document's own 4-step revision cycle (p.2-8) ---
story.append(Paragraph("Core Message — Four Step Framework (p.2)", h2_style))
story.append(Paragraph(
"These four steps were used to develop the messaging. They are also the "
"framework for revising it: <b>Analyze &rarr; Extract &rarr; Formulate &rarr; "
"Validate.</b>", body_style))
story.append(Paragraph("ANALYZE: Who is the target audience and what is their "
"problem? (p.3)", h2_style))
if cohort["total"]:
_an_th_style = TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#fdf6d8")),
("FONTSIZE", (0, 0), (-1, -1), 7.5),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
])
an_table_sector = Table(_counter_rows(cohort["sector"], "Sector (July cohort)"),
colWidths=[5.5 * cm, 2 * cm])
an_table_sector.setStyle(_an_th_style)
an_table_func = Table(_counter_rows(cohort["function"], "Function (July cohort)"),
colWidths=[5.5 * cm, 2 * cm])
an_table_func.setStyle(_an_th_style)
an_table = Table([[an_table_sector, an_table_func]])
story.append(Paragraph("Academia/Research Institute is the largest single "
"sector this month.", body_style))
story.append(an_table)
story.append(Paragraph("EXTRACT: The Formula and the Final Message (p.4)", h2_style))
story.append(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.", body_style))
story.append(Paragraph(
"We seem to have something closer to an actual playbook where we clearly see "
"the value proposition.", body_style))
story.extend(_bullets([
"<b>14/07</b> &mdash; introduces the problem (operation modes, frustration of waiting)",
"<b>28/07</b> &mdash; deepens it with the Ferris wheel analogy, ends on a cliffhanger CTA",
"<b>30/07</b> &mdash; delivers the solution, in Daniel's own voice, closing the loop",
], body_style))
story.append(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.", body_style))
story.append(Paragraph("FORMULATE: Boilerplate Register (p.5), Analytical "
"Dimensions Methodology (p.8)", h2_style))
reg_cell_style = ParagraphStyle("TXRegCell", parent=body_style, fontSize=7.5, leading=9)
reg_rows = [["Date", "Format", "Real Eng %", "Post", "Rating", "Why"]]
for date, fmt, eng, post, rating, why in july_register_ratings:
if rating == "Repost":
# Struck through: a reshare of someone else's post, excluded from comparison.
date, fmt, eng, post_txt, rating, why = (
Paragraph(f"<strike>{v}</strike>", reg_cell_style)
for v in (date, fmt, eng, post[:50], rating, why))
reg_rows.append([date, fmt, eng, post_txt, rating, why])
else:
reg_rows.append([date, fmt, eng, Paragraph(post[:50], reg_cell_style), rating, why])
reg_table = Table(reg_rows, colWidths=[1.6 * cm, 2 * cm, 1.6 * cm, 6 * cm, 2 * cm, 4.3 * cm])
repost_row = next((i for i, r in enumerate(july_register_ratings, start=1)
if r[4] == "Repost"), None)
reg_style = [
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#fdf6d8")),
("FONTSIZE", (0, 0), (-1, -1), 7.5),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
if repost_row is not None:
reg_style.append(("BACKGROUND", (0, repost_row), (-1, repost_row), colors.HexColor("#fbdcdc")))
reg_table.setStyle(TableStyle(reg_style))
story.append(reg_table)
story.append(Spacer(1, 6))
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."),
]:
story.append(Paragraph(f"<b>{dim_title}:</b> {dim_text}", body_style))
story.append(Paragraph("VALIDATE (p.6)", h2_style))
story.append(Paragraph(
"Validation was tried with the July 21 poll in which <b>zero</b> people took "
"part in.", body_style))
story.append(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.", body_style))
# --- Campaign Roadmap: Four Stages, Traction by Format, Best Posting Day,
# Growth Windows (p.2-5 of the Roadmap) — mirrors app.py's
# _roadmap_insights_page(), reposts already excluded above via posts_fmt. ---
story.append(Paragraph("Campaign Roadmap — Four Stages in Sequence (p.4)", h2_style))
story.append(Paragraph(
"Stage 1, <b>Traction &mdash; Find the Voice</b> (July), is the current stage: "
"establish a consistent brand voice and discover which posts/formats drive "
"engagement, ahead of Demographics, Conversion and Growth.", body_style))
story.append(Paragraph("Traction by Format (p.3)", h2_style))
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()]
fmt_table = Table(fmt_rows, colWidths=[2.3 * cm, 1.6 * cm, 4.8 * cm, 2.2 * cm, 2.2 * cm, 2.1 * cm])
fmt_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#fdf6d8")), ("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(fmt_table)
story.append(Spacer(1, 8))
story.append(Paragraph("Best Posting Day (p.2)", h2_style))
by_day = (posts_fmt.groupby("Weekday").agg(**{"Real Avg Eng %": ("Real Eng %", "mean")}).round(1))
best_day = by_day["Real Avg Eng %"].idxmax()
story.append(Paragraph(f"<b>{best_day}</b> shows the highest average engagement this month.", body_style))
# Chronological, not grouped by weekday — same sort order as every other
# per-post table in this report, so cross-referencing a post between them
# isn't confusing. The format/day confound is called out separately below.
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"],
Paragraph(str(r["Short title"])[:30], body_style), 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()]
wk_table = Table(wk_rows, colWidths=[1.3*cm, 1.7*cm, 1.7*cm, 3.5*cm, 1.8*cm, 1.3*cm, 1.3*cm, 1.3*cm, 1.2*cm, 1.5*cm, 1.4*cm])
wk_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#fdf6d8")), ("FONTSIZE", (0, 0), (-1, -1), 6.5),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
story.append(wk_table)
story.append(Spacer(1, 4))
flag_style = ParagraphStyle("TXFlag", parent=body_style, backColor=colors.HexColor("#fbdcdc"),
textColor=colors.HexColor("#8a3a3a"), borderPadding=6)
story.append(Paragraph(
"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.", flag_style))
story.append(Paragraph("Real Engagement Over Time", h2_style))
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",
))
story.append(_fig_to_image(eng_fig, height_px=200, width_cm=13))
story.append(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.", body_style))
# --- All Posts: Traction by Format + Top Posts (its own Reports-tab section,
# distinct from Roadmap above even though both read the same posts_fmt) ---
story.append(Paragraph("All Posts — Traction by Format", h2_style))
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()]
ap_fmt_table = Table(ap_fmt_rows, colWidths=[4 * cm, 2.5 * cm, 2.5 * cm, 4 * cm])
ap_fmt_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(ap_fmt_table)
story.append(Spacer(1, 8))
story.append(Paragraph("Top Posts by Comments + Shares", h2_style))
top_posts = posts_fmt.sort_values("Shares", ascending=False).head(3)
top_rows = [["Post", "Date", "Format", "Comments", "Reposts"]] + [
[Paragraph(str(r["Short title"])[:50], body_style), r["Created date"].strftime("%d/%m/%Y"),
r["Content Type"], str(int(r["Comments"])), str(int(r["Reposts"]))]
for _, r in top_posts.iterrows()]
top_table = Table(top_rows, colWidths=[6 * cm, 2.2 * cm, 2.5 * cm, 2.2 * cm, 2.2 * cm])
top_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(top_table)
# --- Follower Growth: weekly breakdown + growth windows ---
story.append(Paragraph("Follower Growth — New Followers by Week", h2_style))
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()]
week_table = Table(week_rows, colWidths=[6 * cm, 4 * cm])
week_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(week_table)
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)
story.append(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.", note_style))
# --- Audience Composition: Seniority + Country ---
story.append(Paragraph("Audience Composition — Seniority and Country", h2_style))
if cohort["total"]:
sen_table = Table(_counter_rows(cohort["seniority"], "Seniority (July cohort)"),
colWidths=[9 * cm, 3 * cm])
sen_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(sen_table)
story.append(Spacer(1, 6))
country_rows = [["Country (July cohort)", "Count"]] + [[str(k), str(v)] for k, v in cohort["country"]]
country_table = Table(country_rows, colWidths=[9 * cm, 3 * cm])
country_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(country_table)
# --- Follower Study: Notable Contacts ---
story.append(Paragraph("Follower Study — Notable Contacts", h2_style))
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", "")))
story.append(Paragraph(
f"{len(notable)} followers across the whole Directory sit at C-Level/Founder or "
f"Director/Head/Principal seniority (internal team members excluded).", body_style))
_notable_cell_style = ParagraphStyle("TXNotable", parent=body_style, fontSize=7.5)
notable_rows = [["Name", "Seniority", "Company", "Country"]] + [
[Paragraph(_ascii_safe(d.get("name", "")), _notable_cell_style),
d.get("seniority", ""),
Paragraph(_ascii_safe(d.get("company", "")), _notable_cell_style),
d.get("country", "")]
for d in notable]
notable_table = Table(notable_rows, colWidths=[3.5 * cm, 3.3 * cm, 6.2 * cm, 3 * cm])
notable_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY), ("FONTSIZE", (0, 0), (-1, -1), 7.5),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
story.append(notable_table)
story.append(Spacer(1, 16))
story.append(Paragraph("TXproducts · Internal Report · Generated automatically from LinkedIn Page Analytics exports.", note_style))
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=1.5 * cm, bottomMargin=1.5 * cm,
leftMargin=1.5 * cm, rightMargin=1.5 * cm)
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()