Video watch metrics, per-product analytics page, SPA thumbnail sync

Analytics:
- Add video watch metrics section to shop dashboard (avg % played,
  completion rate, seeks, rewinds, pauses, speed) with per-product
  video breakdown table
- Add per-product analytics detail page at /s/{shop_id}/analytics/{product_id}
  with views over time, video watch, traffic, devices, ring entries,
  engagement, comment sentiment, and price history
- Product title links on shop analytics now go to per-product analytics
- Add get_sentiment_summary_for_product() helper

SPA fix:
- Update thumbnail image src and wrapping link href during DJ fade
- Clear stale extra thumbnails from previous product
- Add file_url to watch JSON for thumbnail-to-file links
This commit is contained in:
russell@unturf.com 2026-02-11 09:03:29 -05:00
parent 2f5498a1f4
commit faefd94abf
21 changed files with 1221 additions and 27 deletions

View file

@ -1,12 +1,16 @@
# Add sentiment analysis to comments
Integrate sentiment analysis for comments to support the Love pillar:
Lightweight, self-contained feature. Comments exist, sentiment scoring doesn't.
## Scope
- Classify comments as positive/neutral/negative
- Products with >7:1 positive:negative ratio get "Love Verified" badge
- Weight thoughtful 42+ word comments 14x more than emoji-only or single-word reactions
- Feed sentiment scores into the engagement weighting system (positive comments = 7x, neutral = 1x, negative = 0x)
- Use a lightweight local model or rule-based approach to avoid external API dependency
- Use a lightweight local rule-based approach (no external API dependency)
- Store sentiment score per comment in DB
- Feed sentiment data into existing analytics dashboard (MPS-3)
Blocked by: engagement signal weighting system.
## Notes
- No longer blocked by engagement signal weighting — analytics is live (MPS-3)
- Products with high positive:negative ratio could surface in analytics
- Keep it simple: rule-based first, upgrade to ML later if needed

View file

@ -1,11 +1,14 @@
# Implement Love Gifts (voluntary tipping system)
Add voluntary tip/gift system beyond purchases:
**Decision**: Descoped. xmrchat.com already solves tipping better and accepts
more coins. If revisited, scope to internal shop owners and power shoppers or
paying members only — not a public feature.
## Original scope (archived)
- Love Gifts: Tips from 1-420 units of currency
- No platform cut on the first $0.69 (pure creator love)
- UI: Gift button on product pages and in watch mode
- Integrate with existing payment infrastructure (Stripe, PayPal, crypto)
- Track gifts in DB for engagement scoring (counts as strong positive signal)
- Update watch.js SPA to include gift button in updatePageContent()
- Server-rendered gift button for non-JS users
## Status: ON HOLD — revisit when membership tiers exist

View file

@ -1,9 +1,17 @@
# Build transparent pricing history display
Show price history for products to enforce Truth:
Price model already exists (`price_history` relationship on Product). Needs display.
- Display 14-day, 21-day, and 42-day price history on product pages
- Track all price changes in a price_history table (product_id, price, timestamp)
## Scope
- Visible to **shop owners** in settings/analytics — not public by default
- Optionally visible to paying members (future membership tier gate)
- Display price timeline on product pages (expandable section)
- No dark patterns — "was $420, now $7!" must be verifiable from history
- Simple visual: small text or expandable section showing price timeline
- If product has digital scarcity limits, enforce and display actual remaining count
## Implementation
- `price_history` relationship already on Product model — just wire up display
- Small, honest, useful: show price change dates and amounts
- Add to analytics dashboard for shop owner view
- Keep public display behind a toggle (off by default)

View file

@ -1 +1 @@
e1a8b2f
2f5498a

View file

@ -0,0 +1,98 @@
"""
Rule-based comment sentiment classifier.
No external dependencies — uses only the ``re`` module. Returns −1
(negative), 0 (neutral), or 1 (positive) for a given comment string.
"""
import re
POSITIVE_WORDS = frozenset({
"love", "great", "awesome", "amazing", "excellent", "fantastic",
"wonderful", "beautiful", "perfect", "brilliant", "outstanding",
"superb", "incredible", "magnificent", "thank", "thanks", "good",
"best", "cool", "nice", "happy", "enjoy", "enjoyed", "helpful",
"fire", "impressive", "solid", "recommend", "recommended",
"favorite", "favourite", "gem", "inspiring", "inspirational",
"masterpiece", "phenomenal", "stellar", "bravo", "wow",
"delightful", "exceptional", "glorious", "dope",
})
NEGATIVE_WORDS = frozenset({
"bad", "terrible", "awful", "horrible", "worst", "hate",
"disappointing", "disappointed", "poor", "boring", "waste",
"ugly", "broken", "useless", "annoying", "frustrating",
"mediocre", "overpriced", "scam", "trash", "garbage",
"rubbish", "dreadful", "pathetic", "lame", "sucks",
"meh", "bland", "uninspired", "weak", "confusing",
"disgusting", "offensive", "painful", "regret", "refund",
"spam", "fake", "ripoff", "avoid", "lackluster",
})
NEGATION_WORDS = frozenset({
"not", "no", "never", "neither", "nor", "nothing",
"nowhere", "hardly", "barely", "scarcely", "dont",
"doesnt", "didnt", "wasnt", "werent", "wont",
"wouldnt", "couldnt", "shouldnt", "isnt", "arent",
"cant", "cannot",
})
INTENSIFIERS = frozenset({
"very", "really", "extremely", "absolutely", "incredibly",
"totally", "completely", "utterly", "super", "so",
})
_TOKEN_RE = re.compile(r"[a-zA-Z]{2,}")
_NEGATION_WINDOW = 3
def classify_sentiment(text):
"""Classify *text* and return ``-1``, ``0``, or ``1``."""
if not text:
return 0
tokens = [t.lower() for t in _TOKEN_RE.findall(text)]
if not tokens:
return 0
score = 0.0
negation_countdown = 0
intensifier_active = False
for token in tokens:
# Strip trailing apostrophe contractions already handled by regex
clean = token.replace("'", "")
if clean in NEGATION_WORDS:
negation_countdown = _NEGATION_WINDOW
continue
if clean in INTENSIFIERS:
intensifier_active = True
continue
weight = 0.0
if clean in POSITIVE_WORDS:
weight = 1.0
elif clean in NEGATIVE_WORDS:
weight = -1.0
if weight != 0.0:
if intensifier_active:
weight *= 1.5
if negation_countdown > 0:
weight = -weight
score += weight
intensifier_active = False
if negation_countdown > 0:
negation_countdown -= 1
# Normalize by token count to avoid bias toward long comments
normalized = score / len(tokens)
if normalized > 0.04:
return 1
elif normalized < -0.04:
return -1
return 0

View file

@ -26,6 +26,7 @@ from make_post_sell.lib.time_funcs import (
)
from make_post_sell.lib.render import markdown_to_html
from make_post_sell.lib.sentiment import classify_sentiment
from .meta import Base, RBase
from .meta import UUIDType
@ -82,6 +83,9 @@ class Comment(RBase, Base):
# by default comments are approved. unless shop requires approval.
approved = Column(Boolean, default=True)
# sentiment: -1 = negative, 0 = neutral, 1 = positive, None = unscored
sentiment = Column(Integer, default=None)
ip_address = Column(Unicode(45), default=None)
# lazy='joined' performs a left join to reduce queries, it's magic.
@ -190,6 +194,22 @@ class Comment(RBase, Base):
return True
return False
@property
def human_sentiment(self):
if self.sentiment == 1:
return "positive"
elif self.sentiment == -1:
return "negative"
return "neutral"
@property
def sentiment_icon(self):
if self.sentiment == 1:
return "+"
elif self.sentiment == -1:
return "-"
return "~"
def set_data(self, data):
"""Set comment data and generate HTML."""
self.data = data
@ -197,8 +217,10 @@ class Comment(RBase, Base):
# Pass shop context if available through product relationship
shop = self.product.shop if self.product else None
self.data_html = markdown_to_html(data, shop)
self.sentiment = classify_sentiment(data)
else:
self.data_html = None
self.sentiment = 0
self.updated_timestamp = now_timestamp()
def stamp_updated_timestamp(self):
@ -330,3 +352,66 @@ def get_total_comment_count_for_product(dbsession, product_id, shop=None, user=N
)
return query.count()
def get_sentiment_summary_for_shop(dbsession, shop_id, cutoff_ms):
"""Return counts of positive/neutral/negative comments since *cutoff_ms*.
Returns a dict ``{"positive": int, "neutral": int, "negative": int, "total": int}``.
"""
from .product import Product
rows = (
dbsession.query(Comment.sentiment, func.count().label("cnt"))
.join(Product)
.filter(
Product.shop_id == shop_id,
Comment.disabled == False,
Comment.approved == True,
Comment.sentiment.isnot(None),
Comment.created_timestamp > cutoff_ms,
)
.group_by(Comment.sentiment)
.all()
)
summary = {"positive": 0, "neutral": 0, "negative": 0, "total": 0}
for sentiment_val, cnt in rows:
if sentiment_val == 1:
summary["positive"] = cnt
elif sentiment_val == -1:
summary["negative"] = cnt
else:
summary["neutral"] = cnt
summary["total"] += cnt
return summary
def get_sentiment_summary_for_product(dbsession, product_id, cutoff_ms):
"""Return counts of positive/neutral/negative comments for a product since *cutoff_ms*.
Returns a dict ``{"positive": int, "neutral": int, "negative": int, "total": int}``.
"""
rows = (
dbsession.query(Comment.sentiment, func.count().label("cnt"))
.filter(
Comment.product_id == product_id,
Comment.disabled == False,
Comment.approved == True,
Comment.sentiment.isnot(None),
Comment.created_timestamp > cutoff_ms,
)
.group_by(Comment.sentiment)
.all()
)
summary = {"positive": 0, "neutral": 0, "negative": 0, "total": 0}
for sentiment_val, cnt in rows:
if sentiment_val == 1:
summary["positive"] = cnt
elif sentiment_val == -1:
summary["negative"] = cnt
else:
summary["neutral"] = cnt
summary["total"] += cnt
return summary

View file

