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:
commit
348fe65b88
10 files changed
+3064
No files matched your search
+14
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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()
|
||||
@@ -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
|
||||
@@ -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} | Generated {pd.Timestamp.now().strftime('%B %d, %Y')} "
|
||||
f" | Data as of {D['last_updated'].strftime('%B %d, %Y')}",
|
||||
subtitle_style))
|
||||
story.append(HRFlowable(width="100%", color=NAVY, thickness=1.2))
|
||||
|
||||
# --- KPI summary table ---
|
||||
story.append(Paragraph("Key Metrics", h2_style))
|
||||
kpi_data = [
|
||||
["Total Followers\n(as of today)", "New Followers", "Posts Published", "Total Impressions",
|
||||
"Total Page Views", "Best Post Eng.", "Avg. Eng. Rate"],
|
||||
[f"{current_total_followers:,}", f"{new_followers:,}", f"{posts_published:,}",
|
||||
f"{total_impressions:,}", f"{total_page_views:,}", f"{best_eng:.1f}%",
|
||||
f"{avg_eng:.2f}%" if avg_eng else "—"],
|
||||
]
|
||||
kpi_table = Table(kpi_data, colWidths=[2.6 * cm] * 7)
|
||||
kpi_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 7),
|
||||
("FONTSIZE", (0, 1), (-1, 1), 12),
|
||||
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
|
||||
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(kpi_table)
|
||||
|
||||
# --- Weekly growth pace ---
|
||||
story.append(Paragraph("Weekly Growth Pace", h2_style))
|
||||
on_track = growth_snapshot["on_track"]
|
||||
if on_track is None:
|
||||
status_color, status_label = TEXT_GRAY, "Building History"
|
||||
elif on_track:
|
||||
status_color, status_label = colors.HexColor("#52C41A"), "On Track"
|
||||
else:
|
||||
status_color, status_label = colors.HexColor("#d43f3a"), "Behind"
|
||||
weekly_pct = growth_snapshot["week_to_date_pct"]
|
||||
target_pct = growth_snapshot["rolling_best_weekly_pct"]
|
||||
objective_data = [
|
||||
["This Week", f"Target (best of {growth_snapshot['rolling_best_weeks']} wks)", "Pace Status"],
|
||||
[f"{weekly_pct:+.2f}%" if weekly_pct is not None else "—",
|
||||
f"{target_pct:+.2f}%" if target_pct is not None else "—",
|
||||
status_label],
|
||||
]
|
||||
objective_table = Table(objective_data, colWidths=[5 * cm, 6 * cm, 4 * cm])
|
||||
objective_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTSIZE", (0, 1), (-1, 1), 11),
|
||||
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
|
||||
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
||||
("TEXTCOLOR", (2, 1), (2, 1), status_color),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
story.append(objective_table)
|
||||
story.append(Paragraph(
|
||||
f"Target is a rolling personal best, the highest trailing-7-day growth rate seen in "
|
||||
f"the last {growth_snapshot['rolling_best_weeks']} weeks, not a fixed percentage. It "
|
||||
f"rises automatically as performance improves, but a one-off spike ages out of the "
|
||||
f"window after {growth_snapshot['rolling_best_weeks']} weeks rather than setting an "
|
||||
f"unreachable target permanently.",
|
||||
note_style))
|
||||
|
||||
# --- Follower growth chart ---
|
||||
if not fts_f.empty:
|
||||
story.append(Paragraph("Follower Growth", h2_style))
|
||||
fig = daily_followers_figure(fts_f, posts_f)
|
||||
fig.update_layout(showlegend=True, font=dict(size=14))
|
||||
story.append(_fig_to_image(fig))
|
||||
|
||||
# --- Top posts table ---
|
||||
story.append(Paragraph("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), h2_style))
|
||||
if len(posts_f):
|
||||
top_posts = posts_f.sort_values("Eng %", ascending=False).head(5)
|
||||
rows = [["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]]
|
||||
for _, r in top_posts.iterrows():
|
||||
rows.append([
|
||||
Paragraph(str(r["Short title"])[:60], body_style),
|
||||
r["Created date"].strftime("%d/%m/%Y"),
|
||||
r["Content Type"],
|
||||
f"{int(r['Impressions']):,}",
|
||||
f"{r['Eng %']:.1f}%",
|
||||
f"{r['CTR %']:.1f}%",
|
||||
])
|
||||
posts_table = Table(rows, colWidths=[7 * cm, 2.2 * cm, 2.2 * cm, 2.4 * cm, 1.8 * cm, 1.8 * cm])
|
||||
posts_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]))
|
||||
story.append(posts_table)
|
||||
else:
|
||||
story.append(Paragraph("No posts published in this period.", body_style))
|
||||
|
||||
# --- Content format performance ---
|
||||
if len(posts_f):
|
||||
story.append(Paragraph("Impressions by Content Format", h2_style))
|
||||
fmt_imp = (posts_f.groupby("Content Type", as_index=False)["Impressions"].sum().sort_values("Impressions"))
|
||||
fig2 = std_fn(px.bar(
|
||||
fmt_imp, x="Impressions", y="Content Type", orientation="h",
|
||||
template=tmpl, color="Content Type", color_discrete_map=format_colors))
|
||||
fig2.update_layout(showlegend=False, font=dict(size=14))
|
||||
story.append(_fig_to_image(fig2, height_px=220))
|
||||
|
||||
# --- Competitor snapshot ---
|
||||
story.append(Paragraph("Competitor Snapshot", h2_style))
|
||||
story.append(Paragraph(
|
||||
"Cumulative export snapshot, not specific to the selected period.", note_style))
|
||||
comp_rows = [["Page", "New Followers", "Posts", "Comments", "Comments/day", "Reactions"]]
|
||||
for _, r in comp_ordered.iterrows():
|
||||
label = r["Page"] + (" (You)" if r["Page"] == "TXproducts" else "")
|
||||
comp_rows.append([label, r["New Followers"], r["Posts"], r["Comments"], r["Comments per day"], r["Reactions"]])
|
||||
comp_table = Table(comp_rows, colWidths=[5 * cm, 2.6 * cm, 2 * cm, 2.2 * cm, 2.6 * cm, 2.2 * cm])
|
||||
comp_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]))
|
||||
story.append(comp_table)
|
||||
|
||||
story.append(Spacer(1, 16))
|
||||
story.append(Paragraph("TXproducts · Internal Report · Generated automatically from LinkedIn Page Analytics exports.", note_style))
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=1.5 * cm, bottomMargin=1.5 * cm,
|
||||
leftMargin=1.5 * cm, rightMargin=1.5 * cm)
|
||||
doc.build(story)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,6 @@
|
||||
dash>=2.0
|
||||
dash-bootstrap-components>=1.0
|
||||
plotly>=5.0
|
||||
pandas>=1.3
|
||||
openpyxl>=3.0
|
||||
xlrd>=2.0
|
||||
Reference in new issue
Block a user