feat: time-range dropdown on analytics — 1d / 7d / 14d / 28d / 6mo / 1yr / lifetime

A `?range=` query param threads through every shop + product analytics
query. The page renders a <select> in the header that re-loads with the
chosen range; default stays 28d so existing bookmarks behave the same.

Bucket coarsening keeps every chart ~24-30 bars regardless of range:

  range      bucket     ~bars  label format
  ───────────────────────────────────────────
  1d         hourly     24     HH:00
  7d         daily      7      Mon
  14d        daily      14     Mar 5
  28d        daily      28     Mar 5
  6mo        weekly     26     Mar 5
  1yr        biweekly   26     Mar
  lifetime   monthly+   ~24    Mar '25

Lifetime sizes its bucket dynamically from the shop's first PageSession
so old shops widen past monthly. RANGE_SPECS in views/analytics.py owns
all of it; `label_step` thins x-axis labels per range so they don't
collide.

Every section that used to be hardcoded 7d / 14d / 21d / 28d now reads
the selected range: overview strip, top products (now total + newer-half
+ older-half + trend arrow), ring entries, engagement / attention /
learning / passive boards, video metrics, traffic, devices, sentiment,
keywords, referrer domains, search queries, referrer trend chart.

Ring Consumed (its own multi-range card) and Views Over Time on the
product page stay fixed — they're permanent comparison views.

Test coverage: TestAnalytics.test_analytics_shows_overview_with_data
now asserts the default-range label, that the range <select> renders,
and that switching to ?range=7d re-labels the overview.
This commit is contained in:
russell@unturf.com 2026-05-15 09:56:16 -04:00
parent 546e85416e
commit bb54152d47
No known key found for this signature in database
4 changed files with 466 additions and 296 deletions

View file

@ -12,7 +12,7 @@
{%- if as_pct -%}{{ (v * 100) | round(0) | int }}%{%- else -%}{{ v | round(0) | int }}{{ unit }}{%- endif -%}
{%- endmacro %}
{% macro line_chart(data, max_value, label, color, unit="", as_pct=False, axis_label="") %}
{% macro line_chart(data, max_value, label, color, unit="", as_pct=False, axis_label="", label_step=7) %}
{% if data and max_value > 0 %}
<div class="analytics-chart-svg">
<svg viewBox="0 0 610 170" role="img" aria-label="{{ label }}">
@ -41,7 +41,7 @@
</circle>
{% endfor %}
{% for b in data %}
{% if loop.index0 % 7 == 0 %}
{% if loop.index0 % label_step == 0 %}
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
@ -53,17 +53,28 @@
<section class="analytics-page">
<h3>Analytics</h3>
<div class="analytics-header">
<h3>Analytics</h3>
<form method="get" action="" class="analytics-range-form">
<label for="analytics-range">Time range</label>
<select id="analytics-range" name="range" onchange="this.form.submit()">
{% for key, label in range_options %}
<option value="{{ key }}"{% if key == range_key %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="mps-button">Apply</button></noscript>
</form>
</div>
{% if overview.total_views == 0 %}
<p>No view data yet. Analytics appear after visitors spend at least 7 seconds on your content.</p>
{% else %}
{# --- Section 1: Overview Strip (last 7 days) --- #}
{# --- Section 1: Overview Strip ({{ range_label }}) --- #}
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.total_views }}</span>
<span class="analytics-overview-label">Views (7d)</span>
<span class="analytics-overview-label">Views ({{ range_label }})</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.unique_products }}</span>
@ -85,7 +96,7 @@
{# --- Daily Views (28-day bar chart) --- #}
{% if daily_views and daily_views_max > 0 %}
<h4>Daily Views (last 28 days)</h4>
<h4>Daily Views ({{ range_label }})</h4>
<div class="analytics-chart-svg">
<svg viewBox="0 0 610 170" role="img" aria-label="Daily views bar chart">
{# y-axis label #}
@ -107,9 +118,9 @@
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
</rect>
{% endfor %}
{# Date labels every 7th bar #}
{# Date labels — thin out every label_step bar so they don't collide #}
{% for b in daily_views %}
{% if loop.index0 % 7 == 0 %}
{% if loop.index0 % label_step == 0 %}
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
@ -120,21 +131,21 @@
{# --- Trend Line Charts (28 days) --- #}
{% if daily_session_duration_max > 0 %}
<h4>Session Duration Trend (last 28 days)</h4>
<h4>Session Duration Trend ({{ range_label }})</h4>
<p class="analytics-hint">Average time visitors spend per session.</p>
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)", unit="s", axis_label="seconds") }}
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)", unit="s", axis_label="seconds", label_step=label_step) }}
{% endif %}
{% if daily_engagement %}
<h4>Engagement Trend (last 28 days)</h4>
<h4>Engagement Trend ({{ range_label }})</h4>
<p class="analytics-hint">Active interaction time as a fraction of total session time.</p>
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)", as_pct=True, axis_label="%") }}
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)", as_pct=True, axis_label="%", label_step=label_step) }}
{% endif %}
{% if daily_bounce %}
<h4>Bounce Rate Trend (last 28 days)</h4>
<h4>Bounce Rate Trend ({{ range_label }})</h4>
<p class="analytics-hint">Sessions under 7 seconds visible. Lower is better.</p>
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)", as_pct=True, axis_label="%") }}
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)", as_pct=True, axis_label="%", label_step=label_step) }}
{% endif %}
{# --- Ring Consumed breakdown --- #}
@ -165,16 +176,17 @@
{# --- Section 2: Top Products by Views --- #}
{% if top_products %}
<h4>Top Products by Views</h4>
<h4>Top Products by Views ({{ range_label }})</h4>
<p class="analytics-hint">Total views in range, split into newer half / older half. Trend is the comparison.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th class="analytics-num">7d</th>
<th class="analytics-num">14d</th>
<th class="analytics-num">21d</th>
<th class="analytics-num">Total</th>
<th class="analytics-num">Newer</th>
<th class="analytics-num">Older</th>
<th class="analytics-num">Trend</th>
</tr>
</thead>
@ -183,9 +195,9 @@
<tr>
<td>{{ p.rank }}</td>
<td><a href="{{ p.analytics_url }}">{{ p.title }}</a></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">{{ p.views_total }}<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_newer }}</td>
<td class="analytics-num">{{ p.views_older }}</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>
</tr>
{% endfor %}
@ -196,7 +208,7 @@
{# --- Section 3: Ring Entry Points --- #}
{% if ring_entries %}
<h4>Ring Entry Points (top 42 front doors, last 21 days)</h4>
<h4>Ring Entry Points (top 42 front doors, {{ range_label }})</h4>
<p class="analytics-hint">People find your shop through these products &mdash; make sure they're polished.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
@ -342,7 +354,7 @@
{# --- Video Watch Metrics (21d) --- #}
{% if video_overview %}
<h4>Video Watch Metrics (last 21 days)</h4>
<h4>Video Watch Metrics ({{ range_label }})</h4>
<p class="analytics-hint">Aggregate viewing behavior across {{ video_overview.total_sessions }} media sessions.</p>
<div class="analytics-overview well">
<div class="analytics-overview-item">
@ -410,7 +422,7 @@
<div class="analytics-pair">
{% if traffic %}
<div>
<h4>Traffic Sources (last 21 days)</h4>
<h4>Traffic Sources ({{ range_label }})</h4>
<div class="analytics-bar-rows">
{% for t in traffic %}
<div class="analytics-bar-row">
@ -427,7 +439,7 @@
{% if devices %}
<div>
<h4>Device Split (last 21 days)</h4>
<h4>Device Split ({{ range_label }})</h4>
<div class="analytics-bar-rows">
{% for d in devices %}
<div class="analytics-bar-row">
@ -446,14 +458,14 @@
{# --- Daily External Referrer Trend (28 days) --- #}
{% if daily_referrers_max > 0 %}
<h4>External Referrer Trend (last 28 days)</h4>
<h4>External Referrer Trend ({{ range_label }})</h4>
<p class="analytics-hint">Daily count of visits from external referrers (excludes direct and internal traffic).</p>
{{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)", axis_label="visits") }}
{{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)", axis_label="visits", label_step=label_step) }}
{% endif %}
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
<h4>Top Referrer Domains (last 21 days)</h4>
<h4>Top Referrer Domains ({{ range_label }})</h4>
<p class="analytics-hint">External sites sending visitors to your shop.</p>
<div class="analytics-bar-rows">
{% for d in top_referrer_domains %}
@ -470,7 +482,7 @@
{# --- Search Engine Queries (21d) --- #}
{% if top_referrer_queries %}
<h4>Search Engine Queries (last 21 days)</h4>
<h4>Search Engine Queries ({{ range_label }})</h4>
<p class="analytics-hint">What people searched before finding your shop.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
@ -494,7 +506,7 @@
{# --- Internal Search Keywords (21d) --- #}
{% if top_keywords %}
<h4>Top Search Keywords (last 21 days)</h4>
<h4>Top Search Keywords ({{ range_label }})</h4>
<p class="analytics-hint">What visitors search for inside your shop.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
@ -522,7 +534,7 @@
{# --- Comment Sentiment (21d) --- #}
{% if sentiment and sentiment.total > 0 %}
<h4>Comment Sentiment (last 21 days)</h4>
<h4>Comment Sentiment ({{ range_label }})</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
@ -545,7 +557,7 @@
{# --- Recent Price Changes (21d) --- #}
{% if price_changes %}
<h4>Recent Price Changes (last 21 days)</h4>
<h4>Recent Price Changes ({{ range_label }})</h4>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>

View file

@ -6,29 +6,40 @@
{% block content -%}
{% macro line_chart(data, max_value, label, color) %}
{# y-axis tick label — see analytics.j2 for full doc #}
{% macro y_tick_label(v, unit, as_pct) -%}
{%- if as_pct -%}{{ (v * 100) | round(0) | int }}%{%- else -%}{{ v | round(0) | int }}{{ unit }}{%- endif -%}
{%- endmacro %}
{% macro line_chart(data, max_value, label, color, unit="", as_pct=False, axis_label="", label_step=7) %}
{% if data and max_value > 0 %}
<div class="analytics-chart-svg">
<svg viewBox="0 0 560 160" role="img" aria-label="{{ label }}">
{% 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" />
<svg viewBox="0 0 610 170" role="img" aria-label="{{ label }}">
{% if axis_label %}
<text x="50" y="8" text-anchor="start"
font-size="9" fill="var(--text-muted, #999)">{{ axis_label }}</text>
{% endif %}
{% for pct in [0, 25, 50, 75, 100] %}
{% set y_pos = 140 - (pct / 100 * 130) %}
<line x1="50" y1="{{ y_pos }}" x2="606" y2="{{ y_pos }}"
stroke="{{ 'var(--border-color, #dee2e6)' if pct == 0 else 'var(--border-light, #e9ecef)' }}" stroke-width="1" />
<text x="46" y="{{ y_pos }}" text-anchor="end" dominant-baseline="middle"
font-size="9" fill="var(--text-muted, #999)">{{ y_tick_label((pct / 100) * max_value, unit, as_pct) }}</text>
{% endfor %}
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
<polygon points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 8 }},140 8,140"
<polygon points="{% for b in data %}{{ loop.index0 * 20 + 58 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 58 }},140 58,140"
fill="{{ color }}" opacity="0.15" />
<polyline
points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}"
points="{% for b in data %}{{ loop.index0 * 20 + 58 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}"
fill="none" stroke="{{ color }}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
{% for b in data %}
<circle cx="{{ loop.index0 * 20 + 8 }}" cy="{{ 140 - (b.value / max_value * 130) }}"
<circle cx="{{ loop.index0 * 20 + 58 }}" cy="{{ 140 - (b.value / max_value * 130) }}"
r="3" fill="{{ color }}">
<title>{{ b.label }}: {{ b.tooltip }}</title>
</circle>
{% endfor %}
{% for b in data %}
{% if loop.index0 % 7 == 0 %}
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
{% if loop.index0 % label_step == 0 %}
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
{% endfor %}
@ -43,13 +54,24 @@
<p class="analytics-permalink"><a href="{{ product.absolute_url(request) }}">{{ product.absolute_url(request) }}</a></p>
<h3>{{ product.title }}</h3>
<div class="analytics-header">
<h3>{{ product.title }}</h3>
<form method="get" action="" class="analytics-range-form">
<label for="analytics-range">Time range</label>
<select id="analytics-range" name="range" onchange="this.form.submit()">
{% for key, label in range_options %}
<option value="{{ key }}"{% if key == range_key %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="mps-button">Apply</button></noscript>
</form>
</div>
{# --- Overview Strip --- #}
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.views_7d }}</span>
<span class="analytics-overview-label">Views (7d)</span>
<span class="analytics-overview-value">{{ overview.views_range }}</span>
<span class="analytics-overview-label">Views ({{ range_label }})</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.avg_session }}</span>
@ -65,29 +87,30 @@
</div>
</div>
{# --- Daily Views (28-day bar chart) --- #}
{# --- Daily Views ({{ range_label }}) --- #}
{% if daily_views and daily_views_max > 0 %}
<h4>Daily Views (last 28 days)</h4>
<h4>Daily Views ({{ range_label }})</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" />
<svg viewBox="0 0 610 170" role="img" aria-label="Daily views bar chart">
<text x="50" y="8" text-anchor="start"
font-size="9" fill="var(--text-muted, #999)">views</text>
{% for pct in [0, 25, 50, 75, 100] %}
{% set y_pos = 140 - (pct / 100 * 130) %}
<line x1="50" y1="{{ y_pos }}" x2="606" y2="{{ y_pos }}"
stroke="{{ 'var(--border-color, #dee2e6)' if pct == 0 else 'var(--border-light, #e9ecef)' }}" stroke-width="1" />
<text x="46" y="{{ y_pos }}" text-anchor="end" dominant-baseline="middle"
font-size="9" fill="var(--text-muted, #999)">{{ ((pct / 100) * daily_views_max) | round(0) | int }}</text>
{% 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 }}"
<rect x="{{ loop.index0 * 20 + 50 }}" 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"
{% if loop.index0 % label_step == 0 %}
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
{% endfor %}
@ -97,21 +120,21 @@
{# --- Trend Line Charts (28 days) --- #}
{% if daily_session_duration_max > 0 %}
<h4>Session Duration Trend (last 28 days)</h4>
<h4>Session Duration Trend ({{ range_label }})</h4>
<p class="analytics-hint">Average time visitors spend per session.</p>
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)", unit="s", axis_label="seconds", label_step=label_step) }}
{% endif %}
{% if daily_engagement %}
<h4>Engagement Trend (last 28 days)</h4>
<h4>Engagement Trend ({{ range_label }})</h4>
<p class="analytics-hint">Active interaction time as a fraction of total session time.</p>
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)", as_pct=True, axis_label="%", label_step=label_step) }}
{% endif %}
{% if daily_bounce %}
<h4>Bounce Rate Trend (last 28 days)</h4>
<h4>Bounce Rate Trend ({{ range_label }})</h4>
<p class="analytics-hint">Sessions under 7 seconds visible. Lower is better.</p>
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)", as_pct=True, axis_label="%", label_step=label_step) }}
{% endif %}
{# --- Views Over Time --- #}
@ -145,7 +168,7 @@
{# --- Video Watch Metrics --- #}
{% if video %}
<h4>Video Watch Metrics (last 21 days)</h4>
<h4>Video Watch Metrics ({{ range_label }})</h4>
<p class="analytics-hint">Based on {{ video.sessions }} media sessions.</p>
<div class="analytics-overview well">
<div class="analytics-overview-item">
@ -184,7 +207,7 @@
<div class="analytics-pair">
{% if traffic %}
<div>
<h4>Traffic Sources (last 21 days)</h4>
<h4>Traffic Sources ({{ range_label }})</h4>
<div class="analytics-bar-rows">
{% for t in traffic %}
<div class="analytics-bar-row">
@ -201,7 +224,7 @@
{% if devices %}
<div>
<h4>Device Split (last 21 days)</h4>
<h4>Device Split ({{ range_label }})</h4>
<div class="analytics-bar-rows">
{% for d in devices %}
<div class="analytics-bar-row">
@ -220,14 +243,14 @@
{# --- Daily External Referrer Trend (28 days) --- #}
{% if daily_referrers_max > 0 %}
<h4>External Referrer Trend (last 28 days)</h4>
<h4>External Referrer Trend ({{ range_label }})</h4>
<p class="analytics-hint">Daily count of visits from external referrers (excludes direct and internal traffic).</p>
{{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)") }}
{{ line_chart(daily_referrers, daily_referrers_max, "External referrer trend", "var(--orange-color, #e8a838)", axis_label="visits", label_step=label_step) }}
{% endif %}
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
<h4>Top Referrer Domains (last 21 days)</h4>
<h4>Top Referrer Domains ({{ range_label }})</h4>
<p class="analytics-hint">External sites sending visitors to this product.</p>
<div class="analytics-bar-rows">
{% for d in top_referrer_domains %}
@ -244,7 +267,7 @@
{# --- Search Engine Queries (21d) --- #}
{% if top_referrer_queries %}
<h4>Search Engine Queries (last 21 days)</h4>
<h4>Search Engine Queries ({{ range_label }})</h4>
<p class="analytics-hint">What people searched before finding this product.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
@ -268,7 +291,7 @@
{# --- Ring Entries --- #}
{% if ring_entry_count > 0 %}
<h4>Ring Entry Sessions (last 21 days)</h4>
<h4>Ring Entry Sessions ({{ range_label }})</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ ring_entry_count }}</span>
@ -279,7 +302,7 @@
{# --- Engagement --- #}
{% if engagement %}
<h4>Engagement (last 21 days)</h4>
<h4>Engagement ({{ range_label }})</h4>
<p class="analytics-hint">Based on {{ engagement.sessions }} sessions with 7s+ wall clock.</p>
<div class="analytics-overview well">
<div class="analytics-overview-item">
@ -303,7 +326,7 @@
{# --- Comment Sentiment --- #}
{% if sentiment and sentiment.total > 0 %}
<h4>Comment Sentiment (last 21 days)</h4>
<h4>Comment Sentiment ({{ range_label }})</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.positive }}</span>

View file

@ -4002,7 +4002,14 @@ class TestAnalytics(_AuthenticatedBase):
body = res.body.decode()
# Should show overview, not empty state
self.assertNotIn("No view data yet", body)
self.assertIn("Views (7d)", body)
# Default range is 28d, so the overview label reflects that.
self.assertIn("Views (Last 28 days)", body)
# Range selector should be present on every page load.
self.assertIn('name="range"', body)
# And switching to a 7d range should re-label.
res7 = self.testapp.get(f"/s/{shop_id}/analytics?range=7d", status=200)
self.assertIn("Views (Last 7 days)", res7.body.decode())
def test_analytics_privacy_note(self):
"""Analytics page always shows the privacy note."""
@ -7802,6 +7809,76 @@ class TestUserCartsList(_AuthenticatedBase):
body = self.testapp.get("/u/carts", status=200).body.decode()
self.assertIn(active_id, body)
def test_carts_list_shows_product_titles_with_links(self):
"""A non-empty cart row renders each product's title as a link
to the product page so the user can re-open it."""
from ..models.product import Product
from ..models.user import get_user_by_email
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Mug of Wonder", description="...")
product.shop = shop
product.price_in_cents = 1234
product.is_physical = False
product.is_sellable = True
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
user1 = get_user_by_email(self.dbsession, self.user1_creds[0])
cart = shop.create_new_cart_for_user(user1)
cart.add_product(product)
transaction.commit()
body = self.testapp.get("/u/carts", status=200).body.decode()
self.assertIn("Mug of Wonder", body)
self.assertIn(f"/p/{product_id}/", body)
def test_checked_out_cart_marked_and_shows_invoice_line_items(self):
"""An empty cart that has a linked invoice surfaces:
- a "checked out" tag,
- the line items from the invoice (so the user can repurchase),
- a "View receipt" link."""
from ..models.product import Product
from ..models.invoice import Invoice
from ..models.user import get_user_by_email
from ..models.cart import get_cart_by_id
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Already Bought", description="...")
product.shop = shop
product.price_in_cents = 500
product.is_physical = False
product.is_sellable = True
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
user1 = get_user_by_email(self.dbsession, self.user1_creds[0])
cart_empty = shop.create_new_cart_for_user(user1)
cart_empty_id = cart_empty.uuid_str
_cart_active = shop.create_new_cart_for_user(user1)
invoice = Invoice(user1)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.new_line_item(product=product, quantity=2)
invoice.apply_cart_negotiation(cart_empty)
self.dbsession.add(invoice)
self.dbsession.flush()
invoice_id = invoice.uuid_str
transaction.commit()
cart_empty = get_cart_by_id(self.dbsession, cart_empty_id)
self.assertEqual(cart_empty.invoices.count(), 1)
body = self.testapp.get("/u/carts", status=200).body.decode()
self.assertIn("checked out", body)
self.assertIn("Already Bought", body)
self.assertIn(f"/p/{product_id}/", body)
self.assertIn(f"/i/{invoice_id}", body)
self.assertIn("View receipt", body)
class TestUserOffersBidsDashboards(_AuthenticatedBase):
"""MPS-20 + MPS-21: buyer-side /u/offers and /u/bids pages plus the

View file

@ -1,5 +1,5 @@
from pyramid.view import view_config
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from sqlalchemy import func, case, and_, or_, cast, Float, Integer
from . import shop_editor_required
@ -18,18 +18,76 @@ MIN_SESSIONS = 7
DAY_MS = 24 * 60 * 60 * 1000
def _cutoffs():
"""Return 1d, 7d, 14d, 21d, 28d, 365d cutoff timestamps in milliseconds."""
# --- Time range selector (?range=) ------------------------------------------
#
# Each spec controls both (a) the cutoff applied to every query and (b) the
# bucket size + x-axis label format for the bar/line charts. `buckets` is
# the count of cells the chart will render (so the SVG stays at ~24-30
# bars regardless of range; long ranges coarsen the bucket instead of
# adding more bars). `label_step` thins out the x-axis labels so they
# don't collide.
RANGE_KEYS = ["1d", "7d", "14d", "28d", "6mo", "1yr", "lifetime"]
DEFAULT_RANGE_KEY = "28d"
RANGE_LABELS = {
"1d": "Last 24 hours",
"7d": "Last 7 days",
"14d": "Last 14 days",
"28d": "Last 28 days",
"6mo": "Last 6 months",
"1yr": "Last year",
"lifetime": "Lifetime",
}
RANGE_SPECS = {
"1d": {"days": 1, "bucket_ms": 60 * 60 * 1000, "buckets": 24, "label_fmt": "%H:00", "label_step": 4},
"7d": {"days": 7, "bucket_ms": DAY_MS, "buckets": 7, "label_fmt": "%a", "label_step": 1},
"14d": {"days": 14, "bucket_ms": DAY_MS, "buckets": 14, "label_fmt": "%b %-d", "label_step": 2},
"28d": {"days": 28, "bucket_ms": DAY_MS, "buckets": 28, "label_fmt": "%b %-d", "label_step": 7},
"6mo": {"days": 182, "bucket_ms": 7 * DAY_MS, "buckets": 26, "label_fmt": "%b %-d", "label_step": 4},
"1yr": {"days": 365, "bucket_ms": 14 * DAY_MS, "buckets": 26, "label_fmt": "%b %-d", "label_step": 4},
"lifetime": {"days": None, "bucket_ms": 30 * DAY_MS, "buckets": 24, "label_fmt": "%b '%y", "label_step": 4},
}
def _range_from_request(request):
"""Return the active range key (?range=...), falling back to default."""
key = request.params.get("range", DEFAULT_RANGE_KEY)
if key not in RANGE_SPECS:
key = DEFAULT_RANGE_KEY
return key
def _resolve_range_spec(dbsession, base_filter, range_key):
"""Build a spec dict with cutoff_ms (and lifetime-tuned sizing).
For "lifetime", we look up the first session for `base_filter` and
size the bucket dynamically so the chart still lands ~24 bars wide.
For all other ranges, the static RANGE_SPECS entry is returned with
`cutoff_ms` filled in.
"""
spec = dict(RANGE_SPECS[range_key])
now = now_timestamp()
day_ms = 24 * 60 * 60 * 1000
return {
"1d": now - 1 * day_ms,
"7d": now - 7 * day_ms,
"14d": now - 14 * day_ms,
"21d": now - 21 * day_ms,
"28d": now - 28 * day_ms,
"365d": now - 365 * day_ms,
}
if range_key == "lifetime":
first = (
dbsession.query(func.min(PageSession.created_timestamp))
.filter(base_filter)
.scalar()
)
if not first:
spec["cutoff_ms"] = 0
spec["buckets"] = 1
return spec
span = now - first
# Floor bucket at 30 days (monthly). For older shops, widen so we
# land around 24 cells. Cap at 36 cells.
spec["bucket_ms"] = max(30 * DAY_MS, span // 24)
spec["buckets"] = max(1, min(36, span // spec["bucket_ms"] + 1))
spec["cutoff_ms"] = first - 1
else:
spec["cutoff_ms"] = now - spec["days"] * DAY_MS
return spec
def _product_titles(dbsession, product_ids):
@ -79,195 +137,177 @@ 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.
def _bucket_label(now_ms, offset, bucket_ms, fmt):
"""Format the label for bucket `offset` (0 = most recent) using `fmt`
against the midpoint of the bucket in UTC."""
mid_ms = now_ms - (offset + 0.5) * bucket_ms
return datetime.fromtimestamp(mid_ms / 1000, tz=timezone.utc).strftime(fmt)
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.
def _time_buckets(dbsession, base_filter, spec):
"""Bucket page sessions into bar-chart cells per `spec`.
Returns oldest-first list of {"offset", "label", "count"} with empty
buckets filled. Bucket 0 is the most recent slice.
"""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
bucket_ms = spec["bucket_ms"]
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_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)
dbsession.query(bucket_expr.label("offset"), func.count().label("cnt"))
.filter(base_filter, PageSession.created_timestamp > spec["cutoff_ms"])
.group_by(bucket_expr)
.all()
)
counts = {r.day_offset: r.cnt for r in rows}
counts = {r.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)
for i in range(spec["buckets"] - 1, -1, -1):
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"offset": i,
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
"count": counts.get(i, 0),
})
return buckets
def _daily_avg_duration(dbsession, base_filter, days=28):
"""Bucket page sessions into daily avg wall_clock_ms for a line chart.
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
"""
def _time_avg_duration(dbsession, base_filter, spec):
"""Avg wall_clock_ms per bucket — line chart shape."""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
bucket_ms = spec["bucket_ms"]
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
bucket_expr.label("offset"),
func.avg(PageSession.wall_clock_ms).label("avg_ms"),
)
.filter(base_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.filter(base_filter, PageSession.created_timestamp > spec["cutoff_ms"])
.group_by(bucket_expr)
.all()
)
avgs = {r.day_offset: r.avg_ms or 0 for r in rows}
avgs = {r.offset: r.avg_ms or 0 for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
for i in range(spec["buckets"] - 1, -1, -1):
val = avgs.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"value": val,
"offset": i,
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
"value": val / 1000.0,
"tooltip": _fmt_ms(val),
})
return buckets
def _daily_engagement(dbsession, base_filter, days=28):
"""Bucket sessions into daily engagement ratio (active_ms / wall_clock_ms).
Returns oldest-first list with value as 0-1 float.
"""
def _time_engagement(dbsession, base_filter, spec):
"""Avg active/wall ratio per bucket — line chart shape."""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
bucket_ms = spec["bucket_ms"]
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
bucket_expr.label("offset"),
func.avg(
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
).label("ratio"),
)
.filter(base_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.filter(base_filter, PageSession.created_timestamp > spec["cutoff_ms"])
.group_by(bucket_expr)
.all()
)
ratios = {r.day_offset: r.ratio or 0 for r in rows}
ratios = {r.offset: r.ratio or 0 for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
for i in range(spec["buckets"] - 1, -1, -1):
val = ratios.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"offset": i,
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
"value": val,
"tooltip": _fmt_pct(val),
})
return buckets
def _daily_bounce_rate(dbsession, all_sessions_filter, days=28):
"""Bucket sessions into daily bounce rate (visible < 7s / total).
all_sessions_filter must NOT include the 7s visible floor.
Returns oldest-first list with value as 0-1 float.
"""
def _time_bounce_rate(dbsession, all_sessions_filter, spec):
"""Bounce rate (visible < 7s / total) per bucket. The filter must NOT
include the 7s visible floor bounces need to be in the denominator."""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
bucket_ms = spec["bucket_ms"]
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
bucket_expr.label("offset"),
func.count().label("total"),
func.sum(case(
(PageSession.visible_ms < 7000, 1), else_=0
)).label("bounced"),
)
.filter(all_sessions_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.filter(all_sessions_filter, PageSession.created_timestamp > spec["cutoff_ms"])
.group_by(bucket_expr)
.all()
)
rates = {}
for r in rows:
if r.total and r.total > 0:
rates[r.day_offset] = (r.bounced or 0) / r.total
rates[r.offset] = (r.bounced or 0) / r.total
else:
rates[r.day_offset] = 0
rates[r.offset] = 0
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
for i in range(spec["buckets"] - 1, -1, -1):
val = rates.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"offset": i,
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
"value": val,
"tooltip": _fmt_pct(val),
})
return buckets
def _daily_referrer_counts(dbsession, base_filter, days=28):
"""Bucket external referrer sessions into daily counts for a line chart.
Excludes direct (class=0) and internal (class=3) traffic.
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
"""
def _time_referrer_counts(dbsession, base_filter, spec):
"""External-referrer counts per bucket. Excludes direct and internal."""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
bucket_ms = spec["bucket_ms"]
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
bucket_expr.label("offset"),
func.count().label("cnt"),
)
.filter(
base_filter,
PageSession.created_timestamp > cutoff,
PageSession.created_timestamp > spec["cutoff_ms"],
PageSession.referrer_domain.isnot(None),
PageSession.referrer_class != 0, # exclude direct
PageSession.referrer_class != 3, # exclude internal
PageSession.referrer_class != 0,
PageSession.referrer_class != 3,
)
.group_by(day_offset_expr)
.group_by(bucket_expr)
.all()
)
counts = {r.day_offset: r.cnt for r in rows}
counts = {r.offset: r.cnt for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
for i in range(spec["buckets"] - 1, -1, -1):
val = counts.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"offset": i,
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
"value": val,
"tooltip": str(val),
})
return buckets
def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
"""Top internal search keywords for a shop, last N days."""
def _top_search_keywords(dbsession, shop_id, cutoff_ms, limit=21):
"""Top internal search keywords for a shop since cutoff_ms."""
from ..models.shop_search_request import ShopSearchRequest
cutoff = now_timestamp() - days * DAY_MS
rows = (
dbsession.query(
ShopSearchRequest.keywords,
@ -276,7 +316,7 @@ def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
)
.filter(
ShopSearchRequest.shop_id == shop_id,
ShopSearchRequest.created_timestamp > cutoff,
ShopSearchRequest.created_timestamp > cutoff_ms,
)
.group_by(ShopSearchRequest.keywords)
.order_by(func.count().desc())
@ -294,9 +334,8 @@ def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
]
def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
"""Top referrer domains, excluding internal and direct."""
cutoff = now_timestamp() - days * DAY_MS
def _top_referrer_domains(dbsession, base_filter, cutoff_ms, limit=21):
"""Top referrer domains since cutoff_ms (excludes internal + direct)."""
rows = (
dbsession.query(
PageSession.referrer_domain,
@ -304,7 +343,7 @@ def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
)
.filter(
base_filter,
PageSession.created_timestamp > cutoff,
PageSession.created_timestamp > cutoff_ms,
PageSession.referrer_domain.isnot(None),
PageSession.referrer_class != 3, # exclude internal
PageSession.referrer_class != 0, # exclude direct
@ -326,9 +365,8 @@ def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
]
def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
"""Top search queries from external search engines."""
cutoff = now_timestamp() - days * DAY_MS
def _top_referrer_queries(dbsession, base_filter, cutoff_ms, limit=21):
"""Top search queries from external search engines since cutoff_ms."""
rows = (
dbsession.query(
PageSession.referrer_query,
@ -336,7 +374,7 @@ def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
)
.filter(
base_filter,
PageSession.created_timestamp > cutoff,
PageSession.created_timestamp > cutoff_ms,
PageSession.referrer_query.isnot(None),
PageSession.referrer_query != "",
)
@ -359,12 +397,22 @@ def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
def shop_analytics(request):
shop = request.shop
db = request.dbsession
cuts = _cutoffs()
cutoff_7d, cutoff_14d, cutoff_21d = cuts["7d"], cuts["14d"], cuts["21d"]
range_key = _range_from_request(request)
# Exclude owner/editor traffic from all funnel metrics
_not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712
# Non-temporal view base for time bucketing + lifetime resolution
_view_base = and_(
PageSession.shop_id == shop.id,
PageSession.visible_ms >= 7000,
_not_owner,
)
spec = _resolve_range_spec(db, _view_base, range_key)
cutoff_range = spec["cutoff_ms"]
label_step = spec["label_step"]
def _view_filter(cutoff):
"""Sessions that count as views (7s visible) after cutoff."""
return and_(
@ -383,32 +431,23 @@ 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)
# --- Line chart trends (28 days) ---
daily_session_duration = _daily_avg_duration(db, _view_base, 28)
daily_engagement = _daily_engagement(db, _view_base, 28)
# --- Daily/bucketed bar + line charts (selected range) ---
daily_views = _time_buckets(db, _view_base, spec)
daily_session_duration = _time_avg_duration(db, _view_base, spec)
daily_engagement = _time_engagement(db, _view_base, spec)
_all_sessions_base = and_(
PageSession.shop_id == shop.id,
_not_owner,
)
daily_bounce = _daily_bounce_rate(db, _all_sessions_base, 28)
daily_bounce = _time_bounce_rate(db, _all_sessions_base, spec)
# --- Section 1: Overview Strip (last 7 days) ---
# --- Section 1: Overview Strip (selected range) ---
ov = db.query(
func.count().label("total_views"),
func.count(func.distinct(PageSession.product_id)).label("unique_products"),
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
).filter(_view_filter(cutoff_7d)).one()
).filter(_view_filter(cutoff_range)).one()
ring_size = len(shop.discovery_ring) or 1
_ring_base = and_(
@ -425,29 +464,31 @@ def shop_analytics(request):
func.count(func.distinct(PageSession.product_id)),
).filter(*filt).scalar() or 0
# Ring Consumed has its own permanent multi-range card \u2014 leave fixed.
now_ms = now_timestamp()
ring_consumed = {
"1d": _ring_seen(cuts["1d"]),
"7d": _ring_seen(cuts["7d"]),
"28d": _ring_seen(cuts["28d"]),
"365d": _ring_seen(cuts["365d"]),
"1d": _ring_seen(now_ms - 1 * DAY_MS),
"7d": _ring_seen(now_ms - 7 * DAY_MS),
"28d": _ring_seen(now_ms - 28 * DAY_MS),
"365d": _ring_seen(now_ms - 365 * DAY_MS),
"all": _ring_seen(),
}
top_src = db.query(
PageSession.referrer_class, func.count().label("cnt"),
).filter(
_view_filter(cutoff_7d),
_view_filter(cutoff_range),
).group_by(
PageSession.referrer_class,
).order_by(func.count().desc()).first()
dev_rows_7d = db.query(
dev_rows_range = db.query(
PageSession.device_class, func.count().label("cnt"),
).filter(_view_filter(cutoff_7d)).group_by(PageSession.device_class).all()
dev_total_7d = sum(r.cnt for r in dev_rows_7d) or 1
).filter(_view_filter(cutoff_range)).group_by(PageSession.device_class).all()
dev_total_range = sum(r.cnt for r in dev_rows_range) or 1
dev_split_str = " \u00b7 ".join(
f"{_fmt_pct(r.cnt / dev_total_7d)} {DEVICE_LABELS.get(r.device_class, '?')}"
for r in sorted(dev_rows_7d, key=lambda r: -(r.cnt or 0))
f"{_fmt_pct(r.cnt / dev_total_range)} {DEVICE_LABELS.get(r.device_class, '?')}"
for r in sorted(dev_rows_range, key=lambda r: -(r.cnt or 0))
) or "\u2014"
overview = {
@ -461,41 +502,44 @@ def shop_analytics(request):
"device_split": dev_split_str,
}
# --- Section 2: Top Products by Views (7d / 14d / 21d) ---
# --- Section 2: Top Products by Views (range, with half-range trend) ---
# Split the selected window into a newer half and an older half so we
# can show a rising/falling trend arrow against the same horizon.
if range_key == "lifetime":
# Lifetime: split at the midpoint between cutoff_range and now.
cutoff_half = (cutoff_range + now_ms) // 2
else:
cutoff_half = now_ms - (spec["days"] // 2) * DAY_MS
views_7d_expr = func.sum(case(
(PageSession.created_timestamp > cutoff_7d, 1), else_=0,
))
views_14d_expr = func.sum(case(
(PageSession.created_timestamp > cutoff_14d, 1), else_=0,
views_half_expr = func.sum(case(
(PageSession.created_timestamp > cutoff_half, 1), else_=0,
))
top_prods_raw = db.query(
PageSession.product_id,
views_7d_expr.label("views_7d"),
views_14d_expr.label("views_14d"),
func.count().label("views_21d"),
views_half_expr.label("views_half"),
func.count().label("views_total"),
).filter(
_view_filter(cutoff_21d),
_view_filter(cutoff_range),
).group_by(
PageSession.product_id,
).order_by(views_7d_expr.desc()).limit(21).all()
).order_by(func.count().desc()).limit(21).all()
# --- Section 3: Ring Entry Points (top 7 front doors, 21d) ---
# --- Section 3: Ring Entry Points (top front doors, selected range) ---
ring_raw = db.query(
PageSession.product_id, func.count().label("cnt"),
).filter(
PageSession.shop_id == shop.id,
PageSession.is_ring_entry == True, # noqa: E712
PageSession.created_timestamp > cutoff_21d,
PageSession.created_timestamp > cutoff_range,
_not_owner,
).group_by(PageSession.product_id).order_by(func.count().desc()).limit(42).all()
ring_total = db.query(func.count()).filter(
PageSession.shop_id == shop.id,
PageSession.is_ring_entry == True, # noqa: E712
PageSession.created_timestamp > cutoff_21d,
PageSession.created_timestamp > cutoff_range,
_not_owner,
).scalar() or 1
@ -507,7 +551,7 @@ def shop_analytics(request):
eng_raw = db.query(
PageSession.product_id, eng_expr.label("score"), func.count().label("sessions"),
).filter(
_session_filter(cutoff_21d),
_session_filter(cutoff_range),
).group_by(
PageSession.product_id,
).having(func.count() >= MIN_SESSIONS).order_by(eng_expr.desc()).limit(7).all()
@ -520,7 +564,7 @@ def shop_analytics(request):
att_raw = db.query(
PageSession.product_id, att_expr.label("score"), func.count().label("sessions"),
).filter(
_session_filter(cutoff_21d),
_session_filter(cutoff_range),
).group_by(
PageSession.product_id,
).having(func.count() >= MIN_SESSIONS).order_by(att_expr.desc()).limit(7).all()
@ -550,7 +594,7 @@ def shop_analytics(request):
learn_raw = db.query(
PageSession.product_id, learn_avg.label("score"), func.count().label("sessions"),
).filter(
_session_filter(cutoff_21d),
_session_filter(cutoff_range),
).group_by(
PageSession.product_id,
).having(func.count() >= MIN_SESSIONS).order_by(learn_avg.desc()).limit(7).all()
@ -586,36 +630,36 @@ def shop_analytics(request):
pass_raw = db.query(
PageSession.product_id, pass_avg.label("score"), func.count().label("sessions"),
).filter(
_session_filter(cutoff_21d),
_session_filter(cutoff_range),
).group_by(
PageSession.product_id,
).having(func.count() >= MIN_SESSIONS).order_by(pass_avg.desc()).limit(7).all()
# --- Section 8: Traffic Sources (21d) ---
# --- Section 8: Traffic Sources (selected range) ---
traffic_raw = db.query(
PageSession.referrer_class, func.count().label("cnt"),
).filter(
_view_filter(cutoff_21d),
_view_filter(cutoff_range),
).group_by(PageSession.referrer_class).order_by(func.count().desc()).all()
traffic_total = sum(r.cnt for r in traffic_raw) or 1
# --- Section 9: Device Split (21d) ---
# --- Section 9: Device Split (selected range) ---
device_raw = db.query(
PageSession.device_class, func.count().label("cnt"),
).filter(
_view_filter(cutoff_21d),
_view_filter(cutoff_range),
).group_by(PageSession.device_class).order_by(func.count().desc()).all()
device_total = sum(r.cnt for r in device_raw) or 1
# --- Video Watch Metrics (21d, sessions with media only) ---
# --- Video Watch Metrics (selected range, sessions with media only) ---
_media_filter = and_(
PageSession.shop_id == shop.id,
PageSession.media_duration_ms.isnot(None),
PageSession.media_duration_ms > 0,
PageSession.created_timestamp > cutoff_21d,
PageSession.created_timestamp > cutoff_range,
_not_owner,
)
@ -659,18 +703,18 @@ def shop_analytics(request):
func.avg(PageSession.media_percent_played).desc()
).limit(14).all()
# --- Comment Sentiment (21d) ---
# --- Comment Sentiment (selected range) ---
sentiment = get_sentiment_summary_for_shop(db, shop.id, cutoff_21d)
sentiment = get_sentiment_summary_for_shop(db, shop.id, cutoff_range)
# --- Recent Price Changes (21d) ---
# --- Recent Price Changes (selected range) ---
price_changes_raw = (
db.query(Price)
.join(Product)
.filter(
Product.shop_id == shop.id,
Price.created_timestamp > cutoff_21d,
Price.created_timestamp > cutoff_range,
)
.order_by(Price.created_timestamp.desc())
.limit(21)
@ -701,19 +745,22 @@ 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
top_max = max((r.views_total 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
total = r.views_total or 0
newer = r.views_half or 0
older = total - newer
# Rising if newer-half views exceed older-half (more than 50/50).
top_products.append({
"rank": i + 1,
"title": _t(r.product_id),
"url": _u(r.product_id),
"analytics_url": _a(r.product_id),
"views_7d": v7,
"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,
"views_total": total,
"views_newer": newer,
"views_older": older,
"trend": "rising" if newer > older else "falling",
"pct_of_max": total / top_max * 100,
})
ring_entries = [
@ -770,17 +817,21 @@ def shop_analytics(request):
daily_views_max = max((b["count"] for b in daily_views), default=0)
# --- Internal Search Keywords (21d) ---
top_keywords = _top_search_keywords(db, shop.id, 21, 21)
# --- Internal Search Keywords (selected range) ---
top_keywords = _top_search_keywords(db, shop.id, cutoff_range, 21)
# --- Referrer Domains & Search Queries (21d) ---
top_referrer_domains = _top_referrer_domains(db, _view_base, 21, 21)
top_referrer_queries = _top_referrer_queries(db, _view_base, 21, 21)
# --- Referrer Domains & Search Queries (selected range) ---
top_referrer_domains = _top_referrer_domains(db, _view_base, cutoff_range, 21)
top_referrer_queries = _top_referrer_queries(db, _view_base, cutoff_range, 21)
# --- Daily External Referrer Trend (28 days) ---
daily_referrers = _daily_referrer_counts(db, _view_base, 28)
# --- External Referrer Trend (selected range, bucketed) ---
daily_referrers = _time_referrer_counts(db, _view_base, spec)
return {
"range_key": range_key,
"range_label": RANGE_LABELS[range_key],
"range_options": [(k, RANGE_LABELS[k]) for k in RANGE_KEYS],
"label_step": label_step,
"overview": overview,
"ring_size": ring_size,
"daily_views": daily_views,
@ -836,12 +887,20 @@ def product_analytics(request):
if not product or product.shop_id != shop.id:
raise HTTPNotFound()
cuts = _cutoffs()
cutoff_7d = cuts["7d"]
cutoff_21d = cuts["21d"]
range_key = _range_from_request(request)
_not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712
# Non-temporal view base for time bucketing + lifetime resolution
_pf_base = and_(
PageSession.product_id == product.id,
PageSession.visible_ms >= 7000,
_not_owner,
)
spec = _resolve_range_spec(db, _pf_base, range_key)
cutoff_range = spec["cutoff_ms"]
label_step = spec["label_step"]
def _pf(cutoff):
"""Product view filter: 7s visible on this product after cutoff."""
return and_(
@ -860,28 +919,20 @@ 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)
# --- Line chart trends (28 days) ---
daily_session_duration = _daily_avg_duration(db, _pf_base, 28)
daily_engagement_data = _daily_engagement(db, _pf_base, 28)
daily_views = _time_buckets(db, _pf_base, spec)
daily_session_duration = _time_avg_duration(db, _pf_base, spec)
daily_engagement_data = _time_engagement(db, _pf_base, spec)
_all_product_sessions = and_(
PageSession.product_id == product.id,
_not_owner,
)
daily_bounce = _daily_bounce_rate(db, _all_product_sessions, 28)
daily_bounce = _time_bounce_rate(db, _all_product_sessions, spec)
# --- Overview ---
# --- Overview (selected range) ---
ov = db.query(
func.count().label("views"),
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
).filter(_pf(cutoff_7d)).one()
).filter(_pf(cutoff_range)).one()
bounce_q = db.query(
func.count().label("total"),
@ -890,7 +941,7 @@ def product_analytics(request):
)).label("bounced"),
).filter(
PageSession.product_id == product.id,
PageSession.created_timestamp > cutoff_7d,
PageSession.created_timestamp > cutoff_range,
_not_owner,
).one()
bounce_rate = None
@ -904,27 +955,30 @@ def product_analytics(request):
).scalar() or 0
overview = {
"views_7d": ov.views or 0,
"views_range": ov.views or 0,
"avg_session": _fmt_ms(ov.avg_session_ms),
"bounce_rate": _fmt_pct(bounce_rate),
"lifetime_views": lifetime_views,
# Back-compat: templates that haven't been re-wired yet read views_7d.
"views_7d": ov.views or 0,
}
# --- Views over time ---
# --- Views over time (multi-range comparison card — fixed) ---
now_ms = now_timestamp()
views_over_time = {}
for label, key in [("7d", "7d"), ("14d", "14d"), ("21d", "21d"), ("28d", "28d"), ("365d", "365d")]:
for label, days in [("7d", 7), ("14d", 14), ("21d", 21), ("28d", 28), ("365d", 365)]:
views_over_time[label] = db.query(func.count()).filter(
_pf(cuts[key])
_pf(now_ms - days * DAY_MS)
).scalar() or 0
views_over_time["lifetime"] = lifetime_views
# --- Video Watch Metrics ---
# --- Video Watch Metrics (selected range) ---
video = None
_mf = and_(
PageSession.product_id == product.id,
PageSession.media_duration_ms.isnot(None),
PageSession.media_duration_ms > 0,
PageSession.created_timestamp > cutoff_21d,
PageSession.created_timestamp > cutoff_range,
_not_owner,
)
vagg = db.query(
@ -956,10 +1010,10 @@ def product_analytics(request):
"sessions": vagg.sessions,
}
# --- Traffic Sources (21d) ---
# --- Traffic Sources (selected range) ---
traffic_raw = db.query(
PageSession.referrer_class, func.count().label("cnt"),
).filter(_pf(cutoff_21d)).group_by(
).filter(_pf(cutoff_range)).group_by(
PageSession.referrer_class,
).order_by(func.count().desc()).all()
traffic_total = sum(r.cnt for r in traffic_raw) or 1
@ -974,10 +1028,10 @@ def product_analytics(request):
for r in traffic_raw
]
# --- Devices (21d) ---
# --- Devices (selected range) ---
device_raw = db.query(
PageSession.device_class, func.count().label("cnt"),
).filter(_pf(cutoff_21d)).group_by(
).filter(_pf(cutoff_range)).group_by(
PageSession.device_class,
).order_by(func.count().desc()).all()
device_total = sum(r.cnt for r in device_raw) or 1
@ -992,15 +1046,15 @@ def product_analytics(request):
for r in device_raw
]
# --- Ring Entries (21d) ---
# --- Ring Entries (selected range) ---
ring_entry_count = db.query(func.count()).filter(
PageSession.product_id == product.id,
PageSession.is_ring_entry == True, # noqa: E712
PageSession.created_timestamp > cutoff_21d,
PageSession.created_timestamp > cutoff_range,
_not_owner,
).scalar() or 0
# --- Engagement (21d) ---
# --- Engagement (selected range) ---
eng_q = db.query(
func.avg(
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
@ -1011,7 +1065,7 @@ def product_analytics(request):
func.avg(PageSession.scroll_depth_max).label("avg_scroll"),
func.avg(PageSession.scroll_direction_changes).label("avg_dir_changes"),
func.count().label("sessions"),
).filter(_sf(cutoff_21d)).one()
).filter(_sf(cutoff_range)).one()
engagement = None
if eng_q.sessions and eng_q.sessions >= 1:
@ -1023,8 +1077,8 @@ def product_analytics(request):
"sessions": eng_q.sessions,
}
# --- Comment Sentiment (21d) ---
sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_21d)
# --- Comment Sentiment (selected range) ---
sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_range)
# --- Price History (last 21 changes) ---
price_changes_raw = (
@ -1047,14 +1101,18 @@ def product_analytics(request):
daily_views_max = max((b["count"] for b in daily_views), default=0)
# --- Referrer Domains & Search Queries (21d) ---
top_referrer_domains = _top_referrer_domains(db, _pf_base, 21, 21)
top_referrer_queries = _top_referrer_queries(db, _pf_base, 21, 21)
# --- Referrer Domains & Search Queries (selected range) ---
top_referrer_domains = _top_referrer_domains(db, _pf_base, cutoff_range, 21)
top_referrer_queries = _top_referrer_queries(db, _pf_base, cutoff_range, 21)
# --- Daily External Referrer Trend (28 days) ---
daily_referrers = _daily_referrer_counts(db, _pf_base, 28)
# --- External Referrer Trend (selected range, bucketed) ---
daily_referrers = _time_referrer_counts(db, _pf_base, spec)
return {
"range_key": range_key,
"range_label": RANGE_LABELS[range_key],
"range_options": [(k, RANGE_LABELS[k]) for k in RANGE_KEYS],
"label_step": label_step,
"product": product,
"product_url": product_url,
"overview": overview,