Add Follower Directory/Study hero links, fix country-map centroid gaps, and sync accumulated dashboard work

- Hero banner now shows Follower Directory / Follower Study links on the
  Followers tab only (not the sidebar), opening the standalone reports served
  from assets/.
- Followers by Country: _COUNTRY_CENTROIDS expanded from 14 hardcoded entries
  to a near-complete world table, so a country missing from the old dict no
  longer vanishes from the map silently; added a console warning for any
  country still missing a centroid, and a "N countries represented" caption
  computed from the same rollup the map uses so the two can never disagree.
- Added visitor_history.py: append-only observation log for the Visitors
  export's category breakdowns (Location/Seniority/Company size), since
  LinkedIn only shows a category if it clears a per-export minimum-visits
  threshold and otherwise silently drops it.
- Synced other accumulated app.py/build_static_export.py work not yet
  committed: calendar month drill-down tiles, follower-breakdown expand
  toggle, CTR explainer, manual post title overrides, and matching static-
  export precompute/JS for the calendar and content tabs.
- .gitignore: exclude the newer generated snapshot outputs (follower study/
  directory HTML copies, PDF report, visitor_category_history.json) using
  the same rationale as the existing growth_history.json/snapshot rules —
  regenerable from code + data, not itself code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-07-31 08:49:19 +02:00
1 parent f631ba26a0
commit 8a72eeb402
4 files changed
+984 -116

No files matched your search

+5
View File
@@ -2,9 +2,14 @@
*.xls
*.xlsx
growth_history.json
visitor_category_history.json
# Generated output — regenerate locally with build_static_export.py, not shared code
TXproducts_Dashboard_Snapshot.html
TXproducts_Follower_Study_*.html
LinkedIn_Dashboard_Daily_Report_*.pdf
assets/follower_directory.html
assets/follower_study.html
# Python
__pycache__/
+682 -89
View File
File diff suppressed because it is too large. Load diff
+199 -27
View File
@@ -6,7 +6,7 @@ component trees (dbc.Card/Row/Col are just Bootstrap-class wrappers) into plain
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.
client-side with vanilla JS, everything else is a static snapshot of today's data.
"""
import base64
import html as html_escape_mod
@@ -60,11 +60,11 @@ def style_dict_to_css(style):
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
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
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:
@@ -134,13 +134,22 @@ def render(comp):
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>')
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 ""
# --- Upload: disabled in the snapshot, saving files needs a live Python backend ---
if isinstance(comp, dcc.Upload):
style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
"opacity": "0.5", "cursor": "not-allowed"})
cid = getattr(comp, "id", "") or next_chart_id()
return (f'<div id="{esc(cid)}" style="{style}" '
f'title="Uploading new data needs the live tool (requires a Python backend), not available in this snapshot.">'
f'{render(getattr(comp, "children", None))}</div>')
# --- DataTable: render a plain HTML table from data/columns ---
if isinstance(comp, dash_table.DataTable):
cols = comp.columns
@@ -231,16 +240,19 @@ def render(comp):
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'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}>'
if cid == "growth-indicator-btn":
onclick = f' onclick="switchTab(\'growth\')"'
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
# Unknown component type, best-effort render children
return render(getattr(comp, "children", None))
@@ -250,7 +262,7 @@ def fig_json(fig):
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."""
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"):
@@ -275,33 +287,109 @@ def precompute_calendar_states():
def precompute_fb_states():
"""Followers-By dropdown x Show-all-toggle states."""
"""Followers-By dropdown x Show-all-toggle x calendar-month states — mirrors the live
app's _render_followers_breakdown callback, which re-renders on fb-select, fb-expanded,
AND the selected calendar month (so June shows June's actual demographic snapshot, not
always the latest one)."""
states = {}
months = sorted(A.D["breakdown_history"]) + [None] # None = no month context (fallback/current)
for key, _label in A.FOLLOWER_BREAKDOWNS:
for expanded in (False, True):
_lbl, fig, total_n = A._breakdown_figure(key, expanded)
for ym in months:
year, month = ym if ym is not None else (None, None)
_lbl, fig, total_n = A._breakdown_figure(key, expanded, year, month)
fig = A._std(fig)
needs_toggle = total_n > 10
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
month_label = f"as of {A._MONTH_NAMES[month - 1]} {year}" if year and month else ""
month_key = f"{year}-{month}" if year and month else "none"
states[f"{key}|{str(expanded).lower()}|{month_key}"] = {
"fig": fig_json(fig),
"btnLabel": btn_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
"monthLabel": month_label,
}
return states
def precompute_fb_delta_states():
"""Followers-By month-over-month delta states — one per breakdown key x delta-eligible
month, mirroring the live app's _breakdown_delta_figure branch of _render_followers_breakdown."""
states = {}
for key, _label in A.FOLLOWER_BREAKDOWNS:
for year, month in A._followers_delta_months():
_lbl, fig, changed_n, overflow = A._breakdown_delta_figure(key, year, month)
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()}"] = {
month_label = f"{A._MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
if overflow > 0:
month_label += f" · showing top {A._DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
states[f"{key}|{year}-{month}"] = {
"fig": fig_json(fig),
"btnLabel": btn_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
"monthLabel": month_label,
}
return states
def precompute_vb_states():
"""Visitors-By dropdown states."""
def precompute_fb_count_only_states():
"""Months with real follower-count activity but no category snapshot to attribute it to
(currently just June 2026) — mirrors the live app's count-only branch of
_render_followers_breakdown, which shows a plain explanation instead of a bar chart."""
states = {}
for year, month in A._followers_count_only_months():
net, sign, detail = A._monthly_count_summary(year, month)
month_label = f"{A._MONTH_NAMES[month - 1]} {year} — no category breakdown available"
note_html = (
f'<div style="padding:20px 4px;">'
f'<div style="margin-bottom:8px;font-weight:700;color:#1a2533;">'
f'Net {esc(sign)}{net} follower{"s" if abs(net) != 1 else ""} in '
f'{A._MONTH_NAMES[month - 1]} {year}: {esc(detail)}.</div>'
f'<div style="color:#6b7a8d;font-size:0.8rem;">'
f'Category breakdown (location, industry, etc.) isn\'t available for these &mdash; '
f'LinkedIn\'s demographic export wasn\'t captured yet at this point, so there\'s no '
f'earlier snapshot to know which category they belong to.</div>'
f'</div>'
)
states[f"{year}-{month}"] = {"noteHtml": note_html, "monthLabel": month_label}
return states
def precompute_vb_states():
"""Visitors-By dropdown x Show-all-toggle x period states — mirrors precompute_fb_states.
'total' means the running total (no month scope); other keys are 'YYYY-M' month cutoffs
against the persistent running total (see visitor_history.py)."""
states = {}
periods = [("total", None, None)] + [
(f"{y}-{m}", y, m) for y, m in A._visitor_category_months()
]
for key, _label in A.VISITOR_BREAKDOWNS:
_lbl, fig = A._visitor_breakdown_figure(key)
states[key] = {"fig": fig_json(A._std(fig))}
for expanded in (False, True):
for period_key, year, month in periods:
_lbl, fig, total_n = A._visitor_breakdown_figure(key, expanded, year, month)
fig = A._std(fig)
needs_toggle = total_n > 10
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
month_label = f"as of {A._MONTH_NAMES[month - 1]} {year}" if year and month else ""
states[f"{key}|{str(expanded).lower()}|{period_key}"] = {
"fig": fig_json(fig),
"btnLabel": btn_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
"monthLabel": month_label,
}
return states
def precompute_frr_states():
"""Follow Rate Relative to Reach month-dropdown states."""
states = {}
for opt in A._frr_month_options(A.D["frr"]):
year, month = (int(p) for p in opt["value"].split("-"))
fig = A._std(A.follow_rate_figure(A.D["frr"], year, month))
states[opt["value"]] = {"fig": fig_json(fig)}
return states
def precompute_content_states():
"""Content tab's month-filter dropdown states — 6 charts + the posts table."""
"""Content tab's month-filter dropdown states, 6 charts + the posts table."""
states = {}
for opt in A._content_month_options():
value = opt["value"]
@@ -337,16 +425,26 @@ def precompute_content_states():
JS_TEMPLATE = r"""
const CHARTS = %(charts)s;
const TAB_HERO = %(tab_hero)s;
const TAB_HERO_INFO = %(tab_hero_info)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 FB_DELTA_STATES = %(fb_delta_states)s;
const FB_COUNT_ONLY_STATES = %(fb_count_only_states)s;
const VB_STATES = %(vb_states)s;
const CONTENT_STATES = %(content_states)s;
const FRR_STATES = %(frr_states)s;
const DELTA_LEGEND_HTML = '<div style="margin-top:10px;display:flex;flex-wrap:wrap;align-items:center;">'
+ '<span style="margin-right:16px;white-space:nowrap;"><span style="width:12px;height:12px;background-color:#52C41A;display:inline-block;margin-right:5px;vertical-align:middle;border-radius:2px;"></span><span style="font-size:0.72rem;color:#6b7a8d;">Gained</span></span>'
+ '<span style="margin-right:16px;white-space:nowrap;"><span style="width:12px;height:12px;background-color:#d43f3a;display:inline-block;margin-right:5px;vertical-align:middle;border-radius:2px;"></span><span style="font-size:0.72rem;color:#6b7a8d;">Lost</span></span>'
+ '</div>';
const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH};
let fbActive = 'company_size', fbExpanded = false;
let vbActive = 'seniority';
let fbActive = 'company_size', fbExpanded = false, fbPeriod = 'total';
let vbActive = 'seniority', vbExpanded = false, vbPeriod = 'total';
let frrPeriod = %(frr_default_period)s;
function renderChart(divId, figSpec) {
if (!figSpec || !document.getElementById(divId)) return;
@@ -378,7 +476,10 @@ function switchTab(key) {
var hero = TAB_HERO[key];
if (hero) {
document.getElementById('hero-title').textContent = hero[0];
document.getElementById('hero-subtitle').textContent = hero[1];
var subEl = document.getElementById('hero-subtitle');
subEl.textContent = hero[1];
var infoHtml = TAB_HERO_INFO[key];
if (infoHtml) subEl.insertAdjacentHTML('beforeend', infoHtml);
}
window.dispatchEvent(new Event('resize'));
}
@@ -397,12 +498,41 @@ function selectCalendarMonth(prefix, month) {
if (state.metrics) {
Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); });
}
if (prefix === 'followers') renderFollowersBreakdown();
}
function renderFollowersBreakdown() {
var s = FB_STATES[fbActive + '|' + fbExpanded];
if (fbPeriod !== 'total') {
var cs = FB_COUNT_ONLY_STATES[fbPeriod];
if (cs) {
var area = document.getElementById('followers-breakdown-area');
if (area) area.innerHTML = cs.noteHtml;
var clabel = document.getElementById('fb-month-label');
if (clabel) clabel.textContent = cs.monthLabel;
var cbtn = document.getElementById('fb-expand-btn');
if (cbtn) cbtn.style.display = 'none';
return;
}
var ds = FB_DELTA_STATES[fbActive + '|' + fbPeriod];
if (!ds) return;
var area2 = document.getElementById('followers-breakdown-area');
if (area2) area2.innerHTML = '<div class="js-plotly-chart" id="fb-breakdown-chart"></div>' + DELTA_LEGEND_HTML;
renderChartWithin('followers-breakdown-area', ds.fig);
var dlabel = document.getElementById('fb-month-label');
if (dlabel) dlabel.textContent = ds.monthLabel;
var dbtn = document.getElementById('fb-expand-btn');
if (dbtn) dbtn.style.display = 'none';
return;
}
var month = calMonth.followers;
var monthKey = month ? (CAL_YEAR + '-' + month) : 'none';
var s = FB_STATES[fbActive + '|' + fbExpanded + '|' + monthKey] || FB_STATES[fbActive + '|' + fbExpanded + '|none'];
if (!s) return;
var totalArea = document.getElementById('followers-breakdown-area');
if (totalArea) totalArea.innerHTML = '<div class="js-plotly-chart" id="fb-breakdown-chart"></div>';
renderChartWithin('followers-breakdown-area', s.fig);
var label = document.getElementById('fb-month-label');
if (label) label.textContent = s.monthLabel;
var btn = document.getElementById('fb-expand-btn');
if (btn) {
btn.textContent = s.btnLabel;
@@ -411,9 +541,28 @@ function renderFollowersBreakdown() {
}
function renderVisitorBreakdown() {
var s = VB_STATES[vbActive];
var s = VB_STATES[vbActive + '|' + vbExpanded + '|' + vbPeriod];
if (!s) return;
var area = document.getElementById('visitor-breakdown-area');
if (area) area.innerHTML = '<div class="js-plotly-chart" id="vb-breakdown-chart"></div>';
renderChartWithin('visitor-breakdown-area', s.fig);
var label = document.getElementById('vb-month-label');
if (label) label.textContent = s.monthLabel;
var btn = document.getElementById('vb-expand-btn');
if (btn) {
btn.textContent = s.btnLabel;
btn.style.display = s.btnDisplay;
}
}
function renderFollowRate() {
var s = FRR_STATES[frrPeriod];
if (!s) return;
var area = document.getElementById('frr-area');
if (area && !area.querySelector('.js-plotly-chart')) {
area.innerHTML = '<div class="js-plotly-chart" id="frr-chart"></div>';
}
renderChartWithin('frr-area', s.fig);
}
function renderContentState(value) {
@@ -459,11 +608,19 @@ window.__onClick = function (el) {
renderFollowersBreakdown();
return;
}
if (id === 'vb-expand-btn') {
vbExpanded = !vbExpanded;
renderVisitorBreakdown();
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 === 'fb-period-select') { fbPeriod = el.value; renderFollowersBreakdown(); return; }
if (el.id === 'vb-select') { vbActive = el.value; vbExpanded = false; renderVisitorBreakdown(); return; }
if (el.id === 'vb-period-select') { vbPeriod = el.value; renderVisitorBreakdown(); return; }
if (el.id === 'frr-month-select') { frrPeriod = el.value; renderFollowRate(); return; }
if (el.id === 'content-month-select') { renderContentState(el.value); return; }
};
@@ -482,10 +639,20 @@ def build():
sidebar_html = render(A.sidebar)
hero_html = render(A.hero)
# Per-tab extra markup appended after the hero subtitle text (e.g. the Growth tab's
# "what does growth measure" info popover) — switchTab() re-appends this on tab change
# since setting textContent wipes any previously-injected popover markup.
tab_hero_info_html = {key: render(A.info_popover(f"hero-{key}", info))
for key, info in A.TAB_HERO_INFO.items()}
cal_states, cal_year, cal_default_month = precompute_calendar_states()
fb_states = precompute_fb_states()
fb_delta_states = precompute_fb_delta_states()
fb_count_only_states = precompute_fb_count_only_states()
vb_states = precompute_vb_states()
frr_states = precompute_frr_states()
_frr_opts = A._frr_month_options(A.D["frr"])
frr_default_period = _frr_opts[0]["value"] if _frr_opts else None
content_states = precompute_content_states()
with open(os.path.join(ASSETS_DIR, "custom.css")) as f:
@@ -500,11 +667,16 @@ def build():
js = JS_TEMPLATE % {
"charts": json.dumps(CHARTS),
"tab_hero": json.dumps(A.TAB_HERO),
"tab_hero_info": json.dumps(tab_hero_info_html),
"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),
"fb_delta_states": json.dumps(fb_delta_states),
"fb_count_only_states": json.dumps(fb_count_only_states),
"vb_states": json.dumps(vb_states),
"frr_states": json.dumps(frr_states),
"frr_default_period": json.dumps(frr_default_period),
"content_states": json.dumps(content_states),
}
@@ -512,7 +684,7 @@ def build():
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>TXproducts LinkedIn Analytics — Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})</title>
<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>
@@ -538,7 +710,7 @@ details[open] > .static-accordion-title:before {{ content:'\\25BE '; }}
</style>
</head>
<body>
<div class="snapshot-banner">Static snapshot as of {A.D['last_updated'].strftime('%B %d, %Y')} — dropdowns, month clicks,
<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}
+98
View File
@@ -0,0 +1,98 @@
"""Persists every weekly Visitors export's category breakdown (Location/Seniority/Company
size) as an append-only observation log, so a location that drops out of one week's export
(LinkedIn only shows a category if it clears a minimum-visits threshold that week) isn't lost
just because a later export didn't happen to include it.
Each observation is one (file date, category type, category value, count) reading. Nothing
is ever overwritten — a category's running total "as of" any given month is the sum of every
observation for it up to that date. This is a running additive tally, not a deduplicated
unique-visitor count: LinkedIn's per-export numbers are a rolling window (not a fresh daily
count), so consecutive weekly exports likely overlap in which underlying visits they're
counting. Treat the totals here as a cumulative activity signal, not a precise headcount.
Zero import-time dependency on app.py, same reason growth.py has none: app.py runs as
"__main__", so `import app` here would re-execute it as a second module copy still mid-import
when these functions get called.
"""
import json
import os
import pandas as pd
_SHEET_TYPES = {
"location": "Location",
"seniority": "Seniority",
"company_size": "Company size",
}
def _history_path(upload_dir):
return os.path.join(upload_dir, "visitor_category_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 backfill_history(upload_dir, visitor_files):
"""Scans every visitor export file and appends any observation not already recorded
(keyed by exact file date + category type + category value, so re-running this on
every app refresh never double-counts the same file twice)."""
history = load_history(upload_dir)
seen_keys = {(h["file_date"], h["type"], h["category"]) for h in history}
added = 0
for fp in visitor_files:
file_date = pd.Timestamp(os.path.getmtime(fp), unit="s").strftime("%Y-%m-%d")
for type_key, sheet_name in _SHEET_TYPES.items():
try:
df = pd.read_excel(fp, sheet_name=sheet_name)
except ValueError:
continue # sheet missing from this particular export, skip it
col = df.columns[0]
for _, row in df.iterrows():
category = str(row[col])
key = (file_date, type_key, category)
if key in seen_keys:
continue
history.append({
"file_date": file_date,
"type": type_key,
"category": category,
"count": int(row["Total views"]),
})
seen_keys.add(key)
added += 1
if added:
_save_history(upload_dir, history)
return history
def rollup_as_of(history, type_key, on_or_before=None):
"""Running additive total per category for one breakdown type, summed across every
observation on or before `on_or_before` (a 'YYYY-MM-DD' string or None for everything)."""
rows = [h for h in history if h["type"] == type_key]
if on_or_before is not None:
rows = [h for h in rows if h["file_date"] <= on_or_before]
if not rows:
return pd.DataFrame(columns=["Category", "Total views"])
df = pd.DataFrame(rows)
out = df.groupby("category", as_index=False)["count"].sum()
out.columns = ["Category", "Total views"]
return out.sort_values("Total views", ascending=False).reset_index(drop=True)
def available_months(history):
"""Every (year, month) with at least one observation, sorted oldest first."""
months = {tuple(int(p) for p in h["file_date"].split("-")[:2]) for h in history}
return sorted(months)