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 -%}
|
{%- if as_pct -%}{{ (v * 100) | round(0) | int }}%{%- else -%}{{ v | round(0) | int }}{{ unit }}{%- endif -%}
|
||||||
{%- endmacro %}
|
{%- 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 %}
|
{% if data and max_value > 0 %}
|
||||||
<div class="analytics-chart-svg">
|
<div class="analytics-chart-svg">
|
||||||
<svg viewBox="0 0 610 170" role="img" aria-label="{{ label }}">
|
<svg viewBox="0 0 610 170" role="img" aria-label="{{ label }}">
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
</circle>
|
</circle>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for b in data %}
|
{% 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"
|
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
|
||||||
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -53,17 +53,28 @@
|
||||||
|
|
||||||
<section class="analytics-page">
|
<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 %}
|
{% if overview.total_views == 0 %}
|
||||||
<p>No view data yet. Analytics appear after visitors spend at least 7 seconds on your content.</p>
|
<p>No view data yet. Analytics appear after visitors spend at least 7 seconds on your content.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{# --- Section 1: Overview Strip (last 7 days) --- #}
|
{# --- Section 1: Overview Strip ({{ range_label }}) --- #}
|
||||||
<div class="analytics-overview well">
|
<div class="analytics-overview well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ overview.total_views }}</span>
|
<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>
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ overview.unique_products }}</span>
|
<span class="analytics-overview-value">{{ overview.unique_products }}</span>
|
||||||
|
|
@ -85,7 +96,7 @@
|
||||||
|
|
||||||
{# --- Daily Views (28-day bar chart) --- #}
|
{# --- Daily Views (28-day bar chart) --- #}
|
||||||
{% if daily_views and daily_views_max > 0 %}
|
{% 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">
|
<div class="analytics-chart-svg">
|
||||||
<svg viewBox="0 0 610 170" role="img" aria-label="Daily views bar chart">
|
<svg viewBox="0 0 610 170" role="img" aria-label="Daily views bar chart">
|
||||||
{# y-axis label #}
|
{# y-axis label #}
|
||||||
|
|
@ -107,9 +118,9 @@
|
||||||
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
|
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
|
||||||
</rect>
|
</rect>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{# Date labels every 7th bar #}
|
{# Date labels — thin out every label_step bar so they don't collide #}
|
||||||
{% for b in daily_views %}
|
{% 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"
|
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
|
||||||
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -120,21 +131,21 @@
|
||||||
|
|
||||||
{# --- Trend Line Charts (28 days) --- #}
|
{# --- Trend Line Charts (28 days) --- #}
|
||||||
{% if daily_session_duration_max > 0 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if daily_engagement %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if daily_bounce %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Ring Consumed breakdown --- #}
|
{# --- Ring Consumed breakdown --- #}
|
||||||
|
|
@ -165,16 +176,17 @@
|
||||||
|
|
||||||
{# --- Section 2: Top Products by Views --- #}
|
{# --- Section 2: Top Products by Views --- #}
|
||||||
{% if top_products %}
|
{% 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">
|
<div class="analytics-table-wrap">
|
||||||
<table class="analytics-table">
|
<table class="analytics-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th class="analytics-num">7d</th>
|
<th class="analytics-num">Total</th>
|
||||||
<th class="analytics-num">14d</th>
|
<th class="analytics-num">Newer</th>
|
||||||
<th class="analytics-num">21d</th>
|
<th class="analytics-num">Older</th>
|
||||||
<th class="analytics-num">Trend</th>
|
<th class="analytics-num">Trend</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -183,9 +195,9 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ p.rank }}</td>
|
<td>{{ p.rank }}</td>
|
||||||
<td><a href="{{ p.analytics_url }}">{{ p.title }}</a></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_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_14d }}</td>
|
<td class="analytics-num">{{ p.views_newer }}</td>
|
||||||
<td class="analytics-num">{{ p.views_21d }}</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>
|
<td class="analytics-num">{% if p.trend == "rising" %}<span class="analytics-trend-up">▲</span>{% else %}<span class="analytics-trend-down">▼</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -196,7 +208,7 @@
|
||||||
|
|
||||||
{# --- Section 3: Ring Entry Points --- #}
|
{# --- Section 3: Ring Entry Points --- #}
|
||||||
{% if ring_entries %}
|
{% 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>
|
<p class="analytics-hint">People find your shop through these products — make sure they're polished.</p>
|
||||||
<div class="analytics-table-wrap">
|
<div class="analytics-table-wrap">
|
||||||
<table class="analytics-table">
|
<table class="analytics-table">
|
||||||
|
|
@ -342,7 +354,7 @@
|
||||||
|
|
||||||
{# --- Video Watch Metrics (21d) --- #}
|
{# --- Video Watch Metrics (21d) --- #}
|
||||||
{% if video_overview %}
|
{% 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>
|
<p class="analytics-hint">Aggregate viewing behavior across {{ video_overview.total_sessions }} media sessions.</p>
|
||||||
<div class="analytics-overview well">
|
<div class="analytics-overview well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
|
|
@ -410,7 +422,7 @@
|
||||||
<div class="analytics-pair">
|
<div class="analytics-pair">
|
||||||
{% if traffic %}
|
{% if traffic %}
|
||||||
<div>
|
<div>
|
||||||
<h4>Traffic Sources (last 21 days)</h4>
|
<h4>Traffic Sources ({{ range_label }})</h4>
|
||||||
<div class="analytics-bar-rows">
|
<div class="analytics-bar-rows">
|
||||||
{% for t in traffic %}
|
{% for t in traffic %}
|
||||||
<div class="analytics-bar-row">
|
<div class="analytics-bar-row">
|
||||||
|
|
@ -427,7 +439,7 @@
|
||||||
|
|
||||||
{% if devices %}
|
{% if devices %}
|
||||||
<div>
|
<div>
|
||||||
<h4>Device Split (last 21 days)</h4>
|
<h4>Device Split ({{ range_label }})</h4>
|
||||||
<div class="analytics-bar-rows">
|
<div class="analytics-bar-rows">
|
||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
<div class="analytics-bar-row">
|
<div class="analytics-bar-row">
|
||||||
|
|
@ -446,14 +458,14 @@
|
||||||
|
|
||||||
{# --- Daily External Referrer Trend (28 days) --- #}
|
{# --- Daily External Referrer Trend (28 days) --- #}
|
||||||
{% if daily_referrers_max > 0 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Top Referrer Domains (21d) --- #}
|
{# --- Top Referrer Domains (21d) --- #}
|
||||||
{% if top_referrer_domains %}
|
{% 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>
|
<p class="analytics-hint">External sites sending visitors to your shop.</p>
|
||||||
<div class="analytics-bar-rows">
|
<div class="analytics-bar-rows">
|
||||||
{% for d in top_referrer_domains %}
|
{% for d in top_referrer_domains %}
|
||||||
|
|
@ -470,7 +482,7 @@
|
||||||
|
|
||||||
{# --- Search Engine Queries (21d) --- #}
|
{# --- Search Engine Queries (21d) --- #}
|
||||||
{% if top_referrer_queries %}
|
{% 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>
|
<p class="analytics-hint">What people searched before finding your shop.</p>
|
||||||
<div class="analytics-table-wrap">
|
<div class="analytics-table-wrap">
|
||||||
<table class="analytics-table">
|
<table class="analytics-table">
|
||||||
|
|
@ -494,7 +506,7 @@
|
||||||
|
|
||||||
{# --- Internal Search Keywords (21d) --- #}
|
{# --- Internal Search Keywords (21d) --- #}
|
||||||
{% if top_keywords %}
|
{% 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>
|
<p class="analytics-hint">What visitors search for inside your shop.</p>
|
||||||
<div class="analytics-table-wrap">
|
<div class="analytics-table-wrap">
|
||||||
<table class="analytics-table">
|
<table class="analytics-table">
|
||||||
|
|
@ -522,7 +534,7 @@
|
||||||
|
|
||||||
{# --- Comment Sentiment (21d) --- #}
|
{# --- Comment Sentiment (21d) --- #}
|
||||||
{% if sentiment and sentiment.total > 0 %}
|
{% 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 well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
|
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
|
||||||
|
|
@ -545,7 +557,7 @@
|
||||||
|
|
||||||
{# --- Recent Price Changes (21d) --- #}
|
{# --- Recent Price Changes (21d) --- #}
|
||||||
{% if price_changes %}
|
{% if price_changes %}
|
||||||
<h4>Recent Price Changes (last 21 days)</h4>
|
<h4>Recent Price Changes ({{ range_label }})</h4>
|
||||||
<div class="analytics-table-wrap">
|
<div class="analytics-table-wrap">
|
||||||
<table class="analytics-table">
|
<table class="analytics-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,40 @@
|
||||||
|
|
||||||
{% block content -%}
|
{% 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 %}
|
{% if data and max_value > 0 %}
|
||||||
<div class="analytics-chart-svg">
|
<div class="analytics-chart-svg">
|
||||||
<svg viewBox="0 0 560 160" role="img" aria-label="{{ label }}">
|
<svg viewBox="0 0 610 170" role="img" aria-label="{{ label }}">
|
||||||
{% for pct in [25, 50, 75] %}
|
{% if axis_label %}
|
||||||
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
|
<text x="50" y="8" text-anchor="start"
|
||||||
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
|
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 %}
|
{% 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 + 58 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 58 }},140 58,140"
|
||||||
<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"
|
|
||||||
fill="{{ color }}" opacity="0.15" />
|
fill="{{ color }}" opacity="0.15" />
|
||||||
<polyline
|
<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" />
|
fill="none" stroke="{{ color }}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
||||||
{% for b in data %}
|
{% 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 }}">
|
r="3" fill="{{ color }}">
|
||||||
<title>{{ b.label }}: {{ b.tooltip }}</title>
|
<title>{{ b.label }}: {{ b.tooltip }}</title>
|
||||||
</circle>
|
</circle>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for b in data %}
|
{% for b in data %}
|
||||||
{% if loop.index0 % 7 == 0 %}
|
{% if loop.index0 % label_step == 0 %}
|
||||||
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
|
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
|
||||||
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -43,13 +54,24 @@
|
||||||
|
|
||||||
<p class="analytics-permalink"><a href="{{ product.absolute_url(request) }}">{{ product.absolute_url(request) }}</a></p>
|
<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 --- #}
|
{# --- Overview Strip --- #}
|
||||||
<div class="analytics-overview well">
|
<div class="analytics-overview well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ overview.views_7d }}</span>
|
<span class="analytics-overview-value">{{ overview.views_range }}</span>
|
||||||
<span class="analytics-overview-label">Views (7d)</span>
|
<span class="analytics-overview-label">Views ({{ range_label }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ overview.avg_session }}</span>
|
<span class="analytics-overview-value">{{ overview.avg_session }}</span>
|
||||||
|
|
@ -65,29 +87,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- Daily Views (28-day bar chart) --- #}
|
{# --- Daily Views ({{ range_label }}) --- #}
|
||||||
{% if daily_views and daily_views_max > 0 %}
|
{% 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">
|
<div class="analytics-chart-svg">
|
||||||
<svg viewBox="0 0 560 160" role="img" aria-label="Daily views bar chart">
|
<svg viewBox="0 0 610 170" role="img" aria-label="Daily views bar chart">
|
||||||
{# Gridlines #}
|
<text x="50" y="8" text-anchor="start"
|
||||||
{% for pct in [25, 50, 75] %}
|
font-size="9" fill="var(--text-muted, #999)">views</text>
|
||||||
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
|
{% for pct in [0, 25, 50, 75, 100] %}
|
||||||
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
|
{% 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 %}
|
{% endfor %}
|
||||||
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
|
|
||||||
{# Bars #}
|
|
||||||
{% for b in daily_views %}
|
{% for b in daily_views %}
|
||||||
{% set bar_h = (b.count / daily_views_max * 130) if daily_views_max > 0 else 0 %}
|
{% 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)">
|
rx="2" fill="var(--green-color, #a3c765)">
|
||||||
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
|
<title>{{ b.label }}: {{ b.count }} view{{ 's' if b.count != 1 else '' }}</title>
|
||||||
</rect>
|
</rect>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{# Date labels every 7th bar #}
|
|
||||||
{% for b in daily_views %}
|
{% for b in daily_views %}
|
||||||
{% if loop.index0 % 7 == 0 %}
|
{% if loop.index0 % label_step == 0 %}
|
||||||
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
|
<text x="{{ loop.index0 * 20 + 58 }}" y="155" text-anchor="middle"
|
||||||
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -97,21 +120,21 @@
|
||||||
|
|
||||||
{# --- Trend Line Charts (28 days) --- #}
|
{# --- Trend Line Charts (28 days) --- #}
|
||||||
{% if daily_session_duration_max > 0 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if daily_engagement %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if daily_bounce %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Views Over Time --- #}
|
{# --- Views Over Time --- #}
|
||||||
|
|
@ -145,7 +168,7 @@
|
||||||
|
|
||||||
{# --- Video Watch Metrics --- #}
|
{# --- Video Watch Metrics --- #}
|
||||||
{% if video %}
|
{% 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>
|
<p class="analytics-hint">Based on {{ video.sessions }} media sessions.</p>
|
||||||
<div class="analytics-overview well">
|
<div class="analytics-overview well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
|
|
@ -184,7 +207,7 @@
|
||||||
<div class="analytics-pair">
|
<div class="analytics-pair">
|
||||||
{% if traffic %}
|
{% if traffic %}
|
||||||
<div>
|
<div>
|
||||||
<h4>Traffic Sources (last 21 days)</h4>
|
<h4>Traffic Sources ({{ range_label }})</h4>
|
||||||
<div class="analytics-bar-rows">
|
<div class="analytics-bar-rows">
|
||||||
{% for t in traffic %}
|
{% for t in traffic %}
|
||||||
<div class="analytics-bar-row">
|
<div class="analytics-bar-row">
|
||||||
|
|
@ -201,7 +224,7 @@
|
||||||
|
|
||||||
{% if devices %}
|
{% if devices %}
|
||||||
<div>
|
<div>
|
||||||
<h4>Device Split (last 21 days)</h4>
|
<h4>Device Split ({{ range_label }})</h4>
|
||||||
<div class="analytics-bar-rows">
|
<div class="analytics-bar-rows">
|
||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
<div class="analytics-bar-row">
|
<div class="analytics-bar-row">
|
||||||
|
|
@ -220,14 +243,14 @@
|
||||||
|
|
||||||
{# --- Daily External Referrer Trend (28 days) --- #}
|
{# --- Daily External Referrer Trend (28 days) --- #}
|
||||||
{% if daily_referrers_max > 0 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Top Referrer Domains (21d) --- #}
|
{# --- Top Referrer Domains (21d) --- #}
|
||||||
{% if top_referrer_domains %}
|
{% 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>
|
<p class="analytics-hint">External sites sending visitors to this product.</p>
|
||||||
<div class="analytics-bar-rows">
|
<div class="analytics-bar-rows">
|
||||||
{% for d in top_referrer_domains %}
|
{% for d in top_referrer_domains %}
|
||||||
|
|
@ -244,7 +267,7 @@
|
||||||
|
|
||||||
{# --- Search Engine Queries (21d) --- #}
|
{# --- Search Engine Queries (21d) --- #}
|
||||||
{% if top_referrer_queries %}
|
{% 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>
|
<p class="analytics-hint">What people searched before finding this product.</p>
|
||||||
<div class="analytics-table-wrap">
|
<div class="analytics-table-wrap">
|
||||||
<table class="analytics-table">
|
<table class="analytics-table">
|
||||||
|
|
@ -268,7 +291,7 @@
|
||||||
|
|
||||||
{# --- Ring Entries --- #}
|
{# --- Ring Entries --- #}
|
||||||
{% if ring_entry_count > 0 %}
|
{% 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 well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ ring_entry_count }}</span>
|
<span class="analytics-overview-value">{{ ring_entry_count }}</span>
|
||||||
|
|
@ -279,7 +302,7 @@
|
||||||
|
|
||||||
{# --- Engagement --- #}
|
{# --- Engagement --- #}
|
||||||
{% if 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>
|
<p class="analytics-hint">Based on {{ engagement.sessions }} sessions with 7s+ wall clock.</p>
|
||||||
<div class="analytics-overview well">
|
<div class="analytics-overview well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
|
|
@ -303,7 +326,7 @@
|
||||||
|
|
||||||
{# --- Comment Sentiment --- #}
|
{# --- Comment Sentiment --- #}
|
||||||
{% if sentiment and sentiment.total > 0 %}
|
{% 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 well">
|
||||||
<div class="analytics-overview-item">
|
<div class="analytics-overview-item">
|
||||||
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
|
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
|
||||||
|
|
|
||||||
|
|
@ -4002,7 +4002,14 @@ class TestAnalytics(_AuthenticatedBase):
|
||||||
body = res.body.decode()
|
body = res.body.decode()
|
||||||
# Should show overview, not empty state
|
# Should show overview, not empty state
|
||||||
self.assertNotIn("No view data yet", body)
|
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):
|
def test_analytics_privacy_note(self):
|
||||||
"""Analytics page always shows the privacy note."""
|
"""Analytics page always shows the privacy note."""
|
||||||
|
|
@ -7802,6 +7809,76 @@ class TestUserCartsList(_AuthenticatedBase):
|
||||||
body = self.testapp.get("/u/carts", status=200).body.decode()
|
body = self.testapp.get("/u/carts", status=200).body.decode()
|
||||||
self.assertIn(active_id, body)
|
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):
|
class TestUserOffersBidsDashboards(_AuthenticatedBase):
|
||||||
"""MPS-20 + MPS-21: buyer-side /u/offers and /u/bids pages plus the
|
"""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 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 sqlalchemy import func, case, and_, or_, cast, Float, Integer
|
||||||
|
|
||||||
from . import shop_editor_required
|
from . import shop_editor_required
|
||||||
|
|
@ -18,18 +18,76 @@ MIN_SESSIONS = 7
|
||||||
DAY_MS = 24 * 60 * 60 * 1000
|
DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
def _cutoffs():
|
# --- Time range selector (?range=) ------------------------------------------
|
||||||
"""Return 1d, 7d, 14d, 21d, 28d, 365d cutoff timestamps in milliseconds."""
|
#
|
||||||
|
# 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()
|
now = now_timestamp()
|
||||||
day_ms = 24 * 60 * 60 * 1000
|
if range_key == "lifetime":
|
||||||
return {
|
first = (
|
||||||
"1d": now - 1 * day_ms,
|
dbsession.query(func.min(PageSession.created_timestamp))
|
||||||
"7d": now - 7 * day_ms,
|
.filter(base_filter)
|
||||||
"14d": now - 14 * day_ms,
|
.scalar()
|
||||||
"21d": now - 21 * day_ms,
|
)
|
||||||
"28d": now - 28 * day_ms,
|
if not first:
|
||||||
"365d": now - 365 * day_ms,
|
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):
|
def _product_titles(dbsession, product_ids):
|
||||||
|
|
@ -79,195 +137,177 @@ def _fmt_score(val):
|
||||||
return f"{val:.1f}"
|
return f"{val:.1f}"
|
||||||
|
|
||||||
|
|
||||||
def _daily_buckets(dbsession, base_filter, days=28):
|
def _bucket_label(now_ms, offset, bucket_ms, fmt):
|
||||||
"""Bucket page sessions into daily counts for a bar chart.
|
"""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()
|
now = now_timestamp()
|
||||||
cutoff = now - days * DAY_MS
|
bucket_ms = spec["bucket_ms"]
|
||||||
|
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
|
||||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(day_offset_expr.label("day_offset"), func.count().label("cnt"))
|
dbsession.query(bucket_expr.label("offset"), func.count().label("cnt"))
|
||||||
.filter(base_filter, PageSession.created_timestamp > cutoff)
|
.filter(base_filter, PageSession.created_timestamp > spec["cutoff_ms"])
|
||||||
.group_by(day_offset_expr)
|
.group_by(bucket_expr)
|
||||||
.all()
|
.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 = []
|
buckets = []
|
||||||
for i in range(days - 1, -1, -1): # oldest first
|
for i in range(spec["buckets"] - 1, -1, -1):
|
||||||
d = today - timedelta(days=i)
|
|
||||||
buckets.append({
|
buckets.append({
|
||||||
"day_offset": i,
|
"offset": i,
|
||||||
"label": d.strftime("%b %-d"),
|
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
|
||||||
"count": counts.get(i, 0),
|
"count": counts.get(i, 0),
|
||||||
})
|
})
|
||||||
return buckets
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def _daily_avg_duration(dbsession, base_filter, days=28):
|
def _time_avg_duration(dbsession, base_filter, spec):
|
||||||
"""Bucket page sessions into daily avg wall_clock_ms for a line chart.
|
"""Avg wall_clock_ms per bucket — line chart shape."""
|
||||||
|
|
||||||
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
|
|
||||||
"""
|
|
||||||
now = now_timestamp()
|
now = now_timestamp()
|
||||||
cutoff = now - days * DAY_MS
|
bucket_ms = spec["bucket_ms"]
|
||||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
day_offset_expr.label("day_offset"),
|
bucket_expr.label("offset"),
|
||||||
func.avg(PageSession.wall_clock_ms).label("avg_ms"),
|
func.avg(PageSession.wall_clock_ms).label("avg_ms"),
|
||||||
)
|
)
|
||||||
.filter(base_filter, PageSession.created_timestamp > cutoff)
|
.filter(base_filter, PageSession.created_timestamp > spec["cutoff_ms"])
|
||||||
.group_by(day_offset_expr)
|
.group_by(bucket_expr)
|
||||||
.all()
|
.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 = []
|
buckets = []
|
||||||
for i in range(days - 1, -1, -1):
|
for i in range(spec["buckets"] - 1, -1, -1):
|
||||||
d = today - timedelta(days=i)
|
|
||||||
val = avgs.get(i, 0)
|
val = avgs.get(i, 0)
|
||||||
buckets.append({
|
buckets.append({
|
||||||
"day_offset": i,
|
"offset": i,
|
||||||
"label": d.strftime("%b %-d"),
|
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
|
||||||
"value": val,
|
"value": val / 1000.0,
|
||||||
"tooltip": _fmt_ms(val),
|
"tooltip": _fmt_ms(val),
|
||||||
})
|
})
|
||||||
return buckets
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def _daily_engagement(dbsession, base_filter, days=28):
|
def _time_engagement(dbsession, base_filter, spec):
|
||||||
"""Bucket sessions into daily engagement ratio (active_ms / wall_clock_ms).
|
"""Avg active/wall ratio per bucket — line chart shape."""
|
||||||
|
|
||||||
Returns oldest-first list with value as 0-1 float.
|
|
||||||
"""
|
|
||||||
now = now_timestamp()
|
now = now_timestamp()
|
||||||
cutoff = now - days * DAY_MS
|
bucket_ms = spec["bucket_ms"]
|
||||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
day_offset_expr.label("day_offset"),
|
bucket_expr.label("offset"),
|
||||||
func.avg(
|
func.avg(
|
||||||
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
|
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
|
||||||
).label("ratio"),
|
).label("ratio"),
|
||||||
)
|
)
|
||||||
.filter(base_filter, PageSession.created_timestamp > cutoff)
|
.filter(base_filter, PageSession.created_timestamp > spec["cutoff_ms"])
|
||||||
.group_by(day_offset_expr)
|
.group_by(bucket_expr)
|
||||||
.all()
|
.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 = []
|
buckets = []
|
||||||
for i in range(days - 1, -1, -1):
|
for i in range(spec["buckets"] - 1, -1, -1):
|
||||||
d = today - timedelta(days=i)
|
|
||||||
val = ratios.get(i, 0)
|
val = ratios.get(i, 0)
|
||||||
buckets.append({
|
buckets.append({
|
||||||
"day_offset": i,
|
"offset": i,
|
||||||
"label": d.strftime("%b %-d"),
|
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
|
||||||
"value": val,
|
"value": val,
|
||||||
"tooltip": _fmt_pct(val),
|
"tooltip": _fmt_pct(val),
|
||||||
})
|
})
|
||||||
return buckets
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def _daily_bounce_rate(dbsession, all_sessions_filter, days=28):
|
def _time_bounce_rate(dbsession, all_sessions_filter, spec):
|
||||||
"""Bucket sessions into daily bounce rate (visible < 7s / total).
|
"""Bounce rate (visible < 7s / total) per bucket. The filter must NOT
|
||||||
|
include the 7s visible floor — bounces need to be in the denominator."""
|
||||||
all_sessions_filter must NOT include the 7s visible floor.
|
|
||||||
Returns oldest-first list with value as 0-1 float.
|
|
||||||
"""
|
|
||||||
now = now_timestamp()
|
now = now_timestamp()
|
||||||
cutoff = now - days * DAY_MS
|
bucket_ms = spec["bucket_ms"]
|
||||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
day_offset_expr.label("day_offset"),
|
bucket_expr.label("offset"),
|
||||||
func.count().label("total"),
|
func.count().label("total"),
|
||||||
func.sum(case(
|
func.sum(case(
|
||||||
(PageSession.visible_ms < 7000, 1), else_=0
|
(PageSession.visible_ms < 7000, 1), else_=0
|
||||||
)).label("bounced"),
|
)).label("bounced"),
|
||||||
)
|
)
|
||||||
.filter(all_sessions_filter, PageSession.created_timestamp > cutoff)
|
.filter(all_sessions_filter, PageSession.created_timestamp > spec["cutoff_ms"])
|
||||||
.group_by(day_offset_expr)
|
.group_by(bucket_expr)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
rates = {}
|
rates = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r.total and r.total > 0:
|
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:
|
else:
|
||||||
rates[r.day_offset] = 0
|
rates[r.offset] = 0
|
||||||
|
|
||||||
today = datetime.now(timezone.utc).date()
|
|
||||||
buckets = []
|
buckets = []
|
||||||
for i in range(days - 1, -1, -1):
|
for i in range(spec["buckets"] - 1, -1, -1):
|
||||||
d = today - timedelta(days=i)
|
|
||||||
val = rates.get(i, 0)
|
val = rates.get(i, 0)
|
||||||
buckets.append({
|
buckets.append({
|
||||||
"day_offset": i,
|
"offset": i,
|
||||||
"label": d.strftime("%b %-d"),
|
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
|
||||||
"value": val,
|
"value": val,
|
||||||
"tooltip": _fmt_pct(val),
|
"tooltip": _fmt_pct(val),
|
||||||
})
|
})
|
||||||
return buckets
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def _daily_referrer_counts(dbsession, base_filter, days=28):
|
def _time_referrer_counts(dbsession, base_filter, spec):
|
||||||
"""Bucket external referrer sessions into daily counts for a line chart.
|
"""External-referrer counts per bucket. Excludes direct and internal."""
|
||||||
|
|
||||||
Excludes direct (class=0) and internal (class=3) traffic.
|
|
||||||
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
|
|
||||||
"""
|
|
||||||
now = now_timestamp()
|
now = now_timestamp()
|
||||||
cutoff = now - days * DAY_MS
|
bucket_ms = spec["bucket_ms"]
|
||||||
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
|
bucket_expr = cast((now - PageSession.created_timestamp) / bucket_ms, Integer)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
day_offset_expr.label("day_offset"),
|
bucket_expr.label("offset"),
|
||||||
func.count().label("cnt"),
|
func.count().label("cnt"),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
base_filter,
|
base_filter,
|
||||||
PageSession.created_timestamp > cutoff,
|
PageSession.created_timestamp > spec["cutoff_ms"],
|
||||||
PageSession.referrer_domain.isnot(None),
|
PageSession.referrer_domain.isnot(None),
|
||||||
PageSession.referrer_class != 0, # exclude direct
|
PageSession.referrer_class != 0,
|
||||||
PageSession.referrer_class != 3, # exclude internal
|
PageSession.referrer_class != 3,
|
||||||
)
|
)
|
||||||
.group_by(day_offset_expr)
|
.group_by(bucket_expr)
|
||||||
.all()
|
.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 = []
|
buckets = []
|
||||||
for i in range(days - 1, -1, -1):
|
for i in range(spec["buckets"] - 1, -1, -1):
|
||||||
d = today - timedelta(days=i)
|
|
||||||
val = counts.get(i, 0)
|
val = counts.get(i, 0)
|
||||||
buckets.append({
|
buckets.append({
|
||||||
"day_offset": i,
|
"offset": i,
|
||||||
"label": d.strftime("%b %-d"),
|
"label": _bucket_label(now, i, bucket_ms, spec["label_fmt"]),
|
||||||
"value": val,
|
"value": val,
|
||||||
"tooltip": str(val),
|
"tooltip": str(val),
|
||||||
})
|
})
|
||||||
return buckets
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
|
def _top_search_keywords(dbsession, shop_id, cutoff_ms, limit=21):
|
||||||
"""Top internal search keywords for a shop, last N days."""
|
"""Top internal search keywords for a shop since cutoff_ms."""
|
||||||
from ..models.shop_search_request import ShopSearchRequest
|
from ..models.shop_search_request import ShopSearchRequest
|
||||||
cutoff = now_timestamp() - days * DAY_MS
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
ShopSearchRequest.keywords,
|
ShopSearchRequest.keywords,
|
||||||
|
|
@ -276,7 +316,7 @@ def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
ShopSearchRequest.shop_id == shop_id,
|
ShopSearchRequest.shop_id == shop_id,
|
||||||
ShopSearchRequest.created_timestamp > cutoff,
|
ShopSearchRequest.created_timestamp > cutoff_ms,
|
||||||
)
|
)
|
||||||
.group_by(ShopSearchRequest.keywords)
|
.group_by(ShopSearchRequest.keywords)
|
||||||
.order_by(func.count().desc())
|
.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):
|
def _top_referrer_domains(dbsession, base_filter, cutoff_ms, limit=21):
|
||||||
"""Top referrer domains, excluding internal and direct."""
|
"""Top referrer domains since cutoff_ms (excludes internal + direct)."""
|
||||||
cutoff = now_timestamp() - days * DAY_MS
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
PageSession.referrer_domain,
|
PageSession.referrer_domain,
|
||||||
|
|
@ -304,7 +343,7 @@ def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
base_filter,
|
base_filter,
|
||||||
PageSession.created_timestamp > cutoff,
|
PageSession.created_timestamp > cutoff_ms,
|
||||||
PageSession.referrer_domain.isnot(None),
|
PageSession.referrer_domain.isnot(None),
|
||||||
PageSession.referrer_class != 3, # exclude internal
|
PageSession.referrer_class != 3, # exclude internal
|
||||||
PageSession.referrer_class != 0, # exclude direct
|
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):
|
def _top_referrer_queries(dbsession, base_filter, cutoff_ms, limit=21):
|
||||||
"""Top search queries from external search engines."""
|
"""Top search queries from external search engines since cutoff_ms."""
|
||||||
cutoff = now_timestamp() - days * DAY_MS
|
|
||||||
rows = (
|
rows = (
|
||||||
dbsession.query(
|
dbsession.query(
|
||||||
PageSession.referrer_query,
|
PageSession.referrer_query,
|
||||||
|
|
@ -336,7 +374,7 @@ def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
base_filter,
|
base_filter,
|
||||||
PageSession.created_timestamp > cutoff,
|
PageSession.created_timestamp > cutoff_ms,
|
||||||
PageSession.referrer_query.isnot(None),
|
PageSession.referrer_query.isnot(None),
|
||||||
PageSession.referrer_query != "",
|
PageSession.referrer_query != "",
|
||||||
)
|
)
|
||||||
|
|
@ -359,12 +397,22 @@ def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
|
||||||
def shop_analytics(request):
|
def shop_analytics(request):
|
||||||
shop = request.shop
|
shop = request.shop
|
||||||
db = request.dbsession
|
db = request.dbsession
|
||||||
cuts = _cutoffs()
|
range_key = _range_from_request(request)
|
||||||
cutoff_7d, cutoff_14d, cutoff_21d = cuts["7d"], cuts["14d"], cuts["21d"]
|
|
||||||
|
|
||||||
# Exclude owner/editor traffic from all funnel metrics
|
# Exclude owner/editor traffic from all funnel metrics
|
||||||
_not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712
|
_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):
|
def _view_filter(cutoff):
|
||||||
"""Sessions that count as views (7s visible) after cutoff."""
|
"""Sessions that count as views (7s visible) after cutoff."""
|
||||||
return and_(
|
return and_(
|
||||||
|
|
@ -383,32 +431,23 @@ def shop_analytics(request):
|
||||||
_not_owner,
|
_not_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-temporal view base for daily bucketing
|
# --- Daily/bucketed bar + line charts (selected range) ---
|
||||||
_view_base = and_(
|
daily_views = _time_buckets(db, _view_base, spec)
|
||||||
PageSession.shop_id == shop.id,
|
daily_session_duration = _time_avg_duration(db, _view_base, spec)
|
||||||
PageSession.visible_ms >= 7000,
|
daily_engagement = _time_engagement(db, _view_base, spec)
|
||||||
_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)
|
|
||||||
_all_sessions_base = and_(
|
_all_sessions_base = and_(
|
||||||
PageSession.shop_id == shop.id,
|
PageSession.shop_id == shop.id,
|
||||||
_not_owner,
|
_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(
|
ov = db.query(
|
||||||
func.count().label("total_views"),
|
func.count().label("total_views"),
|
||||||
func.count(func.distinct(PageSession.product_id)).label("unique_products"),
|
func.count(func.distinct(PageSession.product_id)).label("unique_products"),
|
||||||
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
|
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_size = len(shop.discovery_ring) or 1
|
||||||
_ring_base = and_(
|
_ring_base = and_(
|
||||||
|
|
@ -425,29 +464,31 @@ def shop_analytics(request):
|
||||||
func.count(func.distinct(PageSession.product_id)),
|
func.count(func.distinct(PageSession.product_id)),
|
||||||
).filter(*filt).scalar() or 0
|
).filter(*filt).scalar() or 0
|
||||||
|
|
||||||
|
# Ring Consumed has its own permanent multi-range card \u2014 leave fixed.
|
||||||
|
now_ms = now_timestamp()
|
||||||
ring_consumed = {
|
ring_consumed = {
|
||||||
"1d": _ring_seen(cuts["1d"]),
|
"1d": _ring_seen(now_ms - 1 * DAY_MS),
|
||||||
"7d": _ring_seen(cuts["7d"]),
|
"7d": _ring_seen(now_ms - 7 * DAY_MS),
|
||||||
"28d": _ring_seen(cuts["28d"]),
|
"28d": _ring_seen(now_ms - 28 * DAY_MS),
|
||||||
"365d": _ring_seen(cuts["365d"]),
|
"365d": _ring_seen(now_ms - 365 * DAY_MS),
|
||||||
"all": _ring_seen(),
|
"all": _ring_seen(),
|
||||||
}
|
}
|
||||||
|
|
||||||
top_src = db.query(
|
top_src = db.query(
|
||||||
PageSession.referrer_class, func.count().label("cnt"),
|
PageSession.referrer_class, func.count().label("cnt"),
|
||||||
).filter(
|
).filter(
|
||||||
_view_filter(cutoff_7d),
|
_view_filter(cutoff_range),
|
||||||
).group_by(
|
).group_by(
|
||||||
PageSession.referrer_class,
|
PageSession.referrer_class,
|
||||||
).order_by(func.count().desc()).first()
|
).order_by(func.count().desc()).first()
|
||||||
|
|
||||||
dev_rows_7d = db.query(
|
dev_rows_range = db.query(
|
||||||
PageSession.device_class, func.count().label("cnt"),
|
PageSession.device_class, func.count().label("cnt"),
|
||||||
).filter(_view_filter(cutoff_7d)).group_by(PageSession.device_class).all()
|
).filter(_view_filter(cutoff_range)).group_by(PageSession.device_class).all()
|
||||||
dev_total_7d = sum(r.cnt for r in dev_rows_7d) or 1
|
dev_total_range = sum(r.cnt for r in dev_rows_range) or 1
|
||||||
dev_split_str = " \u00b7 ".join(
|
dev_split_str = " \u00b7 ".join(
|
||||||
f"{_fmt_pct(r.cnt / dev_total_7d)} {DEVICE_LABELS.get(r.device_class, '?')}"
|
f"{_fmt_pct(r.cnt / dev_total_range)} {DEVICE_LABELS.get(r.device_class, '?')}"
|
||||||
for r in sorted(dev_rows_7d, key=lambda r: -(r.cnt or 0))
|
for r in sorted(dev_rows_range, key=lambda r: -(r.cnt or 0))
|
||||||
) or "\u2014"
|
) or "\u2014"
|
||||||
|
|
||||||
overview = {
|
overview = {
|
||||||
|
|
@ -461,41 +502,44 @@ def shop_analytics(request):
|
||||||
"device_split": dev_split_str,
|
"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(
|
views_half_expr = func.sum(case(
|
||||||
(PageSession.created_timestamp > cutoff_7d, 1), else_=0,
|
(PageSession.created_timestamp > cutoff_half, 1), else_=0,
|
||||||
))
|
|
||||||
views_14d_expr = func.sum(case(
|
|
||||||
(PageSession.created_timestamp > cutoff_14d, 1), else_=0,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
top_prods_raw = db.query(
|
top_prods_raw = db.query(
|
||||||
PageSession.product_id,
|
PageSession.product_id,
|
||||||
views_7d_expr.label("views_7d"),
|
views_half_expr.label("views_half"),
|
||||||
views_14d_expr.label("views_14d"),
|
func.count().label("views_total"),
|
||||||
func.count().label("views_21d"),
|
|
||||||
).filter(
|
).filter(
|
||||||
_view_filter(cutoff_21d),
|
_view_filter(cutoff_range),
|
||||||
).group_by(
|
).group_by(
|
||||||
PageSession.product_id,
|
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(
|
ring_raw = db.query(
|
||||||
PageSession.product_id, func.count().label("cnt"),
|
PageSession.product_id, func.count().label("cnt"),
|
||||||
).filter(
|
).filter(
|
||||||
PageSession.shop_id == shop.id,
|
PageSession.shop_id == shop.id,
|
||||||
PageSession.is_ring_entry == True, # noqa: E712
|
PageSession.is_ring_entry == True, # noqa: E712
|
||||||
PageSession.created_timestamp > cutoff_21d,
|
PageSession.created_timestamp > cutoff_range,
|
||||||
_not_owner,
|
_not_owner,
|
||||||
).group_by(PageSession.product_id).order_by(func.count().desc()).limit(42).all()
|
).group_by(PageSession.product_id).order_by(func.count().desc()).limit(42).all()
|
||||||
|
|
||||||
ring_total = db.query(func.count()).filter(
|
ring_total = db.query(func.count()).filter(
|
||||||
PageSession.shop_id == shop.id,
|
PageSession.shop_id == shop.id,
|
||||||
PageSession.is_ring_entry == True, # noqa: E712
|
PageSession.is_ring_entry == True, # noqa: E712
|
||||||
PageSession.created_timestamp > cutoff_21d,
|
PageSession.created_timestamp > cutoff_range,
|
||||||
_not_owner,
|
_not_owner,
|
||||||
).scalar() or 1
|
).scalar() or 1
|
||||||
|
|
||||||
|
|
@ -507,7 +551,7 @@ def shop_analytics(request):
|
||||||
eng_raw = db.query(
|
eng_raw = db.query(
|
||||||
PageSession.product_id, eng_expr.label("score"), func.count().label("sessions"),
|
PageSession.product_id, eng_expr.label("score"), func.count().label("sessions"),
|
||||||
).filter(
|
).filter(
|
||||||
_session_filter(cutoff_21d),
|
_session_filter(cutoff_range),
|
||||||
).group_by(
|
).group_by(
|
||||||
PageSession.product_id,
|
PageSession.product_id,
|
||||||
).having(func.count() >= MIN_SESSIONS).order_by(eng_expr.desc()).limit(7).all()
|
).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(
|
att_raw = db.query(
|
||||||
PageSession.product_id, att_expr.label("score"), func.count().label("sessions"),
|
PageSession.product_id, att_expr.label("score"), func.count().label("sessions"),
|
||||||
).filter(
|
).filter(
|
||||||
_session_filter(cutoff_21d),
|
_session_filter(cutoff_range),
|
||||||
).group_by(
|
).group_by(
|
||||||
PageSession.product_id,
|
PageSession.product_id,
|
||||||
).having(func.count() >= MIN_SESSIONS).order_by(att_expr.desc()).limit(7).all()
|
).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(
|
learn_raw = db.query(
|
||||||
PageSession.product_id, learn_avg.label("score"), func.count().label("sessions"),
|
PageSession.product_id, learn_avg.label("score"), func.count().label("sessions"),
|
||||||
).filter(
|
).filter(
|
||||||
_session_filter(cutoff_21d),
|
_session_filter(cutoff_range),
|
||||||
).group_by(
|
).group_by(
|
||||||
PageSession.product_id,
|
PageSession.product_id,
|
||||||
).having(func.count() >= MIN_SESSIONS).order_by(learn_avg.desc()).limit(7).all()
|
).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(
|
pass_raw = db.query(
|
||||||
PageSession.product_id, pass_avg.label("score"), func.count().label("sessions"),
|
PageSession.product_id, pass_avg.label("score"), func.count().label("sessions"),
|
||||||
).filter(
|
).filter(
|
||||||
_session_filter(cutoff_21d),
|
_session_filter(cutoff_range),
|
||||||
).group_by(
|
).group_by(
|
||||||
PageSession.product_id,
|
PageSession.product_id,
|
||||||
).having(func.count() >= MIN_SESSIONS).order_by(pass_avg.desc()).limit(7).all()
|
).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(
|
traffic_raw = db.query(
|
||||||
PageSession.referrer_class, func.count().label("cnt"),
|
PageSession.referrer_class, func.count().label("cnt"),
|
||||||
).filter(
|
).filter(
|
||||||
_view_filter(cutoff_21d),
|
_view_filter(cutoff_range),
|
||||||
).group_by(PageSession.referrer_class).order_by(func.count().desc()).all()
|
).group_by(PageSession.referrer_class).order_by(func.count().desc()).all()
|
||||||
traffic_total = sum(r.cnt for r in traffic_raw) or 1
|
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(
|
device_raw = db.query(
|
||||||
PageSession.device_class, func.count().label("cnt"),
|
PageSession.device_class, func.count().label("cnt"),
|
||||||
).filter(
|
).filter(
|
||||||
_view_filter(cutoff_21d),
|
_view_filter(cutoff_range),
|
||||||
).group_by(PageSession.device_class).order_by(func.count().desc()).all()
|
).group_by(PageSession.device_class).order_by(func.count().desc()).all()
|
||||||
device_total = sum(r.cnt for r in device_raw) or 1
|
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_(
|
_media_filter = and_(
|
||||||
PageSession.shop_id == shop.id,
|
PageSession.shop_id == shop.id,
|
||||||
PageSession.media_duration_ms.isnot(None),
|
PageSession.media_duration_ms.isnot(None),
|
||||||
PageSession.media_duration_ms > 0,
|
PageSession.media_duration_ms > 0,
|
||||||
PageSession.created_timestamp > cutoff_21d,
|
PageSession.created_timestamp > cutoff_range,
|
||||||
_not_owner,
|
_not_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -659,18 +703,18 @@ def shop_analytics(request):
|
||||||
func.avg(PageSession.media_percent_played).desc()
|
func.avg(PageSession.media_percent_played).desc()
|
||||||
).limit(14).all()
|
).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 = (
|
price_changes_raw = (
|
||||||
db.query(Price)
|
db.query(Price)
|
||||||
.join(Product)
|
.join(Product)
|
||||||
.filter(
|
.filter(
|
||||||
Product.shop_id == shop.id,
|
Product.shop_id == shop.id,
|
||||||
Price.created_timestamp > cutoff_21d,
|
Price.created_timestamp > cutoff_range,
|
||||||
)
|
)
|
||||||
.order_by(Price.created_timestamp.desc())
|
.order_by(Price.created_timestamp.desc())
|
||||||
.limit(21)
|
.limit(21)
|
||||||
|
|
@ -701,19 +745,22 @@ def shop_analytics(request):
|
||||||
# --- Build template-ready data ---
|
# --- Build template-ready data ---
|
||||||
|
|
||||||
top_products = []
|
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):
|
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({
|
top_products.append({
|
||||||
"rank": i + 1,
|
"rank": i + 1,
|
||||||
"title": _t(r.product_id),
|
"title": _t(r.product_id),
|
||||||
"url": _u(r.product_id),
|
"url": _u(r.product_id),
|
||||||
"analytics_url": _a(r.product_id),
|
"analytics_url": _a(r.product_id),
|
||||||
"views_7d": v7,
|
"views_total": total,
|
||||||
"views_14d": v14,
|
"views_newer": newer,
|
||||||
"views_21d": r.views_21d or 0,
|
"views_older": older,
|
||||||
"trend": "rising" if v14 > 0 and v7 > v14 / 2 else "falling",
|
"trend": "rising" if newer > older else "falling",
|
||||||
"pct_of_max": v7 / top_max_7d * 100,
|
"pct_of_max": total / top_max * 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
ring_entries = [
|
ring_entries = [
|
||||||
|
|
@ -770,17 +817,21 @@ def shop_analytics(request):
|
||||||
|
|
||||||
daily_views_max = max((b["count"] for b in daily_views), default=0)
|
daily_views_max = max((b["count"] for b in daily_views), default=0)
|
||||||
|
|
||||||
# --- Internal Search Keywords (21d) ---
|
# --- Internal Search Keywords (selected range) ---
|
||||||
top_keywords = _top_search_keywords(db, shop.id, 21, 21)
|
top_keywords = _top_search_keywords(db, shop.id, cutoff_range, 21)
|
||||||
|
|
||||||
# --- Referrer Domains & Search Queries (21d) ---
|
# --- Referrer Domains & Search Queries (selected range) ---
|
||||||
top_referrer_domains = _top_referrer_domains(db, _view_base, 21, 21)
|
top_referrer_domains = _top_referrer_domains(db, _view_base, cutoff_range, 21)
|
||||||
top_referrer_queries = _top_referrer_queries(db, _view_base, 21, 21)
|
top_referrer_queries = _top_referrer_queries(db, _view_base, cutoff_range, 21)
|
||||||
|
|
||||||
# --- Daily External Referrer Trend (28 days) ---
|
# --- External Referrer Trend (selected range, bucketed) ---
|
||||||
daily_referrers = _daily_referrer_counts(db, _view_base, 28)
|
daily_referrers = _time_referrer_counts(db, _view_base, spec)
|
||||||
|
|
||||||
return {
|
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,
|
"overview": overview,
|
||||||
"ring_size": ring_size,
|
"ring_size": ring_size,
|
||||||
"daily_views": daily_views,
|
"daily_views": daily_views,
|
||||||
|
|
@ -836,12 +887,20 @@ def product_analytics(request):
|
||||||
if not product or product.shop_id != shop.id:
|
if not product or product.shop_id != shop.id:
|
||||||
raise HTTPNotFound()
|
raise HTTPNotFound()
|
||||||
|
|
||||||
cuts = _cutoffs()
|
range_key = _range_from_request(request)
|
||||||
cutoff_7d = cuts["7d"]
|
|
||||||
cutoff_21d = cuts["21d"]
|
|
||||||
|
|
||||||
_not_owner = or_(PageSession.is_owner == None, PageSession.is_owner == False) # noqa: E711,E712
|
_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):
|
def _pf(cutoff):
|
||||||
"""Product view filter: 7s visible on this product after cutoff."""
|
"""Product view filter: 7s visible on this product after cutoff."""
|
||||||
return and_(
|
return and_(
|
||||||
|
|
@ -860,28 +919,20 @@ def product_analytics(request):
|
||||||
_not_owner,
|
_not_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-temporal view base for daily bucketing
|
daily_views = _time_buckets(db, _pf_base, spec)
|
||||||
_pf_base = and_(
|
daily_session_duration = _time_avg_duration(db, _pf_base, spec)
|
||||||
PageSession.product_id == product.id,
|
daily_engagement_data = _time_engagement(db, _pf_base, spec)
|
||||||
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)
|
|
||||||
_all_product_sessions = and_(
|
_all_product_sessions = and_(
|
||||||
PageSession.product_id == product.id,
|
PageSession.product_id == product.id,
|
||||||
_not_owner,
|
_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(
|
ov = db.query(
|
||||||
func.count().label("views"),
|
func.count().label("views"),
|
||||||
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
|
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
|
||||||
).filter(_pf(cutoff_7d)).one()
|
).filter(_pf(cutoff_range)).one()
|
||||||
|
|
||||||
bounce_q = db.query(
|
bounce_q = db.query(
|
||||||
func.count().label("total"),
|
func.count().label("total"),
|
||||||
|
|
@ -890,7 +941,7 @@ def product_analytics(request):
|
||||||
)).label("bounced"),
|
)).label("bounced"),
|
||||||
).filter(
|
).filter(
|
||||||
PageSession.product_id == product.id,
|
PageSession.product_id == product.id,
|
||||||
PageSession.created_timestamp > cutoff_7d,
|
PageSession.created_timestamp > cutoff_range,
|
||||||
_not_owner,
|
_not_owner,
|
||||||
).one()
|
).one()
|
||||||
bounce_rate = None
|
bounce_rate = None
|
||||||
|
|
@ -904,27 +955,30 @@ def product_analytics(request):
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
overview = {
|
overview = {
|
||||||
"views_7d": ov.views or 0,
|
"views_range": ov.views or 0,
|
||||||
"avg_session": _fmt_ms(ov.avg_session_ms),
|
"avg_session": _fmt_ms(ov.avg_session_ms),
|
||||||
"bounce_rate": _fmt_pct(bounce_rate),
|
"bounce_rate": _fmt_pct(bounce_rate),
|
||||||
"lifetime_views": lifetime_views,
|
"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 = {}
|
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(
|
views_over_time[label] = db.query(func.count()).filter(
|
||||||
_pf(cuts[key])
|
_pf(now_ms - days * DAY_MS)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
views_over_time["lifetime"] = lifetime_views
|
views_over_time["lifetime"] = lifetime_views
|
||||||
|
|
||||||
# --- Video Watch Metrics ---
|
# --- Video Watch Metrics (selected range) ---
|
||||||
video = None
|
video = None
|
||||||
_mf = and_(
|
_mf = and_(
|
||||||
PageSession.product_id == product.id,
|
PageSession.product_id == product.id,
|
||||||
PageSession.media_duration_ms.isnot(None),
|
PageSession.media_duration_ms.isnot(None),
|
||||||
PageSession.media_duration_ms > 0,
|
PageSession.media_duration_ms > 0,
|
||||||
PageSession.created_timestamp > cutoff_21d,
|
PageSession.created_timestamp > cutoff_range,
|
||||||
_not_owner,
|
_not_owner,
|
||||||
)
|
)
|
||||||
vagg = db.query(
|
vagg = db.query(
|
||||||
|
|
@ -956,10 +1010,10 @@ def product_analytics(request):
|
||||||
"sessions": vagg.sessions,
|
"sessions": vagg.sessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Traffic Sources (21d) ---
|
# --- Traffic Sources (selected range) ---
|
||||||
traffic_raw = db.query(
|
traffic_raw = db.query(
|
||||||
PageSession.referrer_class, func.count().label("cnt"),
|
PageSession.referrer_class, func.count().label("cnt"),
|
||||||
).filter(_pf(cutoff_21d)).group_by(
|
).filter(_pf(cutoff_range)).group_by(
|
||||||
PageSession.referrer_class,
|
PageSession.referrer_class,
|
||||||
).order_by(func.count().desc()).all()
|
).order_by(func.count().desc()).all()
|
||||||
traffic_total = sum(r.cnt for r in traffic_raw) or 1
|
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
|
for r in traffic_raw
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- Devices (21d) ---
|
# --- Devices (selected range) ---
|
||||||
device_raw = db.query(
|
device_raw = db.query(
|
||||||
PageSession.device_class, func.count().label("cnt"),
|
PageSession.device_class, func.count().label("cnt"),
|
||||||
).filter(_pf(cutoff_21d)).group_by(
|
).filter(_pf(cutoff_range)).group_by(
|
||||||
PageSession.device_class,
|
PageSession.device_class,
|
||||||
).order_by(func.count().desc()).all()
|
).order_by(func.count().desc()).all()
|
||||||
device_total = sum(r.cnt for r in device_raw) or 1
|
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
|
for r in device_raw
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- Ring Entries (21d) ---
|
# --- Ring Entries (selected range) ---
|
||||||
ring_entry_count = db.query(func.count()).filter(
|
ring_entry_count = db.query(func.count()).filter(
|
||||||
PageSession.product_id == product.id,
|
PageSession.product_id == product.id,
|
||||||
PageSession.is_ring_entry == True, # noqa: E712
|
PageSession.is_ring_entry == True, # noqa: E712
|
||||||
PageSession.created_timestamp > cutoff_21d,
|
PageSession.created_timestamp > cutoff_range,
|
||||||
_not_owner,
|
_not_owner,
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
# --- Engagement (21d) ---
|
# --- Engagement (selected range) ---
|
||||||
eng_q = db.query(
|
eng_q = db.query(
|
||||||
func.avg(
|
func.avg(
|
||||||
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
|
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_depth_max).label("avg_scroll"),
|
||||||
func.avg(PageSession.scroll_direction_changes).label("avg_dir_changes"),
|
func.avg(PageSession.scroll_direction_changes).label("avg_dir_changes"),
|
||||||
func.count().label("sessions"),
|
func.count().label("sessions"),
|
||||||
).filter(_sf(cutoff_21d)).one()
|
).filter(_sf(cutoff_range)).one()
|
||||||
|
|
||||||
engagement = None
|
engagement = None
|
||||||
if eng_q.sessions and eng_q.sessions >= 1:
|
if eng_q.sessions and eng_q.sessions >= 1:
|
||||||
|
|
@ -1023,8 +1077,8 @@ def product_analytics(request):
|
||||||
"sessions": eng_q.sessions,
|
"sessions": eng_q.sessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Comment Sentiment (21d) ---
|
# --- Comment Sentiment (selected range) ---
|
||||||
sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_21d)
|
sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_range)
|
||||||
|
|
||||||
# --- Price History (last 21 changes) ---
|
# --- Price History (last 21 changes) ---
|
||||||
price_changes_raw = (
|
price_changes_raw = (
|
||||||
|
|
@ -1047,14 +1101,18 @@ def product_analytics(request):
|
||||||
|
|
||||||
daily_views_max = max((b["count"] for b in daily_views), default=0)
|
daily_views_max = max((b["count"] for b in daily_views), default=0)
|
||||||
|
|
||||||
# --- Referrer Domains & Search Queries (21d) ---
|
# --- Referrer Domains & Search Queries (selected range) ---
|
||||||
top_referrer_domains = _top_referrer_domains(db, _pf_base, 21, 21)
|
top_referrer_domains = _top_referrer_domains(db, _pf_base, cutoff_range, 21)
|
||||||
top_referrer_queries = _top_referrer_queries(db, _pf_base, 21, 21)
|
top_referrer_queries = _top_referrer_queries(db, _pf_base, cutoff_range, 21)
|
||||||
|
|
||||||
# --- Daily External Referrer Trend (28 days) ---
|
# --- External Referrer Trend (selected range, bucketed) ---
|
||||||
daily_referrers = _daily_referrer_counts(db, _pf_base, 28)
|
daily_referrers = _time_referrer_counts(db, _pf_base, spec)
|
||||||
|
|
||||||
return {
|
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": product,
|
||||||
"product_url": product_url,
|
"product_url": product_url,
|
||||||
"overview": overview,
|
"overview": overview,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue