analytics: add visual charts to shop and product dashboards

SVG bar charts for 28-day daily views, CSS horizontal bars for
traffic sources and device split, inline mini bars in top products
table. Zero-dep, server-side rendered, theme-aware, no JS.
This commit is contained in:
russell@unturf.com 2026-02-18 09:14:20 -05:00
parent 85dffd0b24
commit b022d67323
4 changed files with 256 additions and 74 deletions

View file

@ -3384,6 +3384,83 @@ textarea {
background-color: transparent;
}
/* --- Analytics Charts --- */
/* SVG daily views chart container */
.analytics-chart-svg {
width: 100%;
margin-bottom: 24px;
}
.analytics-chart-svg svg {
width: 100%;
height: auto;
display: block;
}
/* CSS horizontal bar rows for traffic/devices */
.analytics-bar-rows {
margin-bottom: 24px;
}
.analytics-bar-row {
display: table;
width: 100%;
table-layout: fixed;
margin-bottom: 6px;
font-size: 0.9em;
}
.analytics-bar-label {
display: table-cell;
width: 80px;
vertical-align: middle;
padding-right: 8px;
color: var(--text-primary);
}
.analytics-bar-track {
display: table-cell;
vertical-align: middle;
padding: 0 8px;
}
.analytics-bar-fill {
display: block;
height: 16px;
background: var(--green-color, #a3c765);
border-radius: 2px;
min-width: 2px;
}
.analytics-bar-value {
display: table-cell;
width: 100px;
vertical-align: middle;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 0.85em;
}
/* Inline mini bar for table cells (top products 7d column) */
.analytics-inline-bar {
display: inline-block;
vertical-align: middle;
width: 50px;
height: 10px;
background: var(--bg-tertiary, #e9ecef);
border-radius: 2px;
margin-left: 6px;
}
.analytics-inline-bar-fill {
display: block;
height: 100%;
background: var(--green-color, #a3c765);
border-radius: 2px;
}
@media (min-width: 960px) {
.analytics-overview {
grid-template-columns: repeat(3, 1fr);
@ -3392,6 +3469,14 @@ textarea {
.analytics-pair {
grid-template-columns: 1fr 1fr;
}
.analytics-bar-label {
width: 100px;
}
.analytics-bar-value {
width: 120px;
}
}
.price-history-current td {

View file

@ -38,6 +38,36 @@
</div>
</div>
{# --- Daily Views (28-day bar chart) --- #}
{% if daily_views and daily_views_max > 0 %}
<h4>Daily Views (last 28 days)</h4>
<div class="analytics-chart-svg">
<svg viewBox="0 0 560 160" role="img" aria-label="Daily views bar chart">
{# Gridlines #}
{% for pct in [25, 50, 75] %}
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
{% endfor %}
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
{# Bars #}
{% for b in daily_views %}
{% set bar_h = (b.count / daily_views_max * 130) if daily_views_max > 0 else 0 %}
<rect x="{{ loop.index0 * 20 }}" y="{{ 140 - bar_h }}" width="16" height="{{ bar_h }}"
rx="2" fill="var(--green-color, #a3c765)">
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
</rect>
{% endfor %}
{# Date labels every 7th bar #}
{% for b in daily_views %}
{% if loop.index0 % 7 == 0 %}
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
{% endfor %}
</svg>
</div>
{% endif %}
{# --- Ring Consumed breakdown --- #}
<h4>Ring Consumed</h4>
<p class="analytics-hint">Unique products viewed via the ring as a percentage of all {{ ring_size }} ring products.</p>
@ -84,7 +114,7 @@
<tr>
<td>{{ p.rank }}</td>
<td><a href="{{ p.analytics_url }}">{{ p.title }}</a></td>
<td class="analytics-num">{{ p.views_7d }}</td>
<td class="analytics-num">{{ p.views_7d }}<span class="analytics-inline-bar"><span class="analytics-inline-bar-fill" style="width: {{ p.pct_of_max | round(1) }}%"></span></span></td>
<td class="analytics-num">{{ p.views_14d }}</td>
<td class="analytics-num">{{ p.views_21d }}</td>
<td class="analytics-num">{% if p.trend == "rising" %}<span class="analytics-trend-up">&#9650;</span>{% else %}<span class="analytics-trend-down">&#9660;</span>{% endif %}</td>
@ -312,48 +342,34 @@
{% if traffic %}
<div>
<h4>Traffic Sources (last 21 days)</h4>
<table class="analytics-table">
<thead>
<tr>
<th>Source</th>
<th class="analytics-num">Sessions</th>
<th class="analytics-num">%</th>
</tr>
</thead>
<tbody>
{% for t in traffic %}
<tr>
<td>{{ t.source }}</td>
<td class="analytics-num">{{ t.count }}</td>
<td class="analytics-num">{{ t.pct }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="analytics-bar-rows">
{% for t in traffic %}
<div class="analytics-bar-row">
<span class="analytics-bar-label">{{ t.source }}</span>
<span class="analytics-bar-track">
<span class="analytics-bar-fill" style="width: {{ t.pct_raw | round(1) }}%"></span>
</span>
<span class="analytics-bar-value">{{ t.count }} ({{ t.pct }})</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if devices %}
<div>
<h4>Device Split (last 21 days)</h4>
<table class="analytics-table">
<thead>
<tr>
<th>Device</th>
<th class="analytics-num">Sessions</th>
<th class="analytics-num">%</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td>{{ d.device }}</td>
<td class="analytics-num">{{ d.count }}</td>
<td class="analytics-num">{{ d.pct }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="analytics-bar-rows">
{% for d in devices %}
<div class="analytics-bar-row">
<span class="analytics-bar-label">{{ d.device }}</span>
<span class="analytics-bar-track">
<span class="analytics-bar-fill" style="width: {{ d.pct_raw | round(1) }}%"></span>
</span>
<span class="analytics-bar-value">{{ d.count }} ({{ d.pct }})</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View file

@ -32,6 +32,36 @@
</div>
</div>
{# --- Daily Views (28-day bar chart) --- #}
{% if daily_views and daily_views_max > 0 %}
<h4>Daily Views (last 28 days)</h4>
<div class="analytics-chart-svg">
<svg viewBox="0 0 560 160" role="img" aria-label="Daily views bar chart">
{# Gridlines #}
{% for pct in [25, 50, 75] %}
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
{% endfor %}
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
{# Bars #}
{% for b in daily_views %}
{% set bar_h = (b.count / daily_views_max * 130) if daily_views_max > 0 else 0 %}
<rect x="{{ loop.index0 * 20 }}" y="{{ 140 - bar_h }}" width="16" height="{{ bar_h }}"
rx="2" fill="var(--green-color, #a3c765)">
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
</rect>
{% endfor %}
{# Date labels every 7th bar #}
{% for b in daily_views %}
{% if loop.index0 % 7 == 0 %}
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
{% endfor %}
</svg>
</div>
{% endif %}
{# --- Views Over Time --- #}
<h4>Views Over Time</h4>
<div class="analytics-overview well">
@ -103,48 +133,34 @@
{% if traffic %}
<div>
<h4>Traffic Sources (last 21 days)</h4>
<table class="analytics-table">
<thead>
<tr>
<th>Source</th>
<th class="analytics-num">Sessions</th>
<th class="analytics-num">%</th>
</tr>
</thead>
<tbody>
{% for t in traffic %}
<tr>
<td>{{ t.source }}</td>
<td class="analytics-num">{{ t.count }}</td>
<td class="analytics-num">{{ t.pct }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="analytics-bar-rows">
{% for t in traffic %}
<div class="analytics-bar-row">
<span class="analytics-bar-label">{{ t.source }}</span>
<span class="analytics-bar-track">
<span class="analytics-bar-fill" style="width: {{ t.pct_raw | round(1) }}%"></span>
</span>
<span class="analytics-bar-value">{{ t.count }} ({{ t.pct }})</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if devices %}
<div>
<h4>Device Split (last 21 days)</h4>
<table class="analytics-table">
<thead>
<tr>
<th>Device</th>
<th class="analytics-num">Sessions</th>
<th class="analytics-num">%</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td>{{ d.device }}</td>
<td class="analytics-num">{{ d.count }}</td>
<td class="analytics-num">{{ d.pct }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="analytics-bar-rows">
{% for d in devices %}
<div class="analytics-bar-row">
<span class="analytics-bar-label">{{ d.device }}</span>
<span class="analytics-bar-track">
<span class="analytics-bar-fill" style="width: {{ d.pct_raw | round(1) }}%"></span>
</span>
<span class="analytics-bar-value">{{ d.count }} ({{ d.pct }})</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View file

@ -1,5 +1,6 @@
from pyramid.view import view_config
from sqlalchemy import func, case, and_, or_, cast, Float
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, case, and_, or_, cast, Float, Integer
from . import shop_editor_required
from ..models.page_session import PageSession
@ -14,6 +15,7 @@ from ..lib.currency import cents_to_dollars
REFERRER_LABELS = {0: "Direct", 1: "Search", 2: "Social", 3: "Internal", 4: "Unknown"}
DEVICE_LABELS = {0: "Mobile", 1: "Tablet", 2: "Desktop"}
MIN_SESSIONS = 7
DAY_MS = 24 * 60 * 60 * 1000
def _cutoffs():
@ -77,6 +79,37 @@ def _fmt_score(val):
return f"{val:.1f}"
def _daily_buckets(dbsession, base_filter, days=28):
"""Bucket page sessions into daily counts for a bar chart.
Returns oldest-first list of {"day_offset": int, "label": "Feb 18", "count": int}
with zero-days filled so the list always has exactly `days` entries.
"""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
rows = (
dbsession.query(day_offset_expr.label("day_offset"), func.count().label("cnt"))
.filter(base_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.all()
)
counts = {r.day_offset: r.cnt for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1): # oldest first
d = today - timedelta(days=i)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"count": counts.get(i, 0),
})
return buckets
@view_config(route_name="shop_analytics", renderer="analytics.j2")
@shop_editor_required()
def shop_analytics(request):
@ -106,6 +139,16 @@ def shop_analytics(request):
_not_owner,
)
# Non-temporal view base for daily bucketing
_view_base = and_(
PageSession.shop_id == shop.id,
PageSession.visible_ms >= 7000,
_not_owner,
)
# --- Daily views (28-day bar chart) ---
daily_views = _daily_buckets(db, _view_base, 28)
# --- Section 1: Overview Strip (last 7 days) ---
ov = db.query(
@ -405,6 +448,7 @@ def shop_analytics(request):
# --- Build template-ready data ---
top_products = []
top_max_7d = max((r.views_7d or 0 for r in top_prods_raw), default=0) or 1
for i, r in enumerate(top_prods_raw):
v7, v14 = r.views_7d or 0, r.views_14d or 0
top_products.append({
@ -416,6 +460,7 @@ def shop_analytics(request):
"views_14d": v14,
"views_21d": r.views_21d or 0,
"trend": "rising" if v14 > 0 and v7 > v14 / 2 else "falling",
"pct_of_max": v7 / top_max_7d * 100,
})
ring_entries = [
@ -470,9 +515,13 @@ def shop_analytics(request):
for pc in price_changes_raw
]
daily_views_max = max((b["count"] for b in daily_views), default=0)
return {
"overview": overview,
"ring_size": ring_size,
"daily_views": daily_views,
"daily_views_max": daily_views_max,
"top_products": top_products,
"ring_entries": ring_entries,
"engagement": _ranked(eng_raw, _fmt_pct),
@ -488,6 +537,7 @@ def shop_analytics(request):
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / traffic_total),
"pct_raw": r.cnt / traffic_total * 100,
}
for r in traffic_raw
],
@ -496,6 +546,7 @@ def shop_analytics(request):
"device": DEVICE_LABELS.get(r.device_class, "Unknown"),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / device_total),
"pct_raw": r.cnt / device_total * 100,
}
for r in device_raw
],
@ -537,6 +588,14 @@ def product_analytics(request):
_not_owner,
)
# Non-temporal view base for daily bucketing
_pf_base = and_(
PageSession.product_id == product.id,
PageSession.visible_ms >= 7000,
_not_owner,
)
daily_views = _daily_buckets(db, _pf_base, 28)
# --- Overview ---
ov = db.query(
func.count().label("views"),
@ -629,6 +688,7 @@ def product_analytics(request):
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / traffic_total),
"pct_raw": r.cnt / traffic_total * 100,
}
for r in traffic_raw
]
@ -646,6 +706,7 @@ def product_analytics(request):
"device": DEVICE_LABELS.get(r.device_class, "Unknown"),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / device_total),
"pct_raw": r.cnt / device_total * 100,
}
for r in device_raw
]
@ -703,11 +764,15 @@ def product_analytics(request):
product_url = f"/{'p' if product.is_sellable else 'c'}/{product.uuid_str}/{product.slug}"
daily_views_max = max((b["count"] for b in daily_views), default=0)
return {
"product": product,
"product_url": product_url,
"overview": overview,
"views_over_time": views_over_time,
"daily_views": daily_views,
"daily_views_max": daily_views_max,
"video": video,
"traffic": traffic,
"devices": devices,