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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 & 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 ────────────────────────────────────── */
|
||||
|
||||
Executable
+89
@@ -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()
|
||||
Reference in New Issue
Block a user