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()