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>
This commit is contained in:
1 parent
f254a9f10f
commit
292bd21abe
3 files changed
+453
-7
No files matched your search
@@ -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__":
|
||||
|
||||
@@ -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'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled '
|
||||
f'title="PDF generation needs the live tool (requires a Python backend), not available in this snapshot.">'
|
||||
f'title="Report generation needs the live tool (requires a Python backend), not available in this snapshot.">'
|
||||
f'{children_html}</button>')
|
||||
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
|
||||
return f'<button type="button"{id_attr}{class_attr} style="{style}"{onclick}{title_attr}>{children_html}</button>'
|
||||
|
||||
@@ -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 <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.
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user