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