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:
parent
85dffd0b24
commit
b022d67323
4 changed files with 256 additions and 74 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">▲</span>{% else %}<span class="analytics-trend-down">▼</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue