diff --git a/app.py b/app.py index 01f2965..c98804c 100644 --- a/app.py +++ b/app.py @@ -12,11 +12,18 @@ import pandas as pd import report import growth import visitor_history +import monthly_report_notes +import follower_directory_data # Overridable via env var so the same code runs unmodified in Docker/internal deployments — # defaults to this machine's path for local dev. DATA_DIR = os.environ.get("LINKEDIN_DATA_DIR", "/Users/mckinleyblack/Documents") UPLOAD_DIR = os.path.join(DATA_DIR, "linkedin_analytics") +ASSETS_DIR = os.path.join(UPLOAD_DIR, "assets") + +# Loaded once at startup, not re-parsed per callback — re-run the app to pick up new +# assets/follower_directory.html entries, same as every other data source here. +DIRECTORY_DATA = follower_directory_data.load_directory(ASSETS_DIR) # LinkedIn's export only gives daily gains, never an absolute follower total. This is # the one manual anchor point: the count going into the very first day this export @@ -1347,6 +1354,7 @@ def refresh_data(): TAB_CONTENT_MAP = { "overview": overview, "content": content_tab, "followers": followers_tab, "visitors": visitors_tab, "competitors": competitors_tab, "growth": growth_tab, + "reports": reports_tab, } @@ -2023,6 +2031,46 @@ def build_growth_tab(): ], fluid=True, className="pb-3") +def _reports_month_options(): + """Only months with curated narrative notes are selectable here, not every month + with export data — this tab is about the campaign's written progress reports, not + a generic timeframe filter (that's what the sidebar's PDF control already does).""" + import calendar as calmod + opts = [] + for key in sorted(monthly_report_notes.NOTES.keys()): + year, month = (int(p) for p in key.split("-")) + opts.append({"label": f"{calmod.month_name[month]} {year}", "value": key}) + return opts + + +_REPORTS_MONTH_OPTIONS = _reports_month_options() +_REPORTS_DEFAULT_MONTH = _REPORTS_MONTH_OPTIONS[-1]["value"] if _REPORTS_MONTH_OPTIONS else None + +reports_tab = dbc.Container([ + html.Div([ + html.Div([ + html.Label("Reporting Month", style={"fontSize": "0.75rem", "fontWeight": "600", + "marginBottom": "4px", "display": "block"}), + dcc.Dropdown( + id="reports-month-select", + options=_REPORTS_MONTH_OPTIONS, value=_REPORTS_DEFAULT_MONTH, + clearable=False, searchable=False, style={"width": "260px"}, + ), + ]), + html.Div([ + html.Button("Download PDF", 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", + "fontSize": "0.8rem", "fontWeight": "700", "cursor": "pointer"}), + dcc.Download(id="reports-download"), + ]), + ], style={"display": "flex", "justifyContent": "space-between", "alignItems": "flex-end", + "marginBottom": "1.25rem"}), + html.Div(id="reports-content-area"), +], fluid=True, className="pb-3") + + refresh_data() @@ -2040,6 +2088,7 @@ NAV_ITEMS = [ ("visitors", "Visitors", visitors_tab), ("competitors", "Competitors", competitors_tab), ("growth", "Growth", growth_tab), + ("reports", "Reports", reports_tab), ] TAB_CONTENT_MAP = {key: content for key, _, content in NAV_ITEMS} @@ -2050,6 +2099,7 @@ TAB_HERO = { "visitors": ("Visitors", "Page views and unique visitors to the TXproducts LinkedIn Page, broken down by device and section."), "competitors": ("Competitors", "Benchmarking TXproducts against tracked competitor pages on LinkedIn."), "growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."), + "reports": ("Reports", "Monthly written progress reports for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory."), } GROWTH_METRIC_EXPLAINER = html.Div([ @@ -2505,6 +2555,18 @@ 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.""" + return { + "D": D, "posts": posts, "comp_ordered": comp_ordered, + "current_total_followers": current_total_followers, + "daily_followers_figure": daily_followers_figure, + "std_fn": _std, "TMPL": TMPL, "FORMAT_COLORS": FORMAT_COLORS, + "growth_snapshot": GROWTH_SNAPSHOT, "directory_data": DIRECTORY_DATA, + } + + @app.callback( Output("report-download", "data"), Input("generate-report-btn", "n_clicks"), @@ -2512,19 +2574,168 @@ def _sync_content_charts(selected_month): prevent_initial_call=True, ) def _generate_report(_n_clicks, timeframe): - ctx_data = { - "D": D, "posts": posts, "comp_ordered": comp_ordered, - "current_total_followers": current_total_followers, - "daily_followers_figure": daily_followers_figure, - "std_fn": _std, "TMPL": TMPL, "FORMAT_COLORS": FORMAT_COLORS, - "growth_snapshot": GROWTH_SNAPSHOT, - } - pdf_bytes = report.build_report_pdf(ctx_data, timeframe) + pdf_bytes = report.build_report_pdf(_build_report_ctx(), timeframe) label = next((o["label"] for o in report.report_month_options(D["fts"]) if o["value"] == timeframe), timeframe) safe_label = label.replace(" ", "_") filename = f"TXproducts_LinkedIn_Report_{safe_label}.pdf" return dcc.send_bytes(lambda buf: buf.write(pdf_bytes), filename=filename) +def _narrative_section(title, items): + if not items: + return html.Div() + return html.Div([ + html.H5(title, style={"marginTop": "18px", "marginBottom": "8px", "color": "#1b3a5c"}), + html.Ul([html.Li(item, style={"marginBottom": "4px"}) for item in items], + style={"fontSize": "0.85rem", "paddingLeft": "1.1rem"}), + ]) + + +def _composition_table(counter, header_left, top_n=None): + """No truncation by default, sector/seniority/function are a small, fixed taxonomy + (see project memory's classification categories), and country is uncapped by + composition_stats() for the same reason, so showing everything keeps the table's + own sum matching the cohort total instead of silently dropping tail categories.""" + items = counter.most_common(top_n) if hasattr(counter, "most_common") else list(counter)[:top_n] + return dbc.Table([ + html.Thead(html.Tr([html.Th(header_left), html.Th("Count")])), + html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in items]), + ], bordered=True, size="sm", style={"fontSize": "0.8rem"}) + + +def _reports_posts_table(posts_f): + """Every post in the period, listed chronologically, not just a top-N by engagement. + A monthly campaign report should show the full picture of what was published, since + the point is a complete record, not a highlights reel.""" + if not len(posts_f): + return html.Div("No posts published in this period.", className="text-muted") + all_posts = posts_f.sort_values("Created date") + return dbc.Table([ + html.Thead(html.Tr([html.Th(c) for c in + ["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]])), + html.Tbody([ + html.Tr([ + html.Td(str(r["Short title"])[:60]), + html.Td(r["Created date"].strftime("%d/%m/%Y")), + html.Td(r["Content Type"]), + html.Td(f"{int(r['Impressions']):,}"), + html.Td(f"{r['Eng %']:.1f}%"), + html.Td(f"{r['CTR %']:.1f}%"), + ]) for _, r in all_posts.iterrows() + ]), + ], bordered=True, size="sm", style={"fontSize": "0.8rem"}) + + +@app.callback( + Output("reports-content-area", "children"), + Input("reports-month-select", "value"), +) +def _render_reports_tab(month_value): + if not month_value: + return dbc.Alert("No campaign months have been written up yet.", color="info") + + stats = report.compute_period_stats(_build_report_ctx(), month_value) + notes = monthly_report_notes.NOTES.get(month_value) + fts_f, posts_f = stats["fts_f"], stats["posts_f"] + + kpi_row = html.Div([ + html.Div(kpi("Total Followers", f"{current_total_followers:,}", "as of latest export"), + style={"flex": "1", "minWidth": "0"}), + html.Div(kpi("New Followers", f"{stats['new_followers']:,}", stats["period_label"]), + style={"flex": "1", "minWidth": "0"}), + html.Div(kpi("Posts Published", f"{stats['posts_published']:,}"), style={"flex": "1", "minWidth": "0"}), + html.Div(kpi("Total Impressions", f"{stats['total_impressions']:,}"), style={"flex": "1", "minWidth": "0"}), + html.Div(kpi("Total Page Views", f"{stats['total_page_views']:,}"), style={"flex": "1", "minWidth": "0"}), + html.Div(kpi("Best Post Eng.", f"{stats['best_eng']:.1f}%"), style={"flex": "1", "minWidth": "0"}), + html.Div(kpi("Avg. Eng. Rate", f"{stats['avg_eng']:.2f}%" if stats["avg_eng"] else "—"), + style={"flex": "1", "minWidth": "0"}), + html.Div(kpi( + "Monthly Growth Rate", + f"{stats['monthly_growth_rate']:+.1f}%" if stats["monthly_growth_rate"] is not None else "—", + (f"+{stats['new_followers']:,} vs {stats['start_of_month_followers']:,} at start " + f"of {stats['period_label']}") if stats["start_of_month_followers"] is not None else "", + color=GREEN if (stats["monthly_growth_rate"] or 0) >= 0 else "#d43f3a", + ), style={"flex": "1", "minWidth": "0"}), + ], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"}) + + chart_card = dbc.Card([ + dbc.CardHeader("Follower Growth"), + dbc.CardBody(G(daily_followers_figure(fts_f, posts_f), style={"height": "280px"}) if not fts_f.empty + else html.Div("No follower data for this period.", className="text-muted")), + ], className="mb-3 shadow-sm") + + posts_card = dbc.Card([ + dbc.CardHeader(f"All Posts, {stats['period_label']}"), + dbc.CardBody(_reports_posts_table(posts_f)), + ], className="mb-3 shadow-sm") + + cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value) + all_time = follower_directory_data.composition_stats(DIRECTORY_DATA) + if cohort["total"]: + composition_body = [ + html.P([ + f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}. " + f"Of those, ", + html.Strong(f"{cohort['total']} have been identified and profiled"), + f" in the Follower Directory so far (a separate, hand-maintained audit of who our " + f"followers are, not a second follower count), out of {all_time['total']} audited " + f"across the whole page to date.", + ], style={"fontSize": "0.85rem"}), + html.Div([ + html.Div(_composition_table(cohort["sector"], "Sector (this month)"), + style={"flex": "1", "minWidth": "220px"}), + html.Div(_composition_table(cohort["seniority"], "Seniority (this month)"), + style={"flex": "1", "minWidth": "220px"}), + html.Div(_composition_table(cohort["country"], "Country (this month)"), + style={"flex": "1", "minWidth": "220px"}), + ], style={"display": "flex", "gap": "1rem", "flexWrap": "wrap"}), + ] + else: + composition_body = [html.P( + f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}, " + f"but none have been identified and profiled in the Follower Directory yet.", + style={"fontSize": "0.85rem"})] + composition_card = dbc.Card([ + dbc.CardHeader("Audience Composition (Follower Directory Coverage)"), + dbc.CardBody(composition_body + [ + html.P("The Follower Directory is a hand-maintained audit that lags real LinkedIn follows " + "and gets corrected after the fact as more profiles are screenshotted and " + "cross-referenced, so this is a coverage figure that can still grow in later " + "updates, not a final count.", className="text-muted", + style={"fontSize": "0.75rem", "fontStyle": "italic", "marginTop": "10px"}), + ]), + ], className="mb-3 shadow-sm") + + if notes: + narrative_card = dbc.Card([ + dbc.CardHeader("Monthly Notes"), + dbc.CardBody([ + _narrative_section("Features / Fixes Shipped", notes.get("features_shipped", [])), + _narrative_section("Data Quality Issues Found and Fixed", notes.get("data_quality", [])), + _narrative_section("Follower Study / Directory Updates", notes.get("follower_study", [])), + _narrative_section("Outstanding", notes.get("outstanding", [])), + ]), + ], className="mb-3 shadow-sm") + else: + narrative_card = dbc.Alert("Notes for this month have not been written yet.", color="warning") + + return html.Div([kpi_row, chart_card, posts_card, composition_card, narrative_card]) + + +@app.callback( + Output("reports-download", "data"), + Input("reports-download-btn", "n_clicks"), + State("reports-month-select", "value"), + prevent_initial_call=True, +) +def _download_reports_pdf(_n_clicks, month_value): + 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) + 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) + + if __name__ == "__main__": app.run(debug=False, port=8051) diff --git a/build_static_export.py b/build_static_export.py index 20ffc49..e639a09 100644 --- a/build_static_export.py +++ b/build_static_export.py @@ -118,6 +118,15 @@ def render(comp): cls = type(comp).__name__ module = type(comp).__module__ + # --- Reports tab content area: empty in the live layout (filled by a callback), so + # bake in the one selectable month's rendered output directly. Switching months + # needs the live tool (see the disabled dropdown below), same rationale as every + # other live-only control in this exporter. + if getattr(comp, "id", None) == "reports-content-area": + if A._REPORTS_DEFAULT_MONTH: + return render(A._render_reports_tab(A._REPORTS_DEFAULT_MONTH)) + return render(dbc.Alert("No campaign months have been written up yet.", color="info")) + # --- Plotly graphs --- if isinstance(comp, dcc.Graph): return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None)) @@ -131,10 +140,13 @@ def render(comp): style = style_dict_to_css(getattr(comp, "style", None)) extra_class = getattr(comp, "className", "") or "" cid = getattr(comp, "id", "") - if cid == "report-timeframe-select": + if cid in ("report-timeframe-select", "reports-month-select"): + title = ("Switching months needs the live tool, not available in this snapshot." + if cid == "reports-month-select" else + "PDF generation needs the live tool, not available in this snapshot.") return (f'') + f'title="{title}">{opts}') return f'' # --- Stores/Download: no static equivalent needed (Download requires a live backend) --- @@ -184,6 +196,15 @@ def render(comp): md = getattr(comp, "md", None) colcls = f"col-md-{md}" if md else "col" return f'
{children_html}
' + if cls == "Table": + table_classes = "table" + if getattr(comp, "bordered", False): + table_classes += " table-bordered" + if getattr(comp, "striped", False): + table_classes += " table-striped" + if getattr(comp, "size", None): + table_classes += f" table-{comp.size}" + return f'{children_html}
' if cls == "Card": return f'
{children_html}
' if cls == "CardHeader": @@ -236,7 +257,7 @@ def render(comp): if cls == "Hr": return f'
' if cls == "Button": - if cid == "generate-report-btn": + if cid in ("generate-report-btn", "reports-download-btn"): 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'