@ -123,6 +123,7 @@ def includeme(config):
config.add_route("shop_sales", "/s/{shop_id}/sales")
config.add_route("shop_comments", "/s/{shop_id}/comments")
config.add_route("shop_analytics", "/s/{shop_id}/analytics")
config.add_route("product_analytics", "/s/{shop_id}/analytics/{product_id}")
config.add_route("shop_settings", "/s/{shop_id}/settings")
config.add_route(

View file

@ -0,0 +1,52 @@
"""add sentiment column to comment
Revision ID: 3c0996ae20f3
Revises: e620bf529ed9
Create Date: 2026-02-11 06:43:24.008752
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3c0996ae20f3'
down_revision = 'e620bf529ed9'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_comment", "sentiment"):
op.add_column(
"mps_comment",
sa.Column("sentiment", sa.Integer(), nullable=True),
)
# Backfill existing comments
from make_post_sell.lib.sentiment import classify_sentiment
conn = op.get_bind()
rows = conn.execute(
sa.text("SELECT id, data FROM mps_comment WHERE sentiment IS NULL AND data IS NOT NULL")
).fetchall()
for row in rows:
comment_id, data = row
sentiment = classify_sentiment(data)
conn.execute(
sa.text("UPDATE mps_comment SET sentiment = :s WHERE id = :id"),
{"s": sentiment, "id": comment_id},
)
def downgrade():
op.drop_column("mps_comment", "sentiment")

View file

@ -2721,12 +2721,18 @@ textarea {
.ring-header-controls {
display: grid;
grid-template-columns: auto auto;
grid-template-columns: auto auto auto;
gap: 16px;
justify-content: end;
align-items: center;
}
.skip-next-btn {
font-size: 0.75em;
padding: 4px 10px;
cursor: pointer;
}
.related-content-header h3 {
margin: 0;
font-size: 0.85em;
@ -2811,6 +2817,9 @@ textarea {
.related-content-row-current .related-content-index {
color: white;
}
.related-content-row-current .queue-add-btn {
color: white;
}
.related-content-now-playing {
font-weight: bold;
}
@ -3291,3 +3300,7 @@ textarea {
grid-template-columns: 1fr 1fr;
}
}
.price-history-current td {
font-weight: bold;
}

View file

@ -20,6 +20,9 @@
var djCrossfadeData = null;
var preloadingId = null;
var currentProductId = null;
var currentCanonicalUrl = null;
var currentProductMediaType = null;
var currentThumbnailUrl = null;
var urlRefreshTimer = null;
var URL_REFRESH_MS = 7 * 60 * 1000; // 7 minutes
var PROGRESS_KEY = 'mps_watch_progress';
@ -537,6 +540,9 @@
}
if (data.is_mod) isMod = true;
currentProductId = data.product_id;
currentCanonicalUrl = data.canonical_url || null;
currentProductMediaType = data.media_type || null;
currentThumbnailUrl = data.thumbnail_url || null;
markWatched(data.product_id);
syncRingPosition(data.product_id);
scheduleUrlRefresh();
@ -581,6 +587,26 @@
if (dlWell) dlWell.style.display = 'none';
}
// Update non-media product-main images (audio cover art, thumbnail-to-file links)
var mainImgs = document.querySelectorAll('img.product-main');
for (var mi = 0; mi < mainImgs.length; mi++) {
if (data.thumbnail_url) {
mainImgs[mi].src = data.thumbnail_url;
}
var parentLink = mainImgs[mi].closest('a');
if (parentLink && data.file_url) {
parentLink.href = data.file_url;
}
}
// Clear stale extra thumbnails from previous product
var staleThumbs = document.querySelectorAll('.product-thumbnail');
for (var st = 0; st < staleThumbs.length; st++) {
var thumbLink = staleThumbs[st].closest('a');
if (thumbLink) thumbLink.remove();
else staleThumbs[st].remove();
}
// Update file type/size info
var descDiv = document.querySelector('.product-description');
if (descDiv) {
@ -672,6 +698,7 @@
+ '<span id="ring-progress" class="ring-progress"></span>'
+ '</div>'
+ '<div class="ring-header-controls">'
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>'
+ '<label class="watch-direction-label js-only">'
+ '<span class="direction-label-text">Reverse</span>'
+ '<input type="checkbox" id="watch-direction-toggle"' + directionChecked + ' />'
@ -715,6 +742,7 @@
}
html += '<span class="related-content-title">' + escapeHtml(currentTitle) + '</span>';
html += '</span>';
html += '<button class="queue-add-btn js-only" data-product-id="' + currentProductId + '" data-title="' + escapeHtml(currentTitle) + '" data-url="' + (currentCanonicalUrl || '') + '" data-media-type="' + (currentProductMediaType || '') + '" data-thumbnail="' + (currentThumb || '') + '" title="Add to queue">+</button>';
html += '</div>';
}
@ -805,6 +833,47 @@
};
}
// --- Skip to next unwatched ring item ---
function getNextUnwatchedItem() {
if (!ringProductIds.length) return getNextItem();
// Walk forward through the ring, skipping watched items
for (var i = 1; i < ringProductIds.length; i++) {
var pos = (ringPosition + i * ringDirection + ringProductIds.length * ringProductIds.length) % ringProductIds.length;
var id = ringProductIds[pos];
if (!isWatched(id)) {
var el = document.querySelector('[data-watch-id="' + id + '"]');
return {
id: id,
title: el ? (el.querySelector('span') ? el.querySelector('span').textContent : '') : '',
thumbnail_url: el ? (el.querySelector('img') ? el.querySelector('img').src : null) : null,
url: el ? el.getAttribute('href') : '/c/' + id
};
}
}
// All items watched — fall back to regular next
return getNextItem();
}
function skipToNextUnwatched() {
if (djCrossfadeActive) cancelDjCrossfade();
cancelCountdown();
if (staticTimer) { clearTimeout(staticTimer); staticTimer = null; }
var item = getNextUnwatchedItem();
if (!item || !item.id) return;
// Advance ring position to the target item
var idx = ringProductIds.indexOf(item.id);
if (idx !== -1) {
ringPosition = idx;
saveRingState();
}
fetchAndNavigate(item.id);
}
function showCountdownOverlay(next) {
var overlay = document.getElementById('watch-countdown');
if (!overlay) return;
@ -1370,6 +1439,12 @@
return;
}
if (e.target.id === 'skip-next-btn' || e.target.closest('#skip-next-btn')) {
e.preventDefault();
skipToNextUnwatched();
return;
}
var link = e.target.closest('.related-content-item[data-watch-id]');
if (link) {
e.preventDefault();

View file

@ -83,7 +83,7 @@
{% for p in top_products %}
<tr>
<td>{{ p.rank }}</td>
<td><a href="{{ p.url }}">{{ p.title }}</a></td>
<td><a href="{{ p.analytics_url }}">{{ p.title }}</a></td>
<td class="analytics-num">{{ p.views_7d }}</td>
<td class="analytics-num">{{ p.views_14d }}</td>
<td class="analytics-num">{{ p.views_21d }}</td>
@ -113,7 +113,7 @@
{% for r in ring_entries %}
<tr>
<td>{{ r.rank }}</td>
<td><a href="{{ r.url }}">{{ r.title }}</a></td>
<td><a href="{{ r.analytics_url }}">{{ r.title }}</a></td>
<td class="analytics-num">{{ r.count }}</td>
<td class="analytics-num">{{ r.pct }}</td>
</tr>
@ -143,7 +143,7 @@
{% for e in engagement %}
<tr>
<td>{{ e.rank }}</td>
<td><a href="{{ e.url }}">{{ e.title }}</a></td>
<td><a href="{{ e.analytics_url }}">{{ e.title }}</a></td>
<td class="analytics-num">{{ e.score }}</td>
<td class="analytics-num">{{ e.sessions }}</td>
</tr>
@ -170,7 +170,7 @@
{% for a in attention %}
<tr>
<td>{{ a.rank }}</td>
<td><a href="{{ a.url }}">{{ a.title }}</a></td>
<td><a href="{{ a.analytics_url }}">{{ a.title }}</a></td>
<td class="analytics-num">{{ a.score }}</td>
<td class="analytics-num">{{ a.sessions }}</td>
</tr>
@ -202,7 +202,7 @@
{% for l in learning %}
<tr>
<td>{{ l.rank }}</td>
<td><a href="{{ l.url }}">{{ l.title }}</a></td>
<td><a href="{{ l.analytics_url }}">{{ l.title }}</a></td>
<td class="analytics-num">{{ l.score }}</td>
<td class="analytics-num">{{ l.sessions }}</td>
</tr>
@ -229,7 +229,7 @@
{% for b in passive %}
<tr>
<td>{{ b.rank }}</td>
<td><a href="{{ b.url }}">{{ b.title }}</a></td>
<td><a href="{{ b.analytics_url }}">{{ b.title }}</a></td>
<td class="analytics-num">{{ b.score }}</td>
<td class="analytics-num">{{ b.sessions }}</td>
</tr>
@ -241,6 +241,71 @@
</div>
{% endif %}
{# --- Video Watch Metrics (21d) --- #}
{% if video_overview %}
<h4>Video Watch Metrics (last 21 days)</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">
<span class="analytics-overview-value">{{ video_overview.avg_pct_played }}</span>
<span class="analytics-overview-label">Avg % Played</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video_overview.completion_rate }}</span>
<span class="analytics-overview-label">Completion Rate</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video_overview.avg_seeks }}</span>
<span class="analytics-overview-label">Avg Seeks</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video_overview.avg_rewinds }}</span>
<span class="analytics-overview-label">Avg Rewinds</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video_overview.avg_pauses }}</span>
<span class="analytics-overview-label">Avg Pauses</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video_overview.avg_speed }}</span>
<span class="analytics-overview-label">Avg Speed</span>
</div>
</div>
{% if video_products %}
<h4>Per-Product Video Breakdown</h4>
<p class="analytics-hint">Top 14 by average % played (min {{ video_products[0].sessions if video_products else 7 }} sessions).</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th class="analytics-num">Avg %</th>
<th class="analytics-num">Completed</th>
<th class="analytics-num">Seeks</th>
<th class="analytics-num">Pauses</th>
<th class="analytics-num">Sessions</th>
</tr>
</thead>
<tbody>
{% for v in video_products %}
<tr>
<td>{{ v.rank }}</td>
<td><a href="{{ v.analytics_url }}">{{ v.title }}</a></td>
<td class="analytics-num">{{ v.avg_pct }}</td>
<td class="analytics-num">{{ v.completion_rate }}</td>
<td class="analytics-num">{{ v.avg_seeks }}</td>
<td class="analytics-num">{{ v.avg_pauses }}</td>
<td class="analytics-num">{{ v.sessions }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
{# --- Sections 8 & 9: Traffic Sources & Device Split (side by side) --- #}
{% if traffic or devices %}
<div class="analytics-pair">
@ -296,6 +361,54 @@
{% endif %}{# end overview.total_views check #}
{# --- Comment Sentiment (21d) --- #}
{% if sentiment and sentiment.total > 0 %}
<h4>Comment Sentiment (last 21 days)</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
<span class="analytics-overview-label">Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%)</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.neutral }}</span>
<span class="analytics-overview-label">Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%)</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.negative }}</span>
<span class="analytics-overview-label">Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%)</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.total }}</span>
<span class="analytics-overview-label">Total</span>
</div>
</div>
{% endif %}
{# --- Recent Price Changes (21d) --- #}
{% if price_changes %}
<h4>Recent Price Changes (last 21 days)</h4>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>Product</th>
<th class="analytics-num">Price</th>
<th>When</th>
</tr>
</thead>
<tbody>
{% for pc in price_changes %}
<tr>
<td><a href="{{ pc.analytics_url }}">{{ pc.title }}</a></td>
<td class="analytics-num">{{ pc.price }}</td>
<td>{{ pc.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<p class="analytics-privacy">All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.</p>
</section>

View file

@ -0,0 +1,238 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>Analytics - {{ product.title }} - {{ request.shop.name }}</title>
{%- endblock append_to_head_tag_section -%}
{% block content -%}
<section class="analytics-page">
<p><a href="/s/{{ request.shop.uuid_str }}/analytics">&larr; Shop Analytics</a> &middot; <a href="{{ product_url }}">View Product</a></p>
<h3>{{ product.title }}</h3>
{# --- 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>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.avg_session }}</span>
<span class="analytics-overview-label">Avg Session</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.bounce_rate }}</span>
<span class="analytics-overview-label">Bounce Rate</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ overview.lifetime_views }}</span>
<span class="analytics-overview-label">Lifetime Views</span>
</div>
</div>
{# --- Views Over Time --- #}
<h4>Views Over Time</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ views_over_time['7d'] }}</span>
<span class="analytics-overview-label">7 days</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ views_over_time['14d'] }}</span>
<span class="analytics-overview-label">14 days</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ views_over_time['21d'] }}</span>
<span class="analytics-overview-label">21 days</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ views_over_time['28d'] }}</span>
<span class="analytics-overview-label">28 days</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ views_over_time['365d'] }}</span>
<span class="analytics-overview-label">Year</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ views_over_time['lifetime'] }}</span>
<span class="analytics-overview-label">Lifetime</span>
</div>
</div>
{# --- Video Watch Metrics --- #}
{% if video %}
<h4>Video Watch Metrics (last 21 days)</h4>
<p class="analytics-hint">Based on {{ video.sessions }} media sessions.</p>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.avg_pct_played }}</span>
<span class="analytics-overview-label">Avg % Played</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.completion_rate }}</span>
<span class="analytics-overview-label">Completion Rate</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.avg_seeks }}</span>
<span class="analytics-overview-label">Avg Seeks</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.avg_rewinds }}</span>
<span class="analytics-overview-label">Avg Rewinds</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.avg_pauses }}</span>
<span class="analytics-overview-label">Avg Pauses</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.avg_speed }}</span>
<span class="analytics-overview-label">Avg Speed</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ video.play_duration_ratio }}</span>
<span class="analytics-overview-label">Play/Duration</span>
</div>
</div>
{% endif %}
{# --- Traffic Sources & Devices (side by side) --- #}
{% if traffic or devices %}
<div class="analytics-pair">
{% if traffic %}
<div>
<h4>Traffic Sources (last 21 days)</h4>
<table class="analytics-table">
<thead>
<tr>
<th>Source</th>
<th class="analytics-num">Sessions</th>
<th class="analytics-num">%</th>
</tr>
</thead>
<tbody>
{% for t in traffic %}
<tr>
<td>{{ t.source }}</td>
<td class="analytics-num">{{ t.count }}</td>
<td class="analytics-num">{{ t.pct }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if devices %}
<div>
<h4>Device Split (last 21 days)</h4>
<table class="analytics-table">
<thead>
<tr>
<th>Device</th>
<th class="analytics-num">Sessions</th>
<th class="analytics-num">%</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td>{{ d.device }}</td>
<td class="analytics-num">{{ d.count }}</td>
<td class="analytics-num">{{ d.pct }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endif %}
{# --- Ring Entries --- #}
{% if ring_entry_count > 0 %}
<h4>Ring Entry Sessions (last 21 days)</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ ring_entry_count }}</span>
<span class="analytics-overview-label">Ring Entries</span>
</div>
</div>
{% endif %}
{# --- Engagement --- #}
{% if engagement %}
<h4>Engagement (last 21 days)</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">
<span class="analytics-overview-value">{{ engagement.active_ratio }}</span>
<span class="analytics-overview-label">Active / Wall</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ engagement.visible_ratio }}</span>
<span class="analytics-overview-label">Visible / Wall</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ engagement.avg_scroll_depth }}</span>
<span class="analytics-overview-label">Avg Scroll Depth</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ engagement.avg_direction_changes }}</span>
<span class="analytics-overview-label">Direction Changes</span>
</div>
</div>
{% endif %}
{# --- Comment Sentiment --- #}
{% if sentiment and sentiment.total > 0 %}
<h4>Comment Sentiment (last 21 days)</h4>
<div class="analytics-overview well">
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.positive }}</span>
<span class="analytics-overview-label">Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%)</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.neutral }}</span>
<span class="analytics-overview-label">Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%)</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.negative }}</span>
<span class="analytics-overview-label">Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%)</span>
</div>
<div class="analytics-overview-item">
<span class="analytics-overview-value">{{ sentiment.total }}</span>
<span class="analytics-overview-label">Total</span>
</div>
</div>
{% endif %}
{# --- Price History --- #}
{% if price_changes %}
<h4>Price History (last 21 changes)</h4>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th class="analytics-num">Price</th>
<th>When</th>
</tr>
</thead>
<tbody>
{% for pc in price_changes %}
<tr>
<td class="analytics-num">{{ pc.price }}</td>
<td>{{ pc.ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<p class="analytics-privacy">All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.</p>
</section>
{%- endblock -%}

View file

@ -250,6 +250,32 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
</form>
</section>
{% if price_history | length > 1 %}
<section class="price-history well2">
<h3>Price History</h3>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>Price</th>
<th>When</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{% for ph in price_history %}
<tr{% if ph.is_current %} class="price-history-current"{% endif %}>
<td>{{ ph.price_formatted }}</td>
<td>{{ ph.ago }}</td>
<td>{{ ph.datetime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
</div>
{%- endblock -%}

View file

@ -19,6 +19,7 @@
<th>Product</th>
<th>Author</th>
<th>Comment</th>
<th>Tone</th>
<th class="hide-on-mobile">Date</th>
<th>Status</th>
<th>Actions</th>
@ -30,6 +31,7 @@
<td><a href="{{ comment.product.absolute_url(request) }}">{{ comment.product.title | truncate(30) }}</a></td>
<td>{{ comment.user.name if comment.user else "Anonymous" }}</td>
<td>{{ comment.data | truncate(80) }}</td>
<td>{{ comment.sentiment_icon }}</td>
<td class="hide-on-mobile">{{ comment.ago_string }}</td>
<td>
{% if comment.disabled %}

View file

@ -9,6 +9,7 @@
</div>
{% if request.shop.watch_mode_enabled %}
<div class="ring-header-controls">
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>
<label class="watch-direction-label js-only">
<span class="direction-label-text">Reverse</span>
<input type="checkbox" id="watch-direction-toggle" />
@ -41,6 +42,12 @@
{% endif %}
<span class="related-content-title">{{ product.title }}</span>
</span>
<button class="queue-add-btn js-only"
data-product-id="{{ product.id }}"
data-title="{{ product.title }}"
data-url="{{ product.absolute_url(request) }}"
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
title="Add to queue">+</button>
</div>
{% endif %}
<div class="related-content-row{% if offset < 0 %} related-content-row-prev{% endif %}">

View file

@ -2295,6 +2295,10 @@ class AuthenticatedFunctionalTests(FunctionalTests):
self.assertIsNone(data["parent_id"])
self.assertIn("AJAX comment", data["data_html"])
# Sentiment field is present and is an integer
self.assertIn("sentiment", data)
self.assertIsInstance(data["sentiment"], int)
@patch("smtplib.SMTP")
def test_non_ajax_comment_returns_redirect(self, mock_smtp):
"""Regular POST to /comments/new still returns 302 redirect."""
@ -3493,3 +3497,75 @@ class TestAnalytics(AuthenticatedFunctionalTests):
products = get_all_products(self.dbsession).all()
product = products[0]
return shop, product
@patch("smtplib.SMTP")
def test_ajax_comment_sentiment_positive(self, mock_smtp):
"""AJAX comment with positive text returns sentiment=1."""
shop, product = self._create_shop_and_product_for_comments()
res = self.testapp.post(
"/comments/new",
{
"product_id": str(product.id),
"parent_id": "",
"data": "This is amazing and wonderful!",
},
headers={"X-Requested-With": "XMLHttpRequest"},
status=201,
)
data = res.json
self.assertEqual(data["sentiment"], 1)
@patch("smtplib.SMTP")
def test_ajax_comment_sentiment_negative(self, mock_smtp):
"""AJAX comment with negative text returns sentiment=-1."""
shop, product = self._create_shop_and_product_for_comments()
res = self.testapp.post(
"/comments/new",
{
"product_id": str(product.id),
"parent_id": "",
"data": "Terrible product, total waste",
},
headers={"X-Requested-With": "XMLHttpRequest"},
status=201,
)
data = res.json
self.assertEqual(data["sentiment"], -1)
@patch("smtplib.SMTP")
def test_price_history_on_edit_page(self, mock_smtp):
"""Price history table shows on product edit page after a price change."""
shop = self._create_shop_helper()
# Create a product with initial price
res = self.testapp.post(
f"/p/new?shop_id={shop.id}",
self.product1_params,
)
res = res.follow()
products = get_all_products(self.dbsession).all()
product = products[0]
# Change the price (use 20.00 to avoid float precision issues)
self.testapp.post(
f"/p/{product.id}/edit",
{
"title": product.title,
"description": product.description,
"price": "20.00",
"visibility": str(product.visibility),
},
status=302,
)
# Visit the edit page and check for price history
res = self.testapp.get(f"/p/{product.id}/edit", status=200)
body = res.body.decode()
self.assertIn("Price History", body)
self.assertIn("$20.00", body)
self.assertIn("$3.50", body)

View file

@ -3258,3 +3258,51 @@ class TestAnalyticsHelpers(unittest.TestCase):
self.assertEqual(len(DEVICE_LABELS), 3)
self.assertEqual(DEVICE_LABELS[0], "Mobile")
self.assertEqual(DEVICE_LABELS[2], "Desktop")
class TestSentiment(unittest.TestCase):
"""Unit tests for the rule-based comment sentiment classifier."""
def _classify(self, text):
from ..lib.sentiment import classify_sentiment
return classify_sentiment(text)
def test_positive_comment(self):
self.assertEqual(self._classify("This is amazing and wonderful!"), 1)
def test_negative_comment(self):
self.assertEqual(self._classify("Terrible product, total waste"), -1)
def test_neutral_comment(self):
self.assertEqual(self._classify("I downloaded the file yesterday"), 0)
def test_empty_string(self):
self.assertEqual(self._classify(""), 0)
def test_none_input(self):
self.assertEqual(self._classify(None), 0)
def test_negation_flips_positive(self):
self.assertEqual(self._classify("not good"), -1)
def test_negation_flips_negative(self):
self.assertEqual(self._classify("not bad"), 1)
def test_intensifier_boosts_score(self):
# "really great" should still be positive
self.assertEqual(self._classify("really great"), 1)
# "very terrible" should still be negative
self.assertEqual(self._classify("very terrible"), -1)
def test_mixed_sentiment_leans_neutral(self):
# Roughly equal positive and negative words
self.assertEqual(self._classify("good but also bad"), 0)
def test_only_punctuation_and_numbers(self):
self.assertEqual(self._classify("123 !!! ???"), 0)
def test_short_positive(self):
self.assertEqual(self._classify("love it"), 1)
def test_short_negative(self):
self.assertEqual(self._classify("hate it"), -1)

View file

@ -4,7 +4,11 @@ from sqlalchemy import func, case, and_, cast, Float
from . import shop_editor_required
from ..models.page_session import PageSession
from ..models.product import Product
from ..models.meta import now_timestamp
from ..models.price import Price
from ..models.comment import get_sentiment_summary_for_shop, get_sentiment_summary_for_product
from ..models.meta import now_timestamp, get_object_by_id
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
from ..lib.currency import cents_to_dollars
REFERRER_LABELS = {0: "Direct", 1: "Search", 2: "Social", 3: "Internal", 4: "Unknown"}
@ -27,7 +31,7 @@ def _cutoffs():
def _product_titles(dbsession, product_ids):
"""Batch-fetch product titles and URLs for a set of product IDs."""
"""Batch-fetch product titles, URLs, and IDs for a set of product IDs."""
if not product_ids:
return {}
products = (
@ -39,6 +43,7 @@ def _product_titles(dbsession, product_ids):
p.id: {
"title": p.title,
"url": f"/{'p' if p.is_sellable else 'c'}/{p.id}/{p.slug}",
"uuid_str": p.uuid_str,
}
for p in products
}
@ -300,12 +305,81 @@ def shop_analytics(request):
).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) ---
_media_filter = and_(
PageSession.shop_id == shop.id,
PageSession.media_duration_ms.isnot(None),
PageSession.media_duration_ms > 0,
PageSession.created_timestamp > cutoff_21d,
)
video_agg = db.query(
func.avg(PageSession.media_percent_played).label("avg_pct"),
func.avg(
case((PageSession.media_completed == True, 1.0), else_=0.0) # noqa: E712
).label("completion_rate"),
func.avg(PageSession.media_seek_count).label("avg_seeks"),
func.avg(PageSession.media_seek_back_count).label("avg_rewinds"),
func.avg(PageSession.media_pause_count).label("avg_pauses"),
func.avg(PageSession.media_speed).label("avg_speed"),
func.count().label("total_sessions"),
).filter(_media_filter).one()
video_overview = None
if video_agg.total_sessions and video_agg.total_sessions >= 1:
video_overview = {
"avg_pct_played": _fmt_pct(video_agg.avg_pct),
"completion_rate": _fmt_pct(video_agg.completion_rate),
"avg_seeks": f"{video_agg.avg_seeks:.1f}" if video_agg.avg_seeks else "\u2014",
"avg_rewinds": f"{video_agg.avg_rewinds:.1f}" if video_agg.avg_rewinds else "\u2014",
"avg_pauses": f"{video_agg.avg_pauses:.1f}" if video_agg.avg_pauses else "\u2014",
"avg_speed": f"{video_agg.avg_speed:.2f}x" if video_agg.avg_speed else "\u2014",
"total_sessions": video_agg.total_sessions,
}
# Per-product video breakdown (top 14 by avg % played, min 7 sessions)
video_prod_raw = db.query(
PageSession.product_id,
func.avg(PageSession.media_percent_played).label("avg_pct"),
func.avg(
case((PageSession.media_completed == True, 1.0), else_=0.0) # noqa: E712
).label("completion_rate"),
func.avg(PageSession.media_seek_count).label("avg_seeks"),
func.avg(PageSession.media_pause_count).label("avg_pauses"),
func.count().label("sessions"),
).filter(_media_filter).group_by(
PageSession.product_id,
).having(func.count() >= MIN_SESSIONS).order_by(
func.avg(PageSession.media_percent_played).desc()
).limit(14).all()
# --- Comment Sentiment (21d) ---
sentiment = get_sentiment_summary_for_shop(db, shop.id, cutoff_21d)
# --- Recent Price Changes (21d) ---
price_changes_raw = (
db.query(Price)
.join(Product)
.filter(
Product.shop_id == shop.id,
Price.created_timestamp > cutoff_21d,
)
.order_by(Price.created_timestamp.desc())
.limit(21)
.all()
)
# --- Resolve all product titles in one batch ---
all_pids = set()
for rows in (top_prods_raw, ring_raw, eng_raw, att_raw, learn_raw, pass_raw):
for rows in (top_prods_raw, ring_raw, eng_raw, att_raw, learn_raw, pass_raw, video_prod_raw):
for r in rows:
all_pids.add(r.product_id)
for pc in price_changes_raw:
all_pids.add(pc.product_id)
titles = _product_titles(db, list(all_pids))
def _t(pid):
@ -314,6 +388,11 @@ def shop_analytics(request):
def _u(pid):
return titles.get(pid, {}).get("url", "#")
def _a(pid):
"""Analytics URL for a product."""
uid = titles.get(pid, {}).get("uuid_str", "")
return f"/s/{shop.uuid_str}/analytics/{uid}" if uid else "#"
# --- Build template-ready data ---
top_products = []
@ -323,6 +402,7 @@ def shop_analytics(request):
"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,
@ -334,6 +414,7 @@ def shop_analytics(request):
"rank": i + 1,
"title": _t(r.product_id),
"url": _u(r.product_id),
"analytics_url": _a(r.product_id),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / ring_total),
}
@ -346,12 +427,40 @@ def shop_analytics(request):
"rank": i + 1,
"title": _t(r.product_id),
"url": _u(r.product_id),
"analytics_url": _a(r.product_id),
"score": score_fmt(r.score),
"sessions": r.sessions,
}
for i, r in enumerate(raw)
]
video_products = [
{
"rank": i + 1,
"title": _t(r.product_id),
"url": _u(r.product_id),
"analytics_url": _a(r.product_id),
"avg_pct": _fmt_pct(r.avg_pct),
"completion_rate": _fmt_pct(r.completion_rate),
"avg_seeks": f"{r.avg_seeks:.1f}" if r.avg_seeks else "\u2014",
"avg_pauses": f"{r.avg_pauses:.1f}" if r.avg_pauses else "\u2014",
"sessions": r.sessions,
}
for i, r in enumerate(video_prod_raw)
]
price_changes = [
{
"title": _t(pc.product_id),
"url": _u(pc.product_id),
"analytics_url": _a(pc.product_id),
"price": f"${cents_to_dollars(pc.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(pc.created_timestamp),
"datetime": str(timestamp_to_datetime(pc.created_timestamp)),
}
for pc in price_changes_raw
]
return {
"overview": overview,
"ring_size": ring_size,
@ -361,6 +470,10 @@ def shop_analytics(request):
"attention": _ranked(att_raw, _fmt_pct),
"learning": _ranked(learn_raw, _fmt_score),
"passive": _ranked(pass_raw, _fmt_score),
"video_overview": video_overview,
"video_products": video_products,
"sentiment": sentiment,
"price_changes": price_changes,
"traffic": [
{
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
@ -378,3 +491,211 @@ def shop_analytics(request):
for r in device_raw
],
}
@view_config(route_name="product_analytics", renderer="analytics_product.j2")
@shop_editor_required()
def product_analytics(request):
from pyramid.httpexceptions import HTTPNotFound
shop = request.shop
db = request.dbsession
product = get_object_by_id(db, request.matchdict["product_id"], Product)
if not product or product.shop_id != shop.id:
raise HTTPNotFound()
cuts = _cutoffs()
cutoff_7d = cuts["7d"]
cutoff_21d = cuts["21d"]
def _pf(cutoff):
"""Product view filter: 7s visible on this product after cutoff."""
return and_(
PageSession.product_id == product.id,
PageSession.visible_ms >= 7000,
PageSession.created_timestamp > cutoff,
)
def _sf(cutoff):
"""Product session filter: 7s wall clock after cutoff."""
return and_(
PageSession.product_id == product.id,
PageSession.wall_clock_ms >= 7000,
PageSession.created_timestamp > cutoff,
)
# --- Overview ---
ov = db.query(
func.count().label("views"),
func.avg(PageSession.wall_clock_ms).label("avg_session_ms"),
).filter(_pf(cutoff_7d)).one()
bounce_q = db.query(
func.count().label("total"),
func.sum(case(
(PageSession.wall_clock_ms < 7000, 1), else_=0,
)).label("bounced"),
).filter(
PageSession.product_id == product.id,
PageSession.created_timestamp > cutoff_7d,
).one()
bounce_rate = None
if bounce_q.total and bounce_q.total > 0:
bounce_rate = (bounce_q.bounced or 0) / bounce_q.total
lifetime_views = db.query(func.count()).filter(
PageSession.product_id == product.id,
PageSession.visible_ms >= 7000,
).scalar() or 0
overview = {
"views_7d": ov.views or 0,
"avg_session": _fmt_ms(ov.avg_session_ms),
"bounce_rate": _fmt_pct(bounce_rate),
"lifetime_views": lifetime_views,
}
# --- Views over time ---
views_over_time = {}
for label, key in [("7d", "7d"), ("14d", "14d"), ("21d", "21d"), ("28d", "28d"), ("365d", "365d")]:
views_over_time[label] = db.query(func.count()).filter(
_pf(cuts[key])
).scalar() or 0
views_over_time["lifetime"] = lifetime_views
# --- Video Watch Metrics ---
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,
)
vagg = db.query(
func.avg(PageSession.media_percent_played).label("avg_pct"),
func.avg(
case((PageSession.media_completed == True, 1.0), else_=0.0) # noqa: E712
).label("completion_rate"),
func.avg(PageSession.media_seek_count).label("avg_seeks"),
func.avg(PageSession.media_seek_back_count).label("avg_rewinds"),
func.avg(PageSession.media_pause_count).label("avg_pauses"),
func.avg(PageSession.media_speed).label("avg_speed"),
func.avg(PageSession.media_play_ms).label("avg_play_ms"),
func.avg(PageSession.media_duration_ms).label("avg_duration_ms"),
func.count().label("sessions"),
).filter(_mf).one()
if vagg.sessions and vagg.sessions >= 1:
play_ratio = None
if vagg.avg_duration_ms and vagg.avg_duration_ms > 0:
play_ratio = (vagg.avg_play_ms or 0) / vagg.avg_duration_ms
video = {
"avg_pct_played": _fmt_pct(vagg.avg_pct),
"completion_rate": _fmt_pct(vagg.completion_rate),
"avg_seeks": f"{vagg.avg_seeks:.1f}" if vagg.avg_seeks else "\u2014",
"avg_rewinds": f"{vagg.avg_rewinds:.1f}" if vagg.avg_rewinds else "\u2014",
"avg_pauses": f"{vagg.avg_pauses:.1f}" if vagg.avg_pauses else "\u2014",
"avg_speed": f"{vagg.avg_speed:.2f}x" if vagg.avg_speed else "\u2014",
"play_duration_ratio": _fmt_pct(play_ratio),
"sessions": vagg.sessions,
}
# --- Traffic Sources (21d) ---
traffic_raw = db.query(
PageSession.referrer_class, func.count().label("cnt"),
).filter(_pf(cutoff_21d)).group_by(
PageSession.referrer_class,
).order_by(func.count().desc()).all()
traffic_total = sum(r.cnt for r in traffic_raw) or 1
traffic = [
{
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / traffic_total),
}
for r in traffic_raw
]
# --- Devices (21d) ---
device_raw = db.query(
PageSession.device_class, func.count().label("cnt"),
).filter(_pf(cutoff_21d)).group_by(
PageSession.device_class,
).order_by(func.count().desc()).all()
device_total = sum(r.cnt for r in device_raw) or 1
devices = [
{
"device": DEVICE_LABELS.get(r.device_class, "Unknown"),
"count": r.cnt,
"pct": _fmt_pct(r.cnt / device_total),
}
for r in device_raw
]
# --- Ring Entries (21d) ---
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,
).scalar() or 0
# --- Engagement (21d) ---
eng_q = db.query(
func.avg(
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
).label("active_ratio"),
func.avg(
cast(PageSession.visible_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
).label("visible_ratio"),
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()
engagement = None
if eng_q.sessions and eng_q.sessions >= 1:
engagement = {
"active_ratio": _fmt_pct(eng_q.active_ratio),
"visible_ratio": _fmt_pct(eng_q.visible_ratio),
"avg_scroll_depth": _fmt_pct(eng_q.avg_scroll),
"avg_direction_changes": f"{eng_q.avg_dir_changes:.1f}" if eng_q.avg_dir_changes else "\u2014",
"sessions": eng_q.sessions,
}
# --- Comment Sentiment (21d) ---
sentiment = get_sentiment_summary_for_product(db, product.id, cutoff_21d)
# --- Price History (last 21 changes) ---
price_changes_raw = (
db.query(Price)
.filter(Price.product_id == product.id)
.order_by(Price.created_timestamp.desc())
.limit(21)
.all()
)
price_changes = [
{
"price": f"${cents_to_dollars(pc.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(pc.created_timestamp),
"datetime": str(timestamp_to_datetime(pc.created_timestamp)),
}
for pc in price_changes_raw
]
product_url = f"/{'p' if product.is_sellable else 'c'}/{product.uuid_str}/{product.slug}"
return {
"product": product,
"product_url": product_url,
"overview": overview,
"views_over_time": views_over_time,
"video": video,
"traffic": traffic,
"devices": devices,
"ring_entry_count": ring_entry_count,
"engagement": engagement,
"sentiment": sentiment,
"price_changes": price_changes,
}

View file

@ -152,6 +152,7 @@ def comment_new(request):
"author_name": request.user.name or "Anonymous",
"ago_string": comment.ago_string,
"approved": comment.approved,
"sentiment": comment.sentiment,
"depth": comment.graph_depth,
"parent_id": str(comment.parent_id) if comment.parent_id else None,
},

View file

@ -13,7 +13,8 @@ from ..models.shop import reforge_discovery_ring_async
from ..models.shop_location import ShopLocation
from ..models.inventory import Inventory
from ..lib.currency import validate_float
from ..lib.currency import validate_float, cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
def checkbox_to_bool(checkbox):
@ -402,6 +403,17 @@ def product_edit(request):
inv.shop_location.id: inv.quantity for inv in product.inventories
}
price_history = []
if product.is_sellable:
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({
"price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(ph.created_timestamp),
"datetime": str(timestamp_to_datetime(ph.created_timestamp)),
"is_current": i == 0,
})
return {
"product": product,
"title": title,
@ -411,6 +423,7 @@ def product_edit(request):
"signed_posts": signed_posts,
"locations": locations,
"inventories": inventories if product.is_physical else {},
"price_history": price_history,
}

View file

@ -134,6 +134,15 @@ def watch_json(request):
ExpiresIn=900,
)
# Public CDN URL for the product file (thumbnail-to-file links)
file_url = None
if has_product_file:
file_url = (
f"{request.app['bucket.secure_uploads.get_endpoint']}"
f"/{product.s3_path}/{file_key}"
f"?ts={product.updated_timestamp}"
)
# Check if current user is a shop editor (mod)
is_mod = False
if request.user and request.user.authenticated:
@ -151,6 +160,7 @@ def watch_json(request):
"is_sellable": product.is_sellable,
"has_product_file": has_product_file,
"download_url": download_url,
"file_url": file_url,
"file_type": file_type_str,
"file_size": file_size_str,
"view_count": product.view_count or 0,