From cbb308cf375828c79bfa96246e79e1c57fc58cb5 Mon Sep 17 00:00:00 2001 From: beantownbabe <156287695+beantownbabe@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:29:21 +0200 Subject: [PATCH] Add Core Message/Roadmap/Monthly Notes cards and cross-page nav to Reports tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the Reports tab into a CSS Grid layout (Core Message + Campaign Roadmap stacked, spanning Follower Growth below, alongside All Posts; Audience Composition + Follower Study + Monthly Notes as a second row), each card carrying a short abstract with a link to its full detail. Adds three new "insights" sub-pages (Roadmap, Core Message, Monthly Notes) reachable via a shared hero-banner nav so hopping between them doesn't require detouring through the main Reports page each time. Also fixes two content-type misclassifications (23/07, 30/07 should be Carousel) and trims chart chrome (legend, axis labels/padding) to reclaim vertical space without shrinking bar height. PDF (report.py) and static export (build_static_export.py) intentionally not yet updated to mirror these Reports tab changes — deferred until layout iteration is done. --- .gitignore | 4 + app.py | 434 ++++++++++++++++++++++++++++++++++------ monthly_report_notes.py | 11 + 3 files changed, 386 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index 1db89fe..4e657e7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ __pycache__/ # OS .DS_Store manual_followers_list_* + +# Dev server logs from manual restarts during a session — not shared code +app_restart*.log +app_morning.log diff --git a/app.py b/app.py index 02d337c..3a24bd6 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,7 @@ import warnings; warnings.filterwarnings("ignore") import os from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update +from dash.exceptions import PreventUpdate import dash_bootstrap_components as dbc import plotly.express as px import plotly.graph_objects as go @@ -42,6 +43,8 @@ MANUAL_CONTENT_TYPES = { "2026-07-09": "Carousel", "2026-07-16": "Carousel", "2026-07-21": "Poll", + "2026-07-23": "Carousel", + "2026-07-30": "Carousel", } # ── Palette — matched to txdata_project/portal.py's navy/blue design language ── @@ -1608,7 +1611,7 @@ def daily_followers_figure(fts_df, posts_df, year=None, month=None): fig = px.bar(df, x="Date", y=["Organic followers", "Auto-invited followers"], template=TMPL, barmode="stack", color_discrete_sequence=[BLUE, AMBER], - labels={"value": "Followers", "variable": ""}) + labels={"value": "Followers", "variable": "", "Date": ""}) return _std(mark_posts(fig, df, pdf)) @@ -2114,7 +2117,10 @@ 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."), + "reports": ("Reports", "Monthly progress reports for LinkedIn, combining the dashboard data with the Follower Directory."), + "roadmap-insights": ("Campaign Roadmap — Insights", "July's Month 1 checkpoint: traction by format, top posts by comments and shares, and best posting day."), + "core-message-insights": ("Core Message — Insights", "How July's new followers compare to the Core Message's foundation, by function and sector."), + "monthly-notes-insights": ("Monthly Notes — Insights", "Features/fixes shipped, data quality issues found and fixed, Follower Study/Directory updates, and outstanding items for July."), } GROWTH_METRIC_EXPLAINER = html.Div([ @@ -2126,7 +2132,19 @@ GROWTH_METRIC_EXPLAINER = html.Div([ TAB_HERO_INFO = {"growth": GROWTH_METRIC_EXPLAINER} +def _hero_title_children(active): + """Reports folds its subtitle onto the same line as the title ("Reports — Monthly + progress reports...") instead of a separate line below, to give the tab's own + content (already the most vertically dense tab) a bit more viewing space.""" + title, subtitle = TAB_HERO[active] + if active != "reports": + return title + return [title, html.Span(f" — {subtitle}", style={"fontWeight": "500", "fontSize": "0.85rem"})] + + def _hero_subtitle_children(active): + if active == "reports": + return "" text = TAB_HERO[active][1] info = TAB_HERO_INFO.get(active) if info is None: @@ -2134,25 +2152,50 @@ def _hero_subtitle_children(active): return [text, info_popover(f"hero-{active}", info)] +_REPORT_HERO_LINK_STYLE = { + "color": "white", "fontSize": "0.8rem", "fontWeight": "600", + "textDecoration": "none", "padding": "7px 14px", + "backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px", + "border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap", +} + +# The 3 sub-pages reachable from Reports cards, plus Reports itself — every one of +# these 4 gets a nav back to the other 3 (+ the external Follower Study) in its own +# hero banner, so hopping between insights doesn't require detouring through Reports +# each time. +_REPORT_SECTIONS = [ + ("reports", "Reports", "/reports"), + ("roadmap-insights", "Roadmap Insights", "/roadmap-insights"), + ("core-message-insights", "Core Message Insights", "/core-message-insights"), + ("monthly-notes-insights", "Monthly Notes Insights", "/monthly-notes-insights"), +] + + +def _report_section_nav(active): + links = [dcc.Link(label, href=href, style=_REPORT_HERO_LINK_STYLE) + for key, label, href in _REPORT_SECTIONS if key != active] + links.append(html.A("Follower Study", href="/assets/follower_study.html", target="_blank", + rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE)) + for link in links[1:]: + link.style = {**link.style, "marginLeft": "8px"} + return links + + def _hero_actions(active): """Follower Directory / Follower Study links shown in the hero banner — only on the Followers tab, not in the sidebar. Both are standalone HTML files regenerated from manual_followers_list_2026-07-22.xlsx and copied into assets/, so Dash serves them as-is at /assets/... ; re-copy them here after every follower data update.""" - if active != "followers": - return [] - link_style = { - "color": "white", "fontSize": "0.8rem", "fontWeight": "600", - "textDecoration": "none", "padding": "7px 14px", - "backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px", - "border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap", - } - return [ - html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank", - rel="noopener noreferrer", style=link_style), - html.A("Follower Study", href="/assets/follower_study.html", target="_blank", - rel="noopener noreferrer", style={**link_style, "marginLeft": "8px"}), - ] + if active == "followers": + return [ + html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank", + rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE), + html.A("Follower Study", href="/assets/follower_study.html", target="_blank", + rel="noopener noreferrer", style={**_REPORT_HERO_LINK_STYLE, "marginLeft": "8px"}), + ] + if active in {key for key, _, _ in _REPORT_SECTIONS}: + return _report_section_nav(active) + return [] _SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR) @@ -2242,13 +2285,20 @@ main_content = html.Div([ html.Div(overview, id="tab-content-area"), ], style={"flex": "1", "minWidth": "0", "padding": "20px 24px"}) -app.layout = html.Div([sidebar, main_content], style={"display": "flex", "alignItems": "stretch"}) +# A single, persistent dcc.Location (never remounted) drives the two insights-page +# links via pathname, instead of n_clicks on the link elements themselves — those get +# recreated every time the Reports tab re-renders, and Dash fires a dynamically-mounted +# component's callback once on its first appearance regardless of prevent_initial_call, +# which was hijacking the view back to an insights page immediately on every Reports +# render. pathname changes only on an actual click, so this doesn't have that problem. +app.layout = html.Div([dcc.Location(id="url-router", refresh=False), sidebar, main_content], + style={"display": "flex", "alignItems": "stretch"}) @app.callback( [Output(f"nav-{key}", "className") for key, _, _ in NAV_ITEMS] + [Output("tab-content-area", "children"), Output("hero-title", "children"), Output("hero-subtitle", "children"), - Output("hero-actions", "children")], + Output("hero-actions", "children"), Output("url-router", "pathname")], [Input(f"nav-{key}", "n_clicks") for key, _, _ in NAV_ITEMS] + [Input("growth-indicator-btn", "n_clicks")], ) @@ -2259,7 +2309,41 @@ def _switch_tab(*_): else: active = triggered.replace("nav-", "") if triggered else "overview" classes = ["sidebar-nav-btn active" if key == active else "sidebar-nav-btn" for key, _, _ in NAV_ITEMS] - hero_title = TAB_HERO[active][0] + hero_title = _hero_title_children(active) + # Also resets url-router's pathname to "/" — without this, revisiting the SAME + # insights link twice in a row (e.g. Follower Growth then All Posts, both pointing + # at /roadmap-insights) does nothing the second time, since pathname never actually + # changed value in between (going back to Reports via the sidebar doesn't touch it). + return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), + _hero_actions(active), "/"] + + +_INSIGHTS_ROUTES = {"/roadmap-insights": "roadmap-insights", "/core-message-insights": "core-message-insights", + "/monthly-notes-insights": "monthly-notes-insights", "/reports": "reports"} + + +@app.callback( + [Output(f"nav-{key}", "className", allow_duplicate=True) for key, _, _ in NAV_ITEMS] + + [Output("tab-content-area", "children", allow_duplicate=True), + Output("hero-title", "children", allow_duplicate=True), + Output("hero-subtitle", "children", allow_duplicate=True), + Output("hero-actions", "children", allow_duplicate=True)], + Input("url-router", "pathname"), + prevent_initial_call=True, +) +def _switch_to_insights_page(pathname): + """Driven by dcc.Location's pathname rather than n_clicks on the link elements + themselves: those links live inside reports-content-area, which gets torn down and + recreated every time the Reports tab re-renders, and Dash fires a dynamically + re-mounted component's callback once on each fresh appearance regardless of + prevent_initial_call — that was hijacking the view back to an insights page on + every single Reports render. url-router is a single persistent component defined + once in app.layout, so its pathname only changes on an actual click.""" + active = _INSIGHTS_ROUTES.get(pathname) + if active is None: + raise PreventUpdate + classes = ["sidebar-nav-btn" for _ in NAV_ITEMS] + hero_title = _hero_title_children(active) return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), _hero_actions(active)] @@ -2466,6 +2550,47 @@ def _build_report_ctx(): } +def _rich_text(parts, style=None): + """Renders a list of (text, is_bold) tuples as one html.P, e.g. for a fixed tagline + that needs bolding inside otherwise-plain narrative copy. bold="br" inserts a line + break instead of wrapping text (text is ignored for that entry).""" + children = [] + for text, bold in parts: + if bold == "br": + children.append(html.Br()) + elif bold: + children.append(html.Strong(text)) + else: + children.append(text) + return html.P(children, style=style) + + +def _insights_link(href): + return html.Div( + dcc.Link("Click here for the insights", href=href, + style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f", + "textDecoration": "none", "cursor": "pointer"}), + style={"textAlign": "right"}) + + +def _insights_link_external(label, href): + """Same look as _insights_link, but a plain html.A opening in a new tab — for + standalone hand-maintained documents (like the Follower Study) that live outside + the Dash app entirely, as opposed to an internal SPA route.""" + return html.Div( + html.A(label, href=href, target="_blank", rel="noopener noreferrer", + style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f", + "textDecoration": "none", "cursor": "pointer"}), + style={"textAlign": "right"}) + + +def _reports_card_body_style(): + """Flex column with space-between so an _insights_link sits flush against the + CardBody's own bottom padding (real breathing room before the card's border) + instead of wherever the content above happens to end.""" + return {"display": "flex", "flexDirection": "column", "justifyContent": "space-between", "height": "100%"} + + def _narrative_section(title, items): if not items: return html.Div() @@ -2495,12 +2620,14 @@ def _reports_posts_table(posts_f): 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") + _post_title_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis", + "maxWidth": "260px"} 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(str(r["Short title"]), title=str(r["Short title"]), style=_post_title_style), html.Td(r["Created date"].strftime("%d/%m/%Y")), html.Td(r["Content Type"]), html.Td(f"{int(r['Impressions']):,}"), @@ -2508,7 +2635,116 @@ def _reports_posts_table(posts_f): html.Td(f"{r['CTR %']:.1f}%"), ]) for _, r in all_posts.iterrows() ]), - ], bordered=True, size="sm", style={"fontSize": "0.8rem"}) + ], bordered=True, size="sm", style={"fontSize": "0.75rem"}) + + +def _roadmap_insights_page(): + """Reads the Roadmap's own Month 1 checkpoint (page 5): traction by format, top + posts by comments+shares, and best posting day, computed live from July's posts + rather than hand-typed, so it can't drift from the dashboard's own numbers.""" + stats = report.compute_period_stats(_build_report_ctx(), "2026-07") + posts_f = stats["posts_f"].copy() + posts_f["Shares"] = posts_f["Comments"].fillna(0) + posts_f["Reposts"].fillna(0) + posts_f["Weekday"] = posts_f["Created date"].dt.day_name() + + by_format = (posts_f.groupby("Content Type") + .agg(Posts=("Content Type", "size"), **{"Avg Eng %": ("Eng %", "mean")}, + Shares=("Shares", "sum")) + .round(1).sort_values("Avg Eng %", ascending=False).reset_index()) + format_table = dbc.Table([ + html.Thead(html.Tr([html.Th(c) for c in ["Format", "Posts", "Avg Eng %", "Comments + Shares"]])), + html.Tbody([html.Tr([html.Td(r["Content Type"]), html.Td(int(r["Posts"])), + html.Td(f"{r['Avg Eng %']}%"), html.Td(int(r["Shares"]))]) + for _, r in by_format.iterrows()]), + ], bordered=True, size="sm") + + top_posts = posts_f.sort_values("Shares", ascending=False).head(3) + top_posts_table = dbc.Table([ + html.Thead(html.Tr([html.Th(c) for c in ["Post", "Date", "Format", "Comments", "Reposts"]])), + 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(int(r["Comments"])), html.Td(int(r["Reposts"]))]) + for _, r in top_posts.iterrows()]), + ], bordered=True, size="sm") + + by_day = (posts_f.groupby("Weekday").agg(Posts=("Weekday", "size"), **{"Avg Eng %": ("Eng %", "mean")}) + .round(1).sort_values("Avg Eng %", ascending=False).reset_index()) + day_table = dbc.Table([ + html.Thead(html.Tr([html.Th(c) for c in ["Day", "Posts", "Avg Eng %"]])), + html.Tbody([html.Tr([html.Td(r["Weekday"]), html.Td(int(r["Posts"])), html.Td(f"{r['Avg Eng %']}%")]) + for _, r in by_day.iterrows()]), + ], bordered=True, size="sm") + + return dbc.Container([ + dbc.Card([dbc.CardHeader("Traction by Format"), dbc.CardBody(format_table)], className="mb-3 shadow-sm"), + dbc.Card([dbc.CardHeader("Top Posts by Comments + Shares"), dbc.CardBody(top_posts_table)], + className="mb-3 shadow-sm"), + dbc.Card([dbc.CardHeader("Best Posting Day"), dbc.CardBody(day_table)], className="mb-3 shadow-sm"), + ], fluid=True, className="pb-3") + + +def _core_message_insights_page(): + """Checks July's actual new followers against the Core Message's foundation: how + many sit in Research/Science or Engineering/Technical roles (the audience the + content itself speaks to), computed live from the Follower Directory.""" + cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, "2026-07") + func_table = dbc.Table([ + html.Thead(html.Tr([html.Th("Function (July cohort)"), html.Th("Count")])), + html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["function"].most_common()]), + ], bordered=True, size="sm") + sector_table = dbc.Table([ + html.Thead(html.Tr([html.Th("Sector (July cohort)"), html.Th("Count")])), + html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["sector"].most_common()]), + ], bordered=True, size="sm") + seniority_table = dbc.Table([ + html.Thead(html.Tr([html.Th("Seniority (July cohort)"), html.Th("Count")])), + html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["seniority"].most_common()]), + ], bordered=True, size="sm") + country_table = dbc.Table([ + html.Thead(html.Tr([html.Th("Country (July cohort)"), html.Th("Count")])), + html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["country"]]), + ], bordered=True, size="sm") + return dbc.Container([ + dbc.Card([ + dbc.CardHeader("July's Followers, by Function and Sector"), + dbc.CardBody([ + html.P(f"{cohort['total']} new followers were identified and profiled in July.", + style={"fontSize": "0.85rem"}), + dbc.Row([dbc.Col(func_table, md=6), dbc.Col(sector_table, md=6)]), + ]), + ], className="mb-3 shadow-sm"), + dbc.Card([ + dbc.CardHeader("July's Followers, by Seniority and Country"), + dbc.CardBody(dbc.Row([dbc.Col(seniority_table, md=6), dbc.Col(country_table, md=6)])), + ], className="mb-3 shadow-sm"), + ], fluid=True, className="pb-3") + + +def _monthly_notes_insights_page(): + """Full detail behind the Monthly Notes card's one-line description — moved to its + own page so the Reports tab's Audience Composition / Monthly Notes row can stay as + compact side-by-side abstracts, same pattern as Core Message / Campaign Roadmap.""" + notes = monthly_report_notes.NOTES.get("2026-07") + if not notes: + return dbc.Alert("Notes for this month have not been written yet.", color="info") + return dbc.Container([ + dbc.Card([ + dbc.CardHeader("Monthly Notes — July 2026"), + 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"), + ], fluid=True, className="pb-3") + + +# Not in NAV_ITEMS/the sidebar — reached only via the "Click here for the insights" +# links on the Reports tab's Core Message / Campaign Roadmap cards, same trick as +# growth-indicator-btn (a non-sidebar Input into the shared _switch_tab callback). +TAB_CONTENT_MAP["roadmap-insights"] = _roadmap_insights_page() +TAB_CONTENT_MAP["core-message-insights"] = _core_message_insights_page() +TAB_CONTENT_MAP["monthly-notes-insights"] = _monthly_notes_insights_page() @app.callback( @@ -2611,38 +2847,85 @@ def _render_reports_tab(month_value): ), style={"flex": "1", "minWidth": "0"}), ], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"}) + _reports_abstract_body_style = {"fontSize": "0.8rem"} + + # Core Message sizes to its own content (flex: 0 0 auto) instead of splitting the + # stack 50/50 with Roadmap — it no longer needs to match Roadmap's height, only the + # STACK as a whole needs to reach the row height below. + # 2026-08-07 layout experiment: CSS Grid instead of nested Bootstrap rows/cols, since + # this shape (Core Message + Roadmap side by side on top, Follower Growth spanning + # both of their combined width below, All Posts spanning both rows on the right) + # needs real cell-spanning that dbc.Row/Col can't express without deep nesting. + # Grid's own default stretch behavior handles the equal-height alignment natively + # (no more manual flex/h-100 juggling) — Core Message and Roadmap auto-match each + # other's row height, and All Posts auto-stretches to match Roadmap-row + Follower + # Growth-row combined. Prior (pre-grid) layout measurements, for reference/revert: + # Core Message 304x188.7, Roadmap 304x227.1 (stack 427.8 total), Follower Growth + # 304x427.8, All Posts 632x427.8, all four sharing top=290.1/bottom=717.95. + core_message_card = dbc.Card([ + dbc.CardHeader("Core Message"), + dbc.CardBody([ + _rich_text(notes.get("core_message_abstract", []), style=_reports_abstract_body_style) + if notes else html.Div("Not written up yet.", className="text-muted"), + _insights_link("/core-message-insights"), + ], style=_reports_card_body_style()), + ], className="shadow-sm", style={"gridArea": "cm"}) + + roadmap_card = dbc.Card([ + dbc.CardHeader("Campaign Roadmap"), + dbc.CardBody([ + _rich_text(notes.get("roadmap_abstract", []), style=_reports_abstract_body_style) + if notes else html.Div("Not written up yet.", className="text-muted"), + _insights_link("/roadmap-insights"), + ], style=_reports_card_body_style()), + ], className="shadow-sm", style={"gridArea": "rm"}) + + if fts_f.empty: + chart_content = html.Div("No follower data for this period.", className="text-muted") + else: + _reports_growth_fig = daily_followers_figure(fts_f, posts_f) + # No legend, no autorange padding above the tallest bar: this compact card is an + # at-a-glance summary (full chart with its legend lives on the Followers tab and + # via the "insights" link), so every pixel of its fixed height goes to bar height + # instead of chrome around it. + _reports_growth_fig.update_layout(showlegend=False) + # A star sitting exactly on the y=0 baseline (a post published on a day with no + # net follower change) gets its bottom half clipped by the plot area's own edge + # under rangemode="tozero" — small negative headroom keeps zero off that edge. + _reports_ymax = (fts_f["Organic followers"] + fts_f["Auto-invited followers"]).max() + _reports_growth_fig.update_yaxes(range=[-max(_reports_ymax * 0.12, 0.4), _reports_ymax * 1.15]) + # Date tick labels dropped: the exact day is already on hover for every bar/star, + # and freeing the vertical space they took lets the card shrink without touching + # bar height itself. + _reports_growth_fig.update_xaxes(showticklabels=False) + # Total height reduced by roughly what the tick labels used to take, so the plot + # area (and thus bar height) stays the same as before instead of auto-expanding + # into the freed space — the box shrinks, the bars don't grow past what was approved. + chart_content = G(_reports_growth_fig, style={"height": "165px"}, margin=dict(t=10, b=2, l=10, r=10)) + 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="shadow-sm h-100") + # Tighter padding than the other cards (8px vs the dbc default 16px each side): + # the chart's own 190px height is fixed and not to be touched, so shaving the + # padding around it is the only real lever left to shrink this card's total + # height without shrinking the bars themselves. + dbc.CardBody([ + chart_content, + _insights_link("/roadmap-insights"), + ], style={**_reports_card_body_style(), "padding": "8px"}), + ], className="shadow-sm", style={"gridArea": "fg"}) posts_card = dbc.Card([ dbc.CardHeader(f"All Posts, {stats['period_label']}"), - dbc.CardBody(_reports_posts_table(posts_f)), - ], className="shadow-sm h-100") + dbc.CardBody([ + _reports_posts_table(posts_f), + _insights_link("/roadmap-insights"), + ], style=_reports_card_body_style()), + ], className="shadow-sm", style={"gridArea": "ap"}) 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"}), - ] + composition_body = [_composition_table(cohort["sector"], "Sector (this month)")] else: composition_body = [html.P( f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}, " @@ -2651,29 +2934,54 @@ def _render_reports_tab(month_value): composition_info = info_popover( "reports-composition", "The Follower Directory is updated by hand each time a new follower comes in, " - "so it can run ahead of the dashboard's weekly LinkedIn export.", + "so it can run ahead of the dashboard's weekly LinkedIn export. Seniority and " + "country breakdowns are on the insights page below.", ) composition_card = dbc.Card([ dbc.CardHeader(["Audience Composition (Follower Directory Coverage)", composition_info]), - dbc.CardBody(composition_body), - ], className="mb-3 shadow-sm") + dbc.CardBody(composition_body + [_insights_link("/core-message-insights")], + style=_reports_card_body_style()), + ], className="shadow-sm", style={"gridArea": "ac"}) - 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") + _notes_description = ( + "Contains Features/Fixes Shipped, Data Quality Issues Found and Fixed, " + "Follower Study/Directory Updates, and any Outstanding areas that need " + "further attention." + ) + notes_card = dbc.Card([ + dbc.CardHeader("Monthly Notes"), + dbc.CardBody([ + html.P(_notes_description, style={"fontSize": "0.8rem"}) if notes + else html.Div("Not written up yet.", className="text-muted"), + _insights_link("/monthly-notes-insights"), + ], style=_reports_card_body_style()), + ], className="shadow-sm", style={"gridArea": "mn"}) - charts_row = dbc.Row([dbc.Col(chart_card, md=3), dbc.Col(posts_card, md=9)], - style={"marginBottom": "1rem"}) - return title_heading, html.Div([kpi_row, charts_row, composition_card, narrative_card]) + _study_description = ( + "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." + ) + study_card = dbc.Card([ + dbc.CardHeader("Follower Study"), + dbc.CardBody([ + html.P(_study_description, style={"fontSize": "0.8rem"}), + _insights_link_external("Click here for the study", "/assets/follower_study.html"), + ], style=_reports_card_body_style()), + ], className="shadow-sm", style={"gridArea": "fs"}) + + charts_row = html.Div( + [core_message_card, roadmap_card, chart_card, posts_card], + style={"display": "grid", "gridTemplateColumns": "1fr 1fr 2fr", + "gridTemplateAreas": '"cm rm ap" "fg fg ap"', + "gap": "1rem", "marginBottom": "1rem"}, + ) + composition_row = html.Div( + [composition_card, notes_card, study_card], + style={"display": "grid", "gridTemplateColumns": "1fr 1fr 1fr", "gridTemplateAreas": '"ac fs mn"', + "gap": "1rem", "marginBottom": "1rem"}, + ) + return title_heading, html.Div([kpi_row, charts_row, composition_row]) @app.callback( diff --git a/monthly_report_notes.py b/monthly_report_notes.py index 95c96fe..e223211 100644 --- a/monthly_report_notes.py +++ b/monthly_report_notes.py @@ -10,6 +10,17 @@ code changes are needed for the Reports tab or PDF to pick them up. NOTES = { "2026-07": { + "core_message_abstract": [ + ("\"We reveal the processes inside your materials,\"", True), + ("", "br"), + (" This defines TXproducts' central value proposition. It's the foundation " + "of all outreach and content.", False), + ], + "roadmap_abstract": [ + ("Find the Voice.", True), + (" Establish a consistent brand voice and discover the tone that resonates. " + "Identify which posts and formats drove the most engagement.", False), + ], "features_shipped": [ "Initial dashboard build (Jul 21): Overview, Content, Followers, Visitors, " "Competitors, and Growth tabs, weekly-pace tracking, PDF report generation, "