Add Plausible custom events + weekly insights dashboard + cron

- Custom Plausible events: Pricing Click, Chat Toggle, Chat Message Sent,
  FAQ Toggle, Speedrun Nav, Mobile Menu Toggle, Scroll Depth (25/50/75/100%)
- Weekly Insights section on launch dashboard with visitor/pageview/bounce
  rate/visit duration/top pages/top referrers + analysis cards
- data/insights.json for storing weekly analytics summaries
- scripts/check-plausible-email.py — finds latest Plausible email via IMAP
- Cron job: every Mon 09:00 PYST, reads Plausible email, writes insights,
  analyzes and implements improvements
This commit is contained in:
Oliver
2026-06-09 15:04:54 -03:00
parent b51c28758d
commit 3033fc0de2
4 changed files with 320 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"week": "—",
"updated": "—",
"visitors": 0,
"visitors_change": "",
"pageviews": 0,
"pageviews_change": "",
"bounce_rate": "—",
"bounce_rate_change": "",
"visit_duration": "—",
"visit_duration_change": "",
"top_pages": [],
"top_referrers": [],
"events": [],
"what_worked": [],
"what_didnt": [],
"recommendations": []
}
+32
View File
@@ -1692,6 +1692,7 @@
})();
function slideSpeedrun(dir) {
plausible('Speedrun Nav', { props: { direction: dir > 0 ? 'next' : 'prev' } });
const slider = document.getElementById('speedrunSlider');
if (!slider) return;
const slideWidth = slider.querySelector('.video-slide').offsetWidth + 20;
@@ -2187,12 +2188,15 @@
<script>
// ── Mobile menu ──────────────────────────────────────────
function toggleMobile() {
plausible('Mobile Menu Toggle');
const nav = document.getElementById('nav-links');
nav.classList.toggle('mobile-open');
}
// ── FAQ accordion ────────────────────────────────────────
function toggleFaq(btn) {
const q = btn.innerText.replace(/[?✦]/g, '').trim();
plausible('FAQ Toggle', { props: { question: q } });
const answer = btn.nextElementSibling;
const isOpen = answer.classList.contains('open');
// Close all
@@ -2426,6 +2430,8 @@
return;
}
plausible('Pricing Click', { props: { product: product, agent: agent, period: period } });
try {
// Use a hidden form POST so the 302 redirect opens naturally in the new tab
const form = document.createElement('form');
@@ -2473,6 +2479,7 @@
let probeTimer = null;
function chatToggle() {
plausible('Chat Toggle', { props: { action: chatOpen ? 'close' : 'open' } });
chatOpen = !chatOpen;
document.getElementById('chat-widget').classList.toggle('open', chatOpen);
document.getElementById('chat-fab').classList.toggle('open', chatOpen);
@@ -2522,6 +2529,7 @@
const btn = document.getElementById("chat-send-btn");
const msg = input.value.trim();
if (!msg || chatBusy) return;
plausible('Chat Message Sent');
chatBusy = true;
chatOpenSilent();
chatAppendMsg("user", msg);
@@ -2635,6 +2643,30 @@
}
})();
/* ── Scroll Depth Tracking ────────────────────────── */
(function () {
const thresholds = [25, 50, 75, 100];
let sent = {};
let ticking = false;
window.addEventListener('scroll', function () {
if (!ticking) {
window.requestAnimationFrame(function () {
const scrolled = window.pageYOffset + window.innerHeight;
const total = document.documentElement.scrollHeight;
const pct = Math.min(100, Math.round((scrolled / total) * 100));
thresholds.forEach(function (t) {
if (pct >= t && !sent[t]) {
sent[t] = true;
plausible('Scroll Depth', { props: { percent: t + '%' } });
}
});
ticking = false;
});
ticking = true;
}
});
})();
</script>
<div class="pricing-toast" id="pricing-toast"></div>
+181
View File
@@ -485,6 +485,91 @@
border: 1px solid var(--border);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
/* ── Insights Section ───────────────────────────── */
.insight-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.insight-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: 20px;
}
.insight-card h3 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.insight-stat {
font-size: 32px;
font-weight: 700;
color: var(--text);
line-height: 1.1;
}
.insight-stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.insight-list {
list-style: none;
padding: 0;
margin: 0;
}
.insight-list li {
padding: 6px 0;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 8px;
border-bottom: 1px solid rgba(10,48,96,0.3);
}
.insight-list li:last-child { border-bottom: none; }
.insight-list .val { color: var(--accent); font-weight: 600; margin-left: auto; flex-shrink: 0; }
.insight-rec {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-card);
padding: 16px 20px;
margin-top: 12px;
font-size: 13px;
line-height: 1.6;
}
.insight-rec h4 {
color: var(--accent);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.insight-empty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 40px 0;
}
.insight-whatworked { color: var(--success); }
.insight-whatdidnt { color: var(--danger); }
.insight-neut { color: var(--warning); }
.insight-meta {
font-size: 11px;
color: var(--text-muted);
margin-top: 12px;
text-align: right;
}
@media (max-width: 700px) {
.insight-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
@@ -531,6 +616,20 @@
</div>
</div>
<!-- ── WEEKLY INSIGHTS ─────────────────────────── -->
<div class="section">
<div class="section-header" onclick="toggleSection('insights-section', this)">
<h2><span class="icon">📈</span> Weekly SEO &amp; Analytics Insights</h2>
<span class="section-toggle"></span>
</div>
<div class="section-body" id="insights-section">
<div id="insights-content">
<div class="insight-empty">Waiting for first analytics report. Data populates weekly from Plausible.</div>
</div>
<div class="insight-meta" id="insights-meta"></div>
</div>
</div>
<!-- ── ACTION PLAN ───────────────────────────── -->
<div class="section">
<div class="section-header" onclick="toggleSection('actions-section', this)">
@@ -924,6 +1023,78 @@
return NEEDS;
}
/* ── Render Weekly Insights ─────────────────── */
function renderInsights(insights) {
const container = document.getElementById("insights-content");
const meta = document.getElementById("insights-meta");
if (!insights || !insights.week) {
container.innerHTML = '<div class="insight-empty">Waiting for first analytics report. Data populates weekly from Plausible.</div>';
meta.textContent = "";
return;
}
const whatWorked = (insights.what_worked || []).map(function (s) {
return '<li><span class="insight-whatworked">▲</span> ' + s + '</li>';
}).join("");
const whatDidnt = (insights.what_didnt || []).map(function (s) {
return '<li><span class="insight-whatdidnt">▼</span> ' + s + '</li>';
}).join("");
const recs = (insights.recommendations || []).map(function (s) {
return '<li>→ ' + s + '</li>';
}).join("");
let topPages = "", topRefs = "";
if (insights.top_pages && insights.top_pages.length) {
topPages = insights.top_pages.map(function (p) {
return '<li>' + p.page + ' <span class="val">' + p.visits + '</span></li>';
}).join("");
}
if (insights.top_referrers && insights.top_referrers.length) {
topRefs = insights.top_referrers.map(function (r) {
return '<li>' + r.source + ' <span class="val">' + r.visits + '</span></li>';
}).join("");
}
container.innerHTML = '' +
'<div class="insight-grid">' +
'<div class="insight-card">' +
'<h3>👥 Visitors</h3>' +
'<div class="insight-stat">' + (insights.visitors || 0) + '</div>' +
'<div class="insight-stat-label">' + (insights.visitors_change || '') + '</div>' +
'</div>' +
'<div class="insight-card">' +
'<h3>📄 Pageviews</h3>' +
'<div class="insight-stat">' + (insights.pageviews || 0) + '</div>' +
'<div class="insight-stat-label">' + (insights.pageviews_change || '') + '</div>' +
'</div>' +
'<div class="insight-card">' +
'<h3>🔄 Bounce Rate</h3>' +
'<div class="insight-stat">' + (insights.bounce_rate || '—') + '</div>' +
'<div class="insight-stat-label">' + (insights.bounce_rate_change || '') + '</div>' +
'</div>' +
'<div class="insight-card">' +
'<h3>⏱ Visit Duration</h3>' +
'<div class="insight-stat">' + (insights.visit_duration || '—') + '</div>' +
'<div class="insight-stat-label">' + (insights.visit_duration_change || '') + '</div>' +
'</div>' +
'</div>' +
'<div class="insight-grid">' +
'<div class="insight-card">' +
'<h3>📑 Top Pages</h3>' +
(topPages ? '<ul class="insight-list">' + topPages + '</ul>' : '<div class="insight-empty" style="padding:20px 0">No data yet</div>') +
'</div>' +
'<div class="insight-card">' +
'<h3>🔗 Top Referrers</h3>' +
(topRefs ? '<ul class="insight-list">' + topRefs + '</ul>' : '<div class="insight-empty" style="padding:20px 0">No data yet</div>') +
'</div>' +
'</div>' +
(whatWorked ? '<div class="insight-rec"><h4>✅ What Worked</h4><ul class="insight-list">' + whatWorked + '</ul></div>' : '') +
(whatDidnt ? '<div class="insight-rec"><h4>❌ What Didn\'t</h4><ul class="insight-list">' + whatDidnt + '</ul></div>' : '') +
(recs ? '<div class="insight-rec" style="border-left-color:var(--warning)"><h4>💡 Recommendations</h4><ul class="insight-list">' + recs + '</ul></div>' : '');
meta.textContent = "Updated: " + (insights.updated || "—") + " — Week: " + insights.week;
}
/* ── Toggle sections ─────────────────────────── */
function toggleSection(id, header) {
const body = document.getElementById(id);
@@ -964,6 +1135,16 @@
renderKPIs(KPIS);
renderActions(ACTIONS);
}
// Also load insights
try {
const ir = await fetch("data/insights.json?_=" + Date.now());
if (ir.ok) {
renderInsights(await ir.json());
}
} catch(e) {
// No insights yet — leave placeholder
}
}
/* ── Init ────────────────────────────────────── */
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Check for Plausible analytics email and output raw content for cron analysis."""
import json
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent
INSIGHTS_FILE = HERE / "data" / "insights.json"
def run_cmd(*args, timeout=30):
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
return None, result.stderr
return result.stdout, None
def find_plausible_email():
"""Find the latest Plausible-related email in inbox."""
out, err = run_cmd("himalaya", "envelope", "list", "--page", "1", "--page-size", "30")
if not out:
return None, None
best_id, best_subject = None, ""
current_id, current_subject = None, ""
for line in out.splitlines():
# himalaya format: "ID | DATE | FROM | SUBJECT"
if "|" not in line:
continue
parts = [p.strip() for p in line.split("|")]
if len(parts) < 4:
continue
try:
current_id = int(parts[0])
except ValueError:
continue
current_subject = parts[-1] if len(parts) >= 4 else ""
subj_lower = current_subject.lower()
score = 0
if "plausible" in subj_lower:
score += 10
if "weekly" in subj_lower and ("report" in subj_lower or "analytics" in subj_lower or "stats" in subj_lower):
score += 5
if "analytics" in subj_lower and "summary" in subj_lower:
score += 5
if "site" in subj_lower and ("stats" in subj_lower or "report" in subj_lower):
score += 3
if score > 0 and score >= (10 if best_id is None else 0):
best_id = current_id
best_subject = current_subject
return best_id, best_subject
def main():
email_id, subject = find_plausible_email()
if email_id is None:
print("No Plausible email found in inbox.", file=sys.stderr)
# Still update last_check timestamp
insights = {}
if INSIGHTS_FILE.exists():
with open(INSIGHTS_FILE) as f:
insights = json.load(f)
insights["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M")
with open(INSIGHTS_FILE, "w") as f:
json.dump(insights, f, indent=2)
sys.exit(0)
content, err = run_cmd("himalaya", "message", "read", str(email_id))
if not content:
print(f"Failed to read email {email_id}: {err}", file=sys.stderr)
sys.exit(0)
print(f"=== Plausible Email Found ===")
print(f"Subject: {subject}")
print(f"Email ID: {email_id}")
print(f"=== Full Content ===")
print(content[:6000])
if __name__ == "__main__":
main()