Add Core Message/Roadmap/Monthly Notes cards and cross-page nav to Reports tab
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.
This commit is contained in:
1 parent
aa30aa992c
commit
cbb308cf37
3 files changed
+380
-57
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
if active == "followers":
|
||||
return [
|
||||
html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank",
|
||||
rel="noopener noreferrer", style=link_style),
|
||||
rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE),
|
||||
html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
|
||||
rel="noopener noreferrer", style={**link_style, "marginLeft": "8px"}),
|
||||
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([
|
||||
_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([
|
||||
_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")
|
||||
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(
|
||||
|
||||
@@ -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, "
|
||||
|
||||
Reference in new issue
Block a user