diff --git a/docs/tickets/an-algo/03-comment-sentiment-analysis.md b/docs/tickets/an-algo/03-comment-sentiment-analysis.md
index cd3d604..2be02a9 100644
--- a/docs/tickets/an-algo/03-comment-sentiment-analysis.md
+++ b/docs/tickets/an-algo/03-comment-sentiment-analysis.md
@@ -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
diff --git a/docs/tickets/an-algo/04-love-gifts-tipping.md b/docs/tickets/an-algo/04-love-gifts-tipping.md
index bb20ea5..c343ac2 100644
--- a/docs/tickets/an-algo/04-love-gifts-tipping.md
+++ b/docs/tickets/an-algo/04-love-gifts-tipping.md
@@ -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
diff --git a/docs/tickets/an-algo/07-pricing-history.md b/docs/tickets/an-algo/07-pricing-history.md
index da459a2..258acf9 100644
--- a/docs/tickets/an-algo/07-pricing-history.md
+++ b/docs/tickets/an-algo/07-pricing-history.md
@@ -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)
diff --git a/make_post_sell/GIT_HASH b/make_post_sell/GIT_HASH
index 8d488a5..bd98d66 100644
--- a/make_post_sell/GIT_HASH
+++ b/make_post_sell/GIT_HASH
@@ -1 +1 @@
-e1a8b2f
\ No newline at end of file
+2f5498a
\ No newline at end of file
diff --git a/make_post_sell/lib/sentiment.py b/make_post_sell/lib/sentiment.py
new file mode 100644
index 0000000..aff4d1e
--- /dev/null
+++ b/make_post_sell/lib/sentiment.py
@@ -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
diff --git a/make_post_sell/models/comment.py b/make_post_sell/models/comment.py
index 5ab3856..f8c41a0 100644
--- a/make_post_sell/models/comment.py
+++ b/make_post_sell/models/comment.py
@@ -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
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index 02208b0..92da513 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -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(
diff --git a/make_post_sell/scripts/alembic/versions/3c0996ae20f3_add_sentiment_column_to_comment.py b/make_post_sell/scripts/alembic/versions/3c0996ae20f3_add_sentiment_column_to_comment.py
new file mode 100644
index 0000000..3a111d3
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/3c0996ae20f3_add_sentiment_column_to_comment.py
@@ -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")
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index 0a356d3..d430c77 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -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;
+}
diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js
index 7ea7f7d..a8a2053 100644
--- a/make_post_sell/static/js/watch.js
+++ b/make_post_sell/static/js/watch.js
@@ -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 @@
+ ''
+ ''
+ '
';
}
@@ -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();
diff --git a/make_post_sell/templates/analytics.j2 b/make_post_sell/templates/analytics.j2
index 1f0a214..1b88ca0 100644
--- a/make_post_sell/templates/analytics.j2
+++ b/make_post_sell/templates/analytics.j2
@@ -83,7 +83,7 @@
{% for p in top_products %}
| {{ p.rank }} |
- {{ p.title }} |
+ {{ p.title }} |
{{ p.views_7d }} |
{{ p.views_14d }} |
{{ p.views_21d }} |
@@ -113,7 +113,7 @@
{% for r in ring_entries %}
| {{ r.rank }} |
- {{ r.title }} |
+ {{ r.title }} |
{{ r.count }} |
{{ r.pct }} |
@@ -143,7 +143,7 @@
{% for e in engagement %}
| {{ e.rank }} |
- {{ e.title }} |
+ {{ e.title }} |
{{ e.score }} |
{{ e.sessions }} |
@@ -170,7 +170,7 @@
{% for a in attention %}
| {{ a.rank }} |
- {{ a.title }} |
+ {{ a.title }} |
{{ a.score }} |
{{ a.sessions }} |
@@ -202,7 +202,7 @@
{% for l in learning %}
| {{ l.rank }} |
- {{ l.title }} |
+ {{ l.title }} |
{{ l.score }} |
{{ l.sessions }} |
@@ -229,7 +229,7 @@
{% for b in passive %}
| {{ b.rank }} |
- {{ b.title }} |
+ {{ b.title }} |
{{ b.score }} |
{{ b.sessions }} |
@@ -241,6 +241,71 @@
{% endif %}
+ {# --- Video Watch Metrics (21d) --- #}
+ {% if video_overview %}
+ Video Watch Metrics (last 21 days)
+ Aggregate viewing behavior across {{ video_overview.total_sessions }} media sessions.
+
+
+ {{ video_overview.avg_pct_played }}
+ Avg % Played
+
+
+ {{ video_overview.completion_rate }}
+ Completion Rate
+
+
+ {{ video_overview.avg_seeks }}
+ Avg Seeks
+
+
+ {{ video_overview.avg_rewinds }}
+ Avg Rewinds
+
+
+ {{ video_overview.avg_pauses }}
+ Avg Pauses
+
+
+ {{ video_overview.avg_speed }}
+ Avg Speed
+
+
+
+ {% if video_products %}
+ Per-Product Video Breakdown
+ Top 14 by average % played (min {{ video_products[0].sessions if video_products else 7 }} sessions).
+
+
+
+
+ | # |
+ Title |
+ Avg % |
+ Completed |
+ Seeks |
+ Pauses |
+ Sessions |
+
+
+
+ {% for v in video_products %}
+
+ | {{ v.rank }} |
+ {{ v.title }} |
+ {{ v.avg_pct }} |
+ {{ v.completion_rate }} |
+ {{ v.avg_seeks }} |
+ {{ v.avg_pauses }} |
+ {{ v.sessions }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+ {% endif %}
+
{# --- Sections 8 & 9: Traffic Sources & Device Split (side by side) --- #}
{% if traffic or devices %}
@@ -296,6 +361,54 @@
{% endif %}{# end overview.total_views check #}
+ {# --- Comment Sentiment (21d) --- #}
+ {% if sentiment and sentiment.total > 0 %}
+
Comment Sentiment (last 21 days)
+
+
+ {{ sentiment.positive }}
+ Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%)
+
+
+ {{ sentiment.neutral }}
+ Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%)
+
+
+ {{ sentiment.negative }}
+ Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%)
+
+
+ {{ sentiment.total }}
+ Total
+
+
+ {% endif %}
+
+ {# --- Recent Price Changes (21d) --- #}
+ {% if price_changes %}
+
Recent Price Changes (last 21 days)
+
+
+
+
+ | Product |
+ Price |
+ When |
+
+
+
+ {% for pc in price_changes %}
+
+ | {{ pc.title }} |
+ {{ pc.price }} |
+ {{ pc.ago }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.
diff --git a/make_post_sell/templates/analytics_product.j2 b/make_post_sell/templates/analytics_product.j2
new file mode 100644
index 0000000..8a9a050
--- /dev/null
+++ b/make_post_sell/templates/analytics_product.j2
@@ -0,0 +1,238 @@
+{% extends "base.j2" -%}
+
+{%- block append_to_head_tag_section %}
+
Analytics - {{ product.title }} - {{ request.shop.name }}
+{%- endblock append_to_head_tag_section -%}
+
+{% block content -%}
+
+
+
+ ← Shop Analytics · View Product
+
+ {{ product.title }}
+
+ {# --- Overview Strip --- #}
+
+
+ {{ overview.views_7d }}
+ Views (7d)
+
+
+ {{ overview.avg_session }}
+ Avg Session
+
+
+ {{ overview.bounce_rate }}
+ Bounce Rate
+
+
+ {{ overview.lifetime_views }}
+ Lifetime Views
+
+
+
+ {# --- Views Over Time --- #}
+ Views Over Time
+
+
+ {{ views_over_time['7d'] }}
+ 7 days
+
+
+ {{ views_over_time['14d'] }}
+ 14 days
+
+
+ {{ views_over_time['21d'] }}
+ 21 days
+
+
+ {{ views_over_time['28d'] }}
+ 28 days
+
+
+ {{ views_over_time['365d'] }}
+ Year
+
+
+ {{ views_over_time['lifetime'] }}
+ Lifetime
+
+
+
+ {# --- Video Watch Metrics --- #}
+ {% if video %}
+ Video Watch Metrics (last 21 days)
+ Based on {{ video.sessions }} media sessions.
+
+
+ {{ video.avg_pct_played }}
+ Avg % Played
+
+
+ {{ video.completion_rate }}
+ Completion Rate
+
+
+ {{ video.avg_seeks }}
+ Avg Seeks
+
+
+ {{ video.avg_rewinds }}
+ Avg Rewinds
+
+
+ {{ video.avg_pauses }}
+ Avg Pauses
+
+
+ {{ video.avg_speed }}
+ Avg Speed
+
+
+ {{ video.play_duration_ratio }}
+ Play/Duration
+
+
+ {% endif %}
+
+ {# --- Traffic Sources & Devices (side by side) --- #}
+ {% if traffic or devices %}
+
+ {% if traffic %}
+
+
Traffic Sources (last 21 days)
+
+
+
+ | Source |
+ Sessions |
+ % |
+
+
+
+ {% for t in traffic %}
+
+ | {{ t.source }} |
+ {{ t.count }} |
+ {{ t.pct }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if devices %}
+
+
Device Split (last 21 days)
+
+
+
+ | Device |
+ Sessions |
+ % |
+
+
+
+ {% for d in devices %}
+
+ | {{ d.device }} |
+ {{ d.count }} |
+ {{ d.pct }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+ {# --- Ring Entries --- #}
+ {% if ring_entry_count > 0 %}
+ Ring Entry Sessions (last 21 days)
+
+
+ {{ ring_entry_count }}
+ Ring Entries
+
+
+ {% endif %}
+
+ {# --- Engagement --- #}
+ {% if engagement %}
+ Engagement (last 21 days)
+ Based on {{ engagement.sessions }} sessions with 7s+ wall clock.
+
+
+ {{ engagement.active_ratio }}
+ Active / Wall
+
+
+ {{ engagement.visible_ratio }}
+ Visible / Wall
+
+
+ {{ engagement.avg_scroll_depth }}
+ Avg Scroll Depth
+
+
+ {{ engagement.avg_direction_changes }}
+ Direction Changes
+
+
+ {% endif %}
+
+ {# --- Comment Sentiment --- #}
+ {% if sentiment and sentiment.total > 0 %}
+ Comment Sentiment (last 21 days)
+
+
+ {{ sentiment.positive }}
+ Positive ({{ (sentiment.positive / sentiment.total * 100) | round | int }}%)
+
+
+ {{ sentiment.neutral }}
+ Neutral ({{ (sentiment.neutral / sentiment.total * 100) | round | int }}%)
+
+
+ {{ sentiment.negative }}
+ Negative ({{ (sentiment.negative / sentiment.total * 100) | round | int }}%)
+
+
+ {{ sentiment.total }}
+ Total
+
+
+ {% endif %}
+
+ {# --- Price History --- #}
+ {% if price_changes %}
+ Price History (last 21 changes)
+
+
+
+
+ | Price |
+ When |
+
+
+
+ {% for pc in price_changes %}
+
+ | {{ pc.price }} |
+ {{ pc.ago }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ All data is anonymous. No individual viewer can be identified. Counts represent aggregate sessions, not people.
+
+
+
+{%- endblock -%}
diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2
index d1d4c0d..8ca9d14 100644
--- a/make_post_sell/templates/product_edit.j2
+++ b/make_post_sell/templates/product_edit.j2
@@ -250,6 +250,32 @@ Your cover (
thumbnail1) will show up on search pages.
+{% if price_history | length > 1 %}
+
+ Price History
+
+
+
+
+ | Price |
+ When |
+ Date |
+
+
+
+ {% for ph in price_history %}
+
+ | {{ ph.price_formatted }} |
+ {{ ph.ago }} |
+ {{ ph.datetime }} |
+
+ {% endfor %}
+
+
+
+
+{% endif %}
+
{%- endblock -%}
diff --git a/make_post_sell/templates/shop_comments.j2 b/make_post_sell/templates/shop_comments.j2
index b190859..051e6eb 100644
--- a/make_post_sell/templates/shop_comments.j2
+++ b/make_post_sell/templates/shop_comments.j2
@@ -19,6 +19,7 @@
Product |
Author |
Comment |
+ Tone |
Date |
Status |
Actions |
@@ -30,6 +31,7 @@
{{ comment.product.title | truncate(30) }} |
{{ comment.user.name if comment.user else "Anonymous" }} |
{{ comment.data | truncate(80) }} |
+ {{ comment.sentiment_icon }} |
{{ comment.ago_string }} |
{% if comment.disabled %}
diff --git a/make_post_sell/templates/snippets/related_content.j2 b/make_post_sell/templates/snippets/related_content.j2
index b96942c..bc22dbf 100644
--- a/make_post_sell/templates/snippets/related_content.j2
+++ b/make_post_sell/templates/snippets/related_content.j2
@@ -9,6 +9,7 @@
{% if request.shop.watch_mode_enabled %}
{% endif %}
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 3e62bae..97847e0 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -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)
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 599cbd4..e0afe9a 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -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)
diff --git a/make_post_sell/views/analytics.py b/make_post_sell/views/analytics.py
index 63800e4..0c19bdf 100644
--- a/make_post_sell/views/analytics.py
+++ b/make_post_sell/views/analytics.py
@@ -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,
+ }
diff --git a/make_post_sell/views/comment.py b/make_post_sell/views/comment.py
index 570f3e7..bb32135 100644
--- a/make_post_sell/views/comment.py
+++ b/make_post_sell/views/comment.py
@@ -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,
},
diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py
index 8f94d69..7a63eff 100644
--- a/make_post_sell/views/product.py
+++ b/make_post_sell/views/product.py
@@ -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,
}
diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py
index fec03cc..7f96d37 100644
--- a/make_post_sell/views/watch.py
+++ b/make_post_sell/views/watch.py
@@ -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,
|