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.
This commit is contained in:
beantownbabe committed 2026-07-21 15:14:28 +02:00
commit 348fe65b88
10 files changed
+3064

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# Real LinkedIn export data — not shared code, uploaded fresh each week via the dashboard
*.xls
*.xlsx
growth_history.json
# Generated output — regenerate locally with build_static_export.py, not shared code
TXproducts_Dashboard_Snapshot.html
# Python
__pycache__/
*.pyc
# OS
.DS_Store
+1898
View File
File diff suppressed because it is too large. Load diff
+113
View File
@@ -0,0 +1,113 @@
/* TXproducts design language, matched to txdata_project/portal.py */
body {
background: #f7f9fc;
font-family: -apple-system, "Source Sans Pro", "Segoe UI", Helvetica, Arial, sans-serif;
}
/* Section cards */
.card {
border: 1px solid #e0e7ef !important;
border-radius: 8px !important;
box-shadow: 0 1px 3px rgba(27, 58, 92, 0.06) !important;
}
.card-header {
background: linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%) !important;
color: white !important;
border-radius: 7px 7px 0 0 !important;
border: none !important;
font-size: 0.85rem;
letter-spacing: 0.2px;
padding: 0.55rem 1rem !important;
}
.card-body {
background: #ffffff;
border-radius: 0 0 7px 7px;
}
hr {
border-color: #e0e7ef !important;
}
/* Tabs */
.nav-tabs {
border-bottom: 1px solid #e0e7ef !important;
}
.nav-tabs .nav-link {
color: #6b7a8d !important;
font-weight: 500;
border: none !important;
border-bottom: 2px solid transparent !important;
}
.nav-tabs .nav-link.active {
color: #1b3a5c !important;
font-weight: 700;
border-bottom: 2px solid #2d6a9f !important;
background: transparent !important;
}
/* DataTable polish */
.dash-table-container {
border-radius: 8px;
overflow: hidden;
}
/* Compact year picker on the follower calendar */
.compact-dropdown .Select-control {
height: 28px !important;
min-height: 28px !important;
}
.compact-dropdown .Select-value, .compact-dropdown .Select-placeholder, .compact-dropdown .Select-input {
line-height: 26px !important;
padding: 0 8px !important;
}
.compact-dropdown .Select-input {
height: 26px !important;
}
.compact-dropdown .Select-arrow-zone {
height: 26px !important;
}
/* Growth tab history log accordion — match the dashboard's font instead of Bootstrap/FLATLY's default */
.accordion-button {
font-family: inherit;
font-size: 0.82rem;
color: #1a2533;
}
.accordion-button:not(.collapsed) {
color: #1b3a5c;
background-color: #e8f0fe;
box-shadow: none;
}
.accordion-button:focus {
box-shadow: none;
border-color: #e0e7ef;
}
/* Sidebar vertical nav */
.sidebar-nav-btn {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
border-left: 3px solid transparent;
color: #6b7a8d;
font-weight: 500;
font-size: 0.92rem;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
}
.sidebar-nav-btn:hover {
background: #f7f9fc;
color: #1b3a5c;
}
.sidebar-nav-btn.active {
background: #e8f0fe;
color: #1b3a5c;
font-weight: 700;
border-left: 3px solid #2d6a9f;
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg745"
version="1.1"
width="1024"
height="267"
viewBox="-0.63 -0.63 85.26 22.26">
<defs
id="defs742" />
<path
id="path661"
d="M 64.58203,0 C 63.72603,0 63,0.67772 63,1.51172 V 19.48828 C 63,20.32328 63.47603,21 64.33203,21 H 82.22852 C 83.08552,21 84,20.32328 84,19.48828 V 1.51172 C 84,0.67772 83.33552,0 82.47852,0 Z M 67.5,2.625 c 1.036,0 1.875,0.839 1.875,1.875 0,1.036 -0.839,1.875 -1.875,1.875 -1.036,0 -1.875,-0.839 -1.875,-1.875 0,-1.036 0.839,-1.875 1.875,-1.875 z M 12.50195,2.6992 c -0.993,0 -1.80273,0.80678 -1.80273,1.80078 0,0.994 0.80973,1.80078 1.80273,1.80078 0.994,0 1.79883,-0.80678 1.79883,-1.80078 0,-0.994 -0.80483,-1.80078 -1.79883,-1.80078 z M 0,3 V 18 H 9 V 15 H 3 V 3 Z m 28,0 v 15 h 3 v -5 l 3.69922,5 h 3.54101 L 34,12.5332 38,8 H 34.5 L 31,12 V 3 Z M 57,3 V 9.19141 H 56.9707 C 56.3597,8.45341 55.34961,7.875 53.72461,7.875 c -2.625,0 -4.74609,2.05672 -4.74609,5.13672 0,3.233 2.15234,5.11328 4.65234,5.11328 1.834,0 2.91606,-0.59441 3.53906,-1.31641 h 0.0293 V 18 H 60 V 3 Z M 22.13672,7.875 c -1.574,0 -2.8483,0.78941 -3.2793,1.56641 h -0.0312 V 8 H 16 v 10 h 3 V 13.19727 C 19,11.69427 19.71856,10.5 21.22656,10.5 22.46856,10.5 23,11.52409 23,12.99609 V 18 h 3 V 12.44922 C 26,9.79122 25.15772,7.875 22.13672,7.875 Z m 21.0918,0 C 39.81152,7.875 38,9.87434 38,12.77734 c 0,3.268 1.99962,5.34766 5.14062,5.34766 2.354,0 3.81154,-0.81933 4.51954,-1.73633 L 45.79297,14.875 c -0.404,0.549 -1.29952,1.25 -2.60352,1.25 -1.494,0 -2.14479,-1.05402 -2.34179,-1.91602 l -0.0156,-0.30859 h 7.11328 c 0,0 0.0547,-0.33673 0.0547,-0.67773 0,-3.409 -1.82048,-5.34766 -4.77148,-5.34766 z m 33.9082,0 C 80.15772,7.875 81,9.47822 81,12.44922 V 18 H 78 V 12.99609 C 78,11.66609 77.46856,10.5 76.22656,10.5 74.71856,10.5 74,11.52127 74,13.19727 V 18 H 71 V 8 h 2.82617 v 1.44141 h 0.0312 c 0.431,-0.777 1.7053,-1.56641 3.2793,-1.56641 z M 11,8 v 10 h 3 V 8 Z m 55,0 h 3 V 18 H 66 Z M 43.13867,9.875 c 1.22,0 1.99461,1.07 1.97461,2.125 h -4.28125 c 0.081,-0.991 0.85464,-2.125 2.30664,-2.125 z m 11.33985,0.25 c 1.717,0 2.7207,1.14361 2.7207,2.84961 0,1.66 -1.0037,2.90039 -2.7207,2.90039 -1.715,0 -2.67774,-1.27339 -2.67774,-2.90039 0,-1.626 0.96274,-2.84961 2.67774,-2.84961 z"
clip-path="none"
style="fill:#0a66c2;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1" />
</svg>
<!-- version: 20171223, original size: 84 21, border: 3% -->

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+569
View File
@@ -0,0 +1,569 @@
"""Builds a single self-contained HTML file snapshot of the live Dash dashboard, for
sharing with people who don't have the app running (e.g. via NextCloud/email).
Reuses app.py's data loading and figure-builder functions directly. Converts the Dash
component trees (dbc.Card/Row/Col are just Bootstrap-class wrappers) into plain HTML,
so the same Bootstrap CSS renders them identically. Charts render via Plotly.js from
embedded JSON. A handful of interactive elements (calendar month click, breakdown
dropdowns, content month filter) have every possible state precomputed and swapped
client-side with vanilla JS — everything else is a static snapshot of today's data.
"""
import base64
import html as html_escape_mod
import json
import mimetypes
import os
import re
import app as A
from dash import html, dcc, dash_table
import dash_bootstrap_components as dbc
CHARTS = {} # chart div id -> plotly figure json string
CHART_COUNTER = [0]
ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
_ASSET_CACHE = {}
def esc(s):
return html_escape_mod.escape(str(s), quote=True)
def inline_asset(src):
"""Rewrite /assets/foo.png into a base64 data: URI so the exported file is fully
self-contained (no separate image files needed alongside it)."""
if not src or not src.startswith("/assets/"):
return src
if src in _ASSET_CACHE:
return _ASSET_CACHE[src]
path = os.path.join(ASSETS_DIR, os.path.basename(src))
if not os.path.isfile(path):
return src
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
uri = f"data:{mime};base64,{b64}"
_ASSET_CACHE[src] = uri
return uri
def style_dict_to_css(style):
if not style:
return ""
parts = []
for k, v in style.items():
# camelCase -> kebab-case
css_key = re.sub(r"([A-Z])", r"-\1", k).lower()
parts.append(f"{css_key}:{v}")
return ";".join(parts)
def conditional_row_bg(conditional, row, idx):
"""Replays a DataTable's style_data_conditional row_index/filter_query rules for one
row — later rules override earlier ones on a match, same as Dash's own precedence — so
the best-engagement/best-CTR highlight still shows up here, not just in the live app.
Only supports the simple "{Col} = value" equality form filter_query uses in this
codebase, not the full Dash query mini-language. Caller applies the result per-<td>,
not on the <tr> — Bootstrap 5 paints table backgrounds via a CSS variable applied
directly to each cell, which sits on top of and hides a tr-level background-color."""
bg = None
for rule in conditional:
cond = rule.get("if", {})
matched = False
if "row_index" in cond:
ri = cond["row_index"]
if ri == "odd" and idx % 2 == 1:
matched = True
elif ri == "even" and idx % 2 == 0:
matched = True
elif isinstance(ri, int) and idx == ri:
matched = True
elif "filter_query" in cond:
m = re.match(r"\{([^}]+)\}\s*=\s*(.+)", cond["filter_query"])
if m:
col, val = m.group(1), m.group(2)
cell = row.get(col)
try:
matched = float(cell) == float(val)
except (TypeError, ValueError):
matched = str(cell) == val
if matched and "backgroundColor" in rule:
bg = rule["backgroundColor"]
return bg
def next_chart_id():
CHART_COUNTER[0] += 1
return f"chart-{CHART_COUNTER[0]}"
def register_chart(fig, style=None, comp_id=None):
cid = comp_id or next_chart_id()
CHARTS[cid] = json.loads(fig.to_json())
height = "320px"
if style and "height" in style:
height = style["height"]
return f'<div id="{cid}" class="js-plotly-chart" style="height:{height};width:100%;"></div>'
def render(comp):
"""Recursively convert a Dash component (or list/str) into an HTML string."""
if comp is None:
return ""
if isinstance(comp, (str, int, float)):
return esc(comp)
if isinstance(comp, (list, tuple)):
return "".join(render(c) for c in comp)
cls = type(comp).__name__
module = type(comp).__module__
# --- Plotly graphs ---
if isinstance(comp, dcc.Graph):
return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None))
# --- Dropdowns: render as <select>, wiring handled by caller via id convention ---
if isinstance(comp, dcc.Dropdown):
opts = "".join(
f'<option value="{esc(o["value"])}"{" selected" if o["value"] == comp.value else ""}>{esc(o["label"])}</option>'
for o in comp.options
)
style = style_dict_to_css(getattr(comp, "style", None))
extra_class = getattr(comp, "className", "") or ""
cid = getattr(comp, "id", "")
if cid == "report-timeframe-select":
return (f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" '
f'style="{style};opacity:0.5;cursor:not-allowed" disabled '
f'title="PDF generation needs the live tool — not available in this snapshot.">{opts}</select>')
return f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" style="{style}" onchange="window.__onSelect && window.__onSelect(this)">{opts}</select>'
# --- Stores/Download: no static equivalent needed (Download requires a live backend) ---
if isinstance(comp, (dcc.Store, dcc.Download)):
return ""
# --- DataTable: render a plain HTML table from data/columns ---
if isinstance(comp, dash_table.DataTable):
cols = comp.columns
rows = comp.data
conditional = getattr(comp, "style_data_conditional", None) or []
thead = "".join(f"<th>{esc(c['name'])}</th>" for c in cols)
trs = []
for i, r in enumerate(rows):
bg = conditional_row_bg(conditional, r, i)
td_style = f' style="background-color:{bg}"' if bg else ""
tds = "".join(f"<td{td_style}>{esc(r.get(c['id'], ''))}</td>" for c in cols)
trs.append(f"<tr>{tds}</tr>")
cid = getattr(comp, "id", "") or next_chart_id()
return (f'<div class="table-responsive"><table id="{esc(cid)}" class="table table-sm static-datatable">'
f"<thead><tr>{thead}</tr></thead><tbody>{''.join(trs)}</tbody></table></div>")
# --- dbc components: map to their underlying Bootstrap markup ---
if module.startswith("dash_bootstrap_components"):
children_html = render(getattr(comp, "children", None))
extra_class = getattr(comp, "className", "") or ""
style = style_dict_to_css(getattr(comp, "style", None))
cid = getattr(comp, "id", "")
id_attr = f' id="{esc(cid)}"' if cid else ""
if cls == "Container":
base = "container-fluid" if getattr(comp, "fluid", False) else "container"
return f'<div class="{base} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Row":
return f'<div class="row {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Col":
md = getattr(comp, "md", None)
colcls = f"col-md-{md}" if md else "col"
return f'<div class="{colcls} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Card":
return f'<div class="card {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "CardHeader":
return f'<div class="card-header" style="{style}">{children_html}</div>'
if cls == "CardBody":
return f'<div class="card-body" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Alert":
color = getattr(comp, "color", "info")
return f'<div class="alert alert-{color} {esc(extra_class)}" style="{style}">{children_html}</div>'
if cls == "Popover":
target = getattr(comp, "target", "")
body_html = render(getattr(comp, "children", None))
return f'<div class="static-popover" data-target="{esc(target)}" style="display:none;">{body_html}</div>'
if cls == "PopoverBody":
style = style_dict_to_css(getattr(comp, "style", None))
return f'<div class="popover-body" style="{style}">{children_html}</div>'
if cls == "Accordion":
return f'<div class="static-accordion {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "AccordionItem":
title = getattr(comp, "title", "") or ""
return (f'<details class="static-accordion-item">'
f'<summary class="static-accordion-title">{esc(title)}</summary>'
f'<div class="static-accordion-body">{children_html}</div>'
f'</details>')
# Fallback: just render children
return children_html
# --- Plain html.* components ---
if module.startswith("dash.html") or module.startswith("dash_html_components"):
children_html = render(getattr(comp, "children", None))
extra_class = getattr(comp, "className", "") or ""
style = style_dict_to_css(getattr(comp, "style", None))
cid = getattr(comp, "id", "")
id_attr = f' id="{esc(cid)}"' if cid else ""
title = getattr(comp, "title", None)
title_attr = f' title="{esc(title)}"' if title else ""
class_attr = f' class="{esc(extra_class)}"' if extra_class else ""
tag_map = {
"Div": "div", "Span": "span", "Strong": "strong", "B": "b", "P": "p",
"H3": "h3", "Hr": "hr", "Button": "button",
"Table": "table", "Thead": "thead", "Tbody": "tbody", "Tr": "tr",
"Th": "th", "Td": "td",
}
tag = tag_map.get(cls, "div")
if cls == "Img":
src = inline_asset(getattr(comp, "src", ""))
return f'<img src="{esc(src)}" style="{style}"{class_attr}{id_attr}/>'
if cls == "Hr":
return f'<hr style="{style}"{class_attr}/>'
if cls == "Button":
if cid == "generate-report-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'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled '
f'title="PDF generation needs the live tool (requires a Python backend) — not available in this snapshot.">'
f'{children_html}</button>')
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f'<button type="button"{id_attr}{class_attr} style="{style}"{onclick}{title_attr}>{children_html}</button>'
if cid.startswith("info-"):
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}{onclick}>{children_html}</{tag}>'
return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}>{children_html}</{tag}>'
# Unknown component type — best-effort render children
return render(getattr(comp, "children", None))
def fig_json(fig):
return json.loads(fig.to_json())
def precompute_calendar_states():
"""Every month's tile-row + daily graph (+ Overview's 4 metric charts), for both
calendar instances — this is what the month-box click swaps between."""
states = {}
year = int(A.D["fts"]["Date"].dt.year.max())
for prefix in ("overview", "followers"):
states[prefix] = {}
for m in range(1, 13):
entry = {
"postsRow": render(A._month_day_tiles(A.D["fts"], A.D["posts"], year, m)),
"dailyFig": fig_json(A.daily_followers_figure(A.D["fts"], A.D["posts"], year, m)),
}
if prefix == "overview":
cts_f = A._filtered_by_month(A.D["cts"], year, m)
vts_f = A._filtered_by_month(A.D["vts"], year, m)
entry["metrics"] = {
"overview-impressions-graph": fig_json(A.impressions_figure(cts_f)),
"overview-engagement-graph": fig_json(A.engagement_figure(cts_f)),
"overview-ctr-graph": fig_json(A.ctr_figure(cts_f)),
"overview-pageviews-graph": fig_json(A.page_views_figure(vts_f)),
}
states[prefix][str(m)] = entry
default_month = A._default_active_month(A.D["fts"], A.D["posts"], year)
return states, year, default_month
def precompute_fb_states():
"""Followers-By dropdown x Show-all-toggle states."""
states = {}
for key, _label in A.FOLLOWER_BREAKDOWNS:
for expanded in (False, True):
_lbl, fig, total_n = A._breakdown_figure(key, expanded)
fig = A._std(fig)
needs_toggle = total_n > 10
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
states[f"{key}|{str(expanded).lower()}"] = {
"fig": fig_json(fig),
"btnLabel": btn_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
}
return states
def precompute_vb_states():
"""Visitors-By dropdown states."""
states = {}
for key, _label in A.VISITOR_BREAKDOWNS:
_lbl, fig = A._visitor_breakdown_figure(key)
states[key] = {"fig": fig_json(A._std(fig))}
return states
def precompute_content_states():
"""Content tab's month-filter dropdown states — 6 charts + the posts table."""
states = {}
for opt in A._content_month_options():
value = opt["value"]
posts_f = A._content_posts_filtered(value)
cts_f = A._content_cts_filtered(value)
records = A._posts_table_records(value)
# Same fixed rule set for every month, matching the live app: its month-filter
# callback only swaps `data`, never `style_data_conditional`, so the highlight
# always marks the all-time best post/CTR, not a per-month recompute.
row_htmls = []
for i, r in enumerate(records):
bg = conditional_row_bg(A.post_highlight_styles, r, i)
td_style = f' style="background-color:{bg}"' if bg else ""
tds = "".join(f"<td{td_style}>{esc(r.get(c, ''))}</td>" for c in A.post_table_cols)
row_htmls.append(f"<tr>{tds}</tr>")
rows_html = "".join(row_htmls)
states[value] = {
"content-impressions-fig": fig_json(A._finalize_fig(
A.impressions_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)),
"content-engagement-fig": fig_json(A._finalize_fig(
A.engagement_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)),
"content-ctr-fig": fig_json(A._finalize_fig(
A.ctr_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)),
"content-scatter-fig": fig_json(A._finalize_fig(
A.impressions_vs_engagement_figure(posts_f), legend=A.CONTENT_SCATTER_LEGEND)),
"content-reactions-fig": fig_json(A._finalize_fig(A.reactions_figure(cts_f))),
"content-clicks-fig": fig_json(A._finalize_fig(A.clicks_figure(cts_f))),
"tableRows": rows_html,
}
return states
JS_TEMPLATE = r"""
const CHARTS = %(charts)s;
const TAB_HERO = %(tab_hero)s;
const CAL_STATES = %(cal_states)s;
const CAL_YEAR = %(cal_year)s;
const CAL_DEFAULT_MONTH = %(cal_default_month)s;
const FB_STATES = %(fb_states)s;
const VB_STATES = %(vb_states)s;
const CONTENT_STATES = %(content_states)s;
const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH};
let fbActive = 'company_size', fbExpanded = false;
let vbActive = 'seniority';
function renderChart(divId, figSpec) {
if (!figSpec || !document.getElementById(divId)) return;
Plotly.react(divId, figSpec.data, figSpec.layout, {displayModeBar: false, responsive: true});
}
function renderChartWithin(containerId, figSpec) {
var container = document.getElementById(containerId);
if (!container || !figSpec) return;
var chartDiv = container.querySelector('.js-plotly-chart');
if (!chartDiv) return;
Plotly.react(chartDiv.id, figSpec.data, figSpec.layout, {displayModeBar: false, responsive: true});
}
document.addEventListener('DOMContentLoaded', function () {
Object.keys(CHARTS).forEach(function (id) {
var spec = CHARTS[id];
Plotly.newPlot(id, spec.data, spec.layout, {displayModeBar: false, responsive: true});
});
});
function switchTab(key) {
document.querySelectorAll('.app-tab').forEach(function (el) { el.style.display = 'none'; });
var el = document.getElementById('tab-' + key);
if (el) el.style.display = 'block';
document.querySelectorAll('[id^="nav-"]').forEach(function (btn) {
btn.classList.toggle('active', btn.id === 'nav-' + key);
});
var hero = TAB_HERO[key];
if (hero) {
document.getElementById('hero-title').textContent = hero[0];
document.getElementById('hero-subtitle').textContent = hero[1];
}
window.dispatchEvent(new Event('resize'));
}
function selectCalendarMonth(prefix, month) {
calMonth[prefix] = month;
var state = CAL_STATES[prefix][String(month)];
if (!state) return;
for (var m = 1; m <= 12; m++) {
var btn = document.getElementById(prefix + '-month-btn-' + m);
if (btn) btn.style.border = (m === month) ? '2px solid #1b3a5c' : '1px solid #e8e8e8';
}
var row = document.getElementById(prefix + '-posts-row');
if (row) row.innerHTML = state.postsRow;
renderChart(prefix + '-daily-followers-graph', state.dailyFig);
if (state.metrics) {
Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); });
}
}
function renderFollowersBreakdown() {
var s = FB_STATES[fbActive + '|' + fbExpanded];
if (!s) return;
renderChartWithin('followers-breakdown-area', s.fig);
var btn = document.getElementById('fb-expand-btn');
if (btn) {
btn.textContent = s.btnLabel;
btn.style.display = s.btnDisplay;
}
}
function renderVisitorBreakdown() {
var s = VB_STATES[vbActive];
if (!s) return;
renderChartWithin('visitor-breakdown-area', s.fig);
}
function renderContentState(value) {
var s = CONTENT_STATES[value];
if (!s) return;
['content-impressions-fig', 'content-engagement-fig', 'content-ctr-fig',
'content-scatter-fig', 'content-reactions-fig', 'content-clicks-fig'].forEach(function (id) {
renderChart(id, s[id]);
});
var tbody = document.querySelector('#posts-table tbody');
if (tbody) tbody.innerHTML = s.tableRows;
}
function closeAllPopovers(except) {
document.querySelectorAll('.static-popover').forEach(function (p) {
if (p !== except) p.style.display = 'none';
});
}
window.__onClick = function (el) {
var id = el.id || '';
var m;
if (id.indexOf('info-') === 0) {
var pop = document.querySelector('.static-popover[data-target="' + id + '"]');
if (!pop) return;
var willOpen = pop.style.display === 'none' || !pop.style.display;
closeAllPopovers();
if (willOpen) {
var rect = el.getBoundingClientRect();
pop.style.position = 'fixed';
pop.style.top = (rect.bottom + 6) + 'px';
pop.style.left = Math.min(rect.left, window.innerWidth - 380) + 'px';
pop.style.display = 'block';
}
return;
}
if ((m = id.match(/^(overview|followers)-month-btn-(\d+)$/))) {
selectCalendarMonth(m[1], parseInt(m[2], 10));
return;
}
if (id === 'fb-expand-btn') {
fbExpanded = !fbExpanded;
renderFollowersBreakdown();
return;
}
};
window.__onSelect = function (el) {
if (el.id === 'fb-select') { fbActive = el.value; fbExpanded = false; renderFollowersBreakdown(); return; }
if (el.id === 'vb-select') { vbActive = el.value; renderVisitorBreakdown(); return; }
if (el.id === 'content-month-select') { renderContentState(el.value); return; }
};
document.addEventListener('click', function (ev) {
if (ev.target.id && ev.target.id.indexOf('info-') === 0) return;
if (ev.target.closest && ev.target.closest('.static-popover')) return;
closeAllPopovers();
});
"""
def build():
tabs_html = {}
for key, _label, comp in A.NAV_ITEMS:
tabs_html[key] = render(comp)
sidebar_html = render(A.sidebar)
hero_html = render(A.hero)
cal_states, cal_year, cal_default_month = precompute_calendar_states()
fb_states = precompute_fb_states()
vb_states = precompute_vb_states()
content_states = precompute_content_states()
with open(os.path.join(ASSETS_DIR, "custom.css")) as f:
custom_css = f.read()
tab_divs = "\n".join(
f'<div id="tab-{key}" class="app-tab" style="display:{"block" if key == "overview" else "none"};">'
f'{tabs_html[key]}</div>'
for key, _label, _c in A.NAV_ITEMS
)
js = JS_TEMPLATE % {
"charts": json.dumps(CHARTS),
"tab_hero": json.dumps(A.TAB_HERO),
"cal_states": json.dumps(cal_states),
"cal_year": json.dumps(cal_year),
"cal_default_month": json.dumps(cal_default_month),
"fb_states": json.dumps(fb_states),
"vb_states": json.dumps(vb_states),
"content_states": json.dumps(content_states),
}
html_doc = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>TXproducts LinkedIn Analytics — Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<style>
{custom_css}
body {{ background:#f7f9fc; }}
.app-shell {{ display:flex; align-items:stretch; }}
.static-popover {{ position:fixed; z-index:2000; background:white; border:1px solid #ccc; border-radius:6px;
box-shadow:0 4px 16px rgba(0,0,0,0.15); max-width:360px; padding:4px; }}
.static-datatable th, .static-datatable td {{ font-size:11px; padding:3px 6px; }}
select.compact-dropdown {{ height:28px; font-size:0.85rem; }}
.snapshot-banner {{ background:#fff3cd; color:#664d03; padding:8px 16px; font-size:0.8rem; text-align:center; }}
.static-accordion {{ border:1px solid #e0e7ef; border-radius:6px; overflow:hidden; }}
.static-accordion-item {{ border-bottom:1px solid #e0e7ef; }}
.static-accordion-item:last-child {{ border-bottom:none; }}
.static-accordion-title {{ cursor:pointer; padding:10px 12px; font-size:0.85rem; font-weight:600;
color:#1a2533; list-style:none; user-select:none; }}
.static-accordion-title::-webkit-details-marker {{ display:none; }}
.static-accordion-title:before {{ content:'\\25B8 '; color:#6b7a8d; }}
details[open] > .static-accordion-title:before {{ content:'\\25BE '; }}
.static-accordion-body {{ padding:4px 12px 14px; }}
.static-accordion .static-accordion {{ border:none; border-radius:0; margin:0 0 0 8px; }}
</style>
</head>
<body>
<div class="snapshot-banner">Static snapshot as of {A.D['last_updated'].strftime('%B %d, %Y')} — dropdowns, month clicks,
and breakdown selectors work, but this file does not update with new data. Open the live tool for current numbers.</div>
<div class="app-shell">
{sidebar_html}
<div style="flex:1;min-width:0;padding:20px 24px;">
{hero_html}
<div id="tab-content-area">
{tab_divs}
</div>
</div>
</div>
<script>
document.querySelectorAll('[id^="nav-"]').forEach(function (btn) {{
btn.addEventListener('click', function () {{ switchTab(btn.id.replace('nav-', '')); }});
}});
{js}
</script>
</body>
</html>
"""
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "TXproducts_Dashboard_Snapshot.html")
with open(out_path, "w") as f:
f.write(html_doc)
print(f"Wrote {out_path} ({len(html_doc):,} bytes, {len(CHARTS)} charts)")
return out_path
if __name__ == "__main__":
build()
+193
View File
@@ -0,0 +1,193 @@
"""Tracks follower-growth history over time: an append-only-per-day log that powers the
sidebar Growth Statistics indicator, the Growth tab's historical table/chart, and the PDF
report's weekly-pace section. Persisted as JSON in UPLOAD_DIR so it survives restarts and
uploads on any team member's machine, not just the original author's.
Pace is judged against a rolling personal best rather than a fixed percentage: the current
week's growth-to-date is compared against the best of the last ROLLING_BEST_WEEKS COMPLETED
weeks (never against earlier days within the current week itself). Weeks are real Monday-
Sunday calendar weeks, not a daily trailing window and not a 7-day block anchored to
whichever day-of-week the data happens to start on — an earlier version compared each day's
trailing-7-day rate against the best trailing rate from the past N weeks, which could
include days from later in the SAME displayed week, so a week could get flagged "Behind" due
to its own mid-week peak rather than genuinely falling short of prior weeks. This rises
automatically as performance improves, but a one-off spike ages out of the window after
ROLLING_BEST_WEEKS instead of setting an unreachable target forever.
Zero import-time dependency on app.py, for the same reason report.py has none: app.py runs
as "__main__" (`python3 app.py`), so `import app` here would re-execute it as a second
module copy that's still mid-import when these functions get called.
"""
import datetime
import json
import os
import pandas as pd
ROLLING_BEST_WEEKS = 4
def _history_path(upload_dir):
return os.path.join(upload_dir, "growth_history.json")
def load_history(upload_dir):
path = _history_path(upload_dir)
if not os.path.exists(path):
return []
with open(path) as f:
return json.load(f)
def _save_history(upload_dir, history):
with open(_history_path(upload_dir), "w") as f:
json.dump(history, f, indent=2)
def _followers_as_of(fts_df, on_or_before):
"""Cumulative Followers value from the most recent row on or before `on_or_before`;
falls back to the earliest available row if the whole series starts later."""
prior = fts_df[fts_df["Date"] <= on_or_before]
if prior.empty:
return float(fts_df["Cumulative Followers"].iloc[0])
return float(prior["Cumulative Followers"].iloc[-1])
def _week_start_for(date):
"""Monday of the standard calendar week `date` falls into — real Monday-Sunday weeks,
not a 7-day block anchored to whatever day-of-week the data happens to start on (which
produced Tuesday-Monday "weeks" here, since the tracked history starts on a Tuesday)."""
return date - pd.Timedelta(days=date.weekday())
def compute_snapshot(fts_df, prior_history, week_boundary_date=None):
"""Computes (but does not persist) a growth snapshot as of the last date in fts_df,
given the history entries recorded so far (used for since_last_pct and the rolling-best
pace target). Taking the prior history as a plain list — rather than reading it from
disk — lets backfill_history() replay this against progressively larger slices of
fts_df without hitting disk each time.
`week_boundary_date` only affects which past weeks are eligible for the rolling-best
lookback below — it does NOT move week-to-date's own week, which always stays the
actual last date in fts_df (so a week with zero new data yet never gets falsely reset
to a hypothetical 0%). Defaults to this entry's own week, which reproduces the original
behavior of excluding the current week from its own target pool. backfill_history()
passes today's real date for the latest entry only, so a week that's actually finished
in real life (e.g. LinkedIn's export lagging a day behind on Mondays) immediately
becomes eligible as a rolling-best candidate, instead of waiting for next week's data
to start arriving before it's ever counted."""
fts_df = fts_df.sort_values("Date")
today = fts_df["Date"].iloc[-1]
total_followers = float(fts_df["Cumulative Followers"].iloc[-1])
# Trailing 7-day rate: a smoother, purely informational "how's it going right now"
# figure. Not used for the pace judgment (see week_to_date_pct below) since a trailing
# window updates every day and doesn't respect week boundaries.
week_ago = today - pd.Timedelta(days=7)
followers_week_ago = _followers_as_of(fts_df, week_ago)
weekly_pct = ((total_followers - followers_week_ago) / followers_week_ago * 100
if followers_week_ago else None)
weekly_pct = round(weekly_pct, 2) if weekly_pct is not None else None
if prior_history:
prev_total = prior_history[-1]["total_followers"]
since_last_pct = (total_followers - prev_total) / prev_total * 100 if prev_total else None
else:
since_last_pct = None
# Discrete week-over-week pace: growth since the start of THIS week (always the real
# last-data week — never moved by week_boundary_date), compared against the best of the
# last ROLLING_BEST_WEEKS COMPLETED weeks.
current_week_start = _week_start_for(today)
week_start_baseline = _followers_as_of(fts_df, current_week_start - pd.Timedelta(days=1))
week_to_date_pct = ((total_followers - week_start_baseline) / week_start_baseline * 100
if week_start_baseline else 0.0)
week_to_date_pct = round(week_to_date_pct, 2)
# Which past weeks are eligible for the rolling-best pool: strictly before the lookback
# boundary, which defaults to this entry's own week (excluding it from its own target,
# since an ONGOING week can't fairly be judged against its own still-accumulating
# total) but can be pushed forward to the real current date for the live/latest entry —
# at that point this week is no longer "ongoing" in real life even if fresh data for it
# hasn't arrived, so it's fair game as a candidate for whatever comes next.
lookback_boundary_week_start = (_week_start_for(week_boundary_date)
if week_boundary_date is not None else current_week_start)
lookback_start = lookback_boundary_week_start - pd.Timedelta(weeks=ROLLING_BEST_WEEKS)
last_entry_per_week = {}
for h in prior_history:
h_week_start = h.get("week_start")
if h_week_start is None or not (lookback_start <= pd.Timestamp(h_week_start) < lookback_boundary_week_start):
continue
existing = last_entry_per_week.get(h_week_start)
if existing is None or h["date"] > existing["date"]:
last_entry_per_week[h_week_start] = h
completed_week_rates = [h["week_to_date_pct"] for h in last_entry_per_week.values()]
rolling_best_weekly_pct = round(max(completed_week_rates), 2) if completed_week_rates else None
on_track = (None if rolling_best_weekly_pct is None
else week_to_date_pct >= rolling_best_weekly_pct)
return {
"date": today.strftime("%Y-%m-%d"),
"total_followers": int(total_followers),
"weekly_pct": weekly_pct,
"since_last_pct": round(since_last_pct, 2) if since_last_pct is not None else None,
"week_start": current_week_start.strftime("%Y-%m-%d"),
"week_to_date_pct": week_to_date_pct,
"rolling_best_weekly_pct": rolling_best_weekly_pct,
"rolling_best_weeks": ROLLING_BEST_WEEKS,
"on_track": on_track,
}
def last_two_completed_weeks(history):
"""The most recent COMPLETED week's final entry, and the one before it — both full
Monday-Sunday weeks. Used for an apples-to-apples week-over-week comparison, as opposed
to comparing a full week against the current week, which is always partial until the
next Monday's upload.
"Completed" is judged against the real calendar (today's actual date), not the last date
present in the export — LinkedIn's export routinely lags a day or two behind (e.g. it may
stop at Saturday), which previously made an already-finished week look "still in
progress" and made the sidebar fall back to showing the week before it."""
if not history:
return None, None
real_today = pd.Timestamp(datetime.date.today())
current_week_start = (real_today - pd.Timedelta(days=real_today.weekday())).strftime("%Y-%m-%d")
last_entry_per_week = {}
for h in history:
if h["week_start"] >= current_week_start:
continue
existing = last_entry_per_week.get(h["week_start"])
if existing is None or h["date"] > existing["date"]:
last_entry_per_week[h["week_start"]] = h
completed_weeks = sorted(last_entry_per_week.values(), key=lambda h: h["week_start"], reverse=True)
last_week = completed_weeks[0] if len(completed_weeks) >= 1 else None
prior_week = completed_weeks[1] if len(completed_weeks) >= 2 else None
return last_week, prior_week
def backfill_history(fts_df, upload_dir):
"""Ensures the growth-history log has one entry per day already present in fts_df —
backfills any days from before this feature existed (each computed using only the data
that would have been available up to that day, so backfilled entries are historically
accurate, not hindsight-adjusted), while always recomputing the MOST RECENT day fresh so
a same-day re-upload/correction updates that day's entry instead of reusing a stale one.
Returns the full merged history list, sorted chronologically."""
existing = {e["date"]: e for e in load_history(upload_dir)}
fts_sorted = fts_df.sort_values("Date")
dates = list(fts_sorted["Date"])
real_today = pd.Timestamp(datetime.date.today())
history = []
for i, d in enumerate(dates):
date_str = d.strftime("%Y-%m-%d")
is_latest = (i == len(dates) - 1)
if date_str in existing and not is_latest:
history.append(existing[date_str])
continue
subset = fts_sorted[fts_sorted["Date"] <= d]
# Only the latest entry gets the real-calendar boundary — backfilled days stay
# reproducible, using just their own date as before.
boundary = max(d, real_today) if is_latest else None
history.append(compute_snapshot(subset, history, week_boundary_date=boundary))
_save_history(upload_dir, history)
return history
+251
View File
@@ -0,0 +1,251 @@
"""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} &nbsp;|&nbsp; Generated {pd.Timestamp.now().strftime('%B %d, %Y')} "
f"&nbsp;|&nbsp; 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()
+6
View File
@@ -0,0 +1,6 @@
dash>=2.0
dash-bootstrap-components>=1.0
plotly>=5.0
pandas>=1.3
openpyxl>=3.0
xlrd>=2.0