Files
LinkedIn-Analytics-Dashboard/report.py
T
beantownbabe 348fe65b88 Initial commit: TXproducts LinkedIn Analytics Dashboard
Dash app with Overview, Content, Followers, Visitors, Competitors, and
Growth tabs. Includes weekly-pace tracking, PDF report generation, data
upload with live refresh, and a static HTML export builder for sharing
via NextCloud.
2026-07-21 15:14:28 +02:00

252 lines
12 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
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)
NAVY = colors.HexColor("#1b3a5c")
LIGHT_GRAY = colors.HexColor("#f0f4f8")
TEXT_GRAY = colors.HexColor("#6b7a8d")
def report_month_options(fts_df):
"""'All Time', then one entry per year, then one entry per (year, month) — lets the
report be scoped to as broad or as narrow a window as the export data supports."""
import calendar as calmod
dates = fts_df["Date"]
years = sorted({d.year for d in dates})
months = sorted({(d.year, d.month) for d in dates})
options = [{"label": "All Time", "value": "all"}]
options += [{"label": str(y), "value": f"year-{y}"} for y in years]
options += [{"label": f"{calmod.month_name[m]} {y}", "value": f"{y}-{m:02d}"} for y, m in months]
return options
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, fts_df):
if not value or value == "all":
return "All Time"
return next((o["label"] for o in report_month_options(fts_df) if o["value"] == value), 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 build_report_pdf(ctx, timeframe_value="all"):
"""ctx is a dict bundling everything the report needs from the live app:
D, posts, comp_ordered, current_total_followers, daily_followers_figure (the
figure-builder function), std_fn (the _std styling helper), TMPL, FORMAT_COLORS."""
D = ctx["D"]
posts = ctx["posts"]
comp_ordered = ctx["comp_ordered"]
current_total_followers = ctx["current_total_followers"]
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"]
fts_f = _filtered(D["fts"], "Date", timeframe_value)
posts_f = _filtered(posts, "Created date", timeframe_value)
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)
best_eng = float(posts_f["Eng %"].max()) if len(posts_f) else 0.0
avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float)
avg_eng = float(avg_eng_series.mean()) if not avg_eng_series.dropna().empty else 0.0
styles = getSampleStyleSheet()
title_style = ParagraphStyle("TXTitle", parent=styles["Title"], textColor=NAVY, fontSize=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)
period_label = _period_label(timeframe_value, D["fts"])
story = []
story.append(Paragraph("TXproducts, LinkedIn Performance Report", title_style))
story.append(Paragraph(
f"Period: {period_label}  |  Generated {pd.Timestamp.now().strftime('%B %d, %Y')} "
f" |  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 = [
["Total Followers\n(as of today)", "New Followers", "Posts Published", "Total Impressions",
"Total Page Views", "Best Post Eng.", "Avg. Eng. Rate"],
[f"{current_total_followers:,}", f"{new_followers:,}", f"{posts_published:,}",
f"{total_impressions:,}", f"{total_page_views:,}", f"{best_eng:.1f}%",
f"{avg_eng:.2f}%" if avg_eng else "—"],
]
kpi_table = Table(kpi_data, colWidths=[2.6 * cm] * 7)
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)
# --- Weekly growth pace ---
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))
# --- 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))
# --- Top posts table ---
story.append(Paragraph("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), h2_style))
if len(posts_f):
top_posts = posts_f.sort_values("Eng %", ascending=False).head(5)
rows = [["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]]
for _, r in top_posts.iterrows():
rows.append([
Paragraph(str(r["Short title"])[:60], body_style),
r["Created date"].strftime("%d/%m/%Y"),
r["Content Type"],
f"{int(r['Impressions']):,}",
f"{r['Eng %']:.1f}%",
f"{r['CTR %']:.1f}%",
])
posts_table = Table(rows, colWidths=[7 * cm, 2.2 * cm, 2.2 * cm, 2.4 * cm, 1.8 * cm, 1.8 * cm])
posts_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"),
("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),
]))
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)
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()