openvibers/openvibe.live Commits

Stats for nerds: every hero number opens its history again

OP OpenVibers committed 17 Sept 2026 · 7fb912e214 · 9 files, +793 −105

The compact headline strip had no metrics, so none of the numbers on it were
clickable. Every chip now opens a lazily loaded stats view (features.json:
statsNerds): 24h/7d/30d/90d/1y, per day or running total (average or peak for
the live readings), vs-previous-period tiles, hover/keyboard tooltips, a table
view and CSV, and ← → / swipe through all the stats.

- Live streams and viewers get history from the five-minute sampler (kept 400
  days instead of 7), bucketed hourly or daily with gaps as gaps.
- VODs, clips, pastes and archived hours come from OpenVibe.Media's new series
  endpoint instead of the frozen local tables.
- Counter series carry before/prev_total for running totals and comparisons.
- "All 24 numbers" is now "Show all 24 stats" with an icon.

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex e9d78fa8..9edba8f0 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -501,30 +501,12 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-tip-recent { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }
.hero-tip-recent div { background: var(--bg-tertiary); border-radius: 8px; padding: 5px 7px; text-align: center; }
.hero-tip-cta { margin-top: 8px; font-size: 0.7rem; color: var(--text-muted); }
--.hero-chart { width: min(760px, 100%); background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; padding: 18px 20px 16px; animation: heroPop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
--.hero-chart-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
--.hero-chart-head h3 i { color: var(--accent); }
--.hero-chart-close { background: none; border: 0; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; line-height: 1; }
--.hero-chart-desc { color: var(--text-secondary); font-size: 0.86rem; margin: 0 0 10px; }
--.hero-chart-ranges { display: inline-flex; gap: 4px; margin-bottom: 10px; }
--.hero-chart-ranges button { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; }
--.hero-chart-ranges button.active { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: var(--accent); color: var(--text-primary); }
--.hero-chart-svg { width: 100%; height: 220px; display: block; }
--.hero-chart-svg .bar:hover { opacity: 1; }
--.hero-chart-svg .grid { stroke: var(--border); stroke-width: 1; }
--.hero-chart-svg .line { fill: none; stroke: var(--accent-light); stroke-width: 2; stroke-linejoin: round; }
--.hero-chart-svg .area { fill: color-mix(in srgb, var(--accent) 22%, transparent); }
--.hero-chart-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }
--.hero-chart-loading { padding: 60px 0; text-align: center; color: var(--text-muted); }
@media (max-width: 720px) {
.hero-stat-label { white-space: normal; line-height: 1.15; }
.hero-stat-chart-hint { opacity: 0.6; }
.hero-tip { max-width: calc(100vw - 24px); }
-- .hero-chart { padding: 14px; }
-- .hero-chart-svg { height: 170px; }
.hero-stat--clickable:hover { transform: none; box-shadow: none; }
}
--@media (prefers-reduced-motion: reduce) { .hero-chart, .hero-chart-overlay { animation: none; } }
.brand-mark--hero .ov-ring { stroke-width: 3.6; } .brand-mark--hero .ov-comet { stroke-width: 3.6; } .brand-mark--hero .ov-v { stroke-width: 4.2; }
.hero-stats { min-height: 246px; }
.hero-stat-strip[hidden] { display: none !important; }
@@@ -550,13 +532,27 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-more {
display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
padding: 8px 12px 9px; cursor: pointer; font: inherit;
-- font-size: 0.74rem; font-weight: 700; letter-spacing: 0.02em;
-- color: color-mix(in srgb, var(--accent) 72%, var(--text-muted));
++ font-size: 0.76rem; font-weight: 700; letter-spacing: 0.02em;
++ color: var(--text-primary);
background: none; border: 0; border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
transition: color 0.2s, background 0.2s;
}
--.hero-stat-more:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
--.hero-stat-more.is-open i { transform: rotate(180deg); }
++/* The stats are something to be proud of — the toggle reads as an invitation, not a footnote. */
++.hero-stat-more-icon {
++ display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px;
++ color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #22d3ee));
++ box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
++ transition: box-shadow 0.3s, transform 0.3s cubic-bezier(.2, 1, .3, 1);
++}
++.hero-stat-more-icon i { font-size: 0.7rem; }
++.hero-stat-more-text { background: linear-gradient(90deg, var(--text-primary), color-mix(in srgb, var(--accent) 60%, var(--text-primary))); -webkit-background-clip: text; background-clip: text; color: transparent; }
++.hero-stat-more-chev { color: var(--text-muted); }
++.hero-stat-more:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
++.hero-stat-more:hover .hero-stat-more-icon { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); transform: rotate(-6deg) scale(1.06); }
++.hero-stat-more.is-open .hero-stat-more-chev { transform: rotate(180deg); }
++/* Every number opens its history: say so without a hover (touch screens have none). */
++.hero-stat--clickable { cursor: pointer; }
++@media (hover: none) { .hero-stat-chart-hint { opacity: 0.55; } }
.hero-stat-full { border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent); }
.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
@media (max-width: 640px) {
ddiff --git a/public/css/features/stats.css b/public/css/features/stats.css
nnew file mode 100644
iindex 00000000..732fe334
---- /dev/null
++++ b/public/css/features/stats.css
@@@ -0,0 +1,126 @@
++/* Stats for nerds (public/js/stats-nerds.js). Data marks use the accent; context uses gray;
++ every label and value uses text colours. Grid and axes are solid hairlines. */
++.sn-overlay {
++ position: fixed; inset: 0; z-index: 10050; /* above the chat/DM bubbles (9999) */ display: flex; align-items: center; justify-content: center;
++ padding: 16px; background: rgba(6, 6, 10, 0.72); backdrop-filter: blur(6px);
++ animation: snFade var(--dur-fast, 160ms) ease-out;
++}
++.sn-overlay.is-closing { animation: snFadeOut 160ms ease-in forwards; }
++.sn-dialog {
++ position: relative; width: min(860px, 100%); max-height: calc(100dvh - 32px); overflow: auto;
++ background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px;
++ padding: 18px 20px 14px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
++ animation: snPop 240ms cubic-bezier(0.2, 0.9, 0.3, 1.15);
++}
++.sn-dialog:focus { outline: none; }
++.sn-head { display: grid; grid-template-columns: auto 1fr auto auto; align-items: start; gap: 10px; }
++.sn-title-wrap { min-width: 0; }
++.sn-kicker {
++ display: inline-flex; align-items: center; gap: 6px; font-size: 0.68rem; font-weight: 800;
++ letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted);
++}
++.sn-kicker i { color: var(--accent); }
++.sn-pos { font-weight: 600; letter-spacing: 0; text-transform: none; opacity: 0.8; }
++.sn-head h3 { margin: 2px 0 2px; font-size: 1.3rem; display: flex; align-items: center; gap: 10px; }
++.sn-icon { color: var(--accent); font-size: 1rem; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; background: color-mix(in srgb, var(--accent) 16%, transparent); }
++.sn-desc { margin: 0; color: var(--text-secondary); font-size: 0.86rem; }
++.sn-nav, .sn-close {
++ width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; cursor: pointer;
++ background: transparent; border: 1px solid var(--border); color: var(--text-secondary);
++ transition: background var(--dur-fast, 160ms), color var(--dur-fast, 160ms), border-color var(--dur-fast, 160ms);
++}
++.sn-nav:hover, .sn-close:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); background: color-mix(in srgb, var(--accent) 10%, transparent); }
++.sn-prev { margin-top: 18px; }
++.sn-next { margin-top: 18px; }
++.sn-close { border-color: transparent; }
++
++.sn-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 14px 0 12px; }
++.sn-seg { display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary) 5%, transparent); border: 1px solid var(--border); }
++.sn-seg button, .sn-table-btn {
++ padding: 5px 11px; border-radius: 999px; border: 0; background: transparent; cursor: pointer;
++ color: var(--text-secondary); font: inherit; font-size: 0.76rem; font-weight: 600;
++ transition: background var(--dur-fast, 160ms), color var(--dur-fast, 160ms);
++}
++.sn-seg button:hover, .sn-table-btn:hover { color: var(--text-primary); }
++.sn-seg button.is-on { background: var(--accent); color: #fff; }
++.sn-table-btn { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); }
++.sn-table-btn.is-on { border-color: var(--accent); color: var(--text-primary); background: color-mix(in srgb, var(--accent) 14%, transparent); }
++
++.sn-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin-bottom: 12px; }
++.sn-tile { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 4%, transparent); border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
++.sn-tile-label { font-size: 0.7rem; color: var(--text-muted); font-weight: 600; }
++.sn-tile-value { font-size: 1.35rem; font-weight: 700; color: var(--text-primary); line-height: 1.15; }
++.sn-tile-sub { font-size: 0.72rem; color: var(--text-secondary); }
++.sn-delta { display: inline-flex; align-items: center; gap: 4px; font-size: 0.72rem; font-weight: 600; color: var(--text-secondary); }
++.sn-delta i { font-size: 0.7rem; }
++.sn-delta.is-up i { color: var(--success, #2ecc71); }
++.sn-delta.is-down i { color: var(--error, #e74c3c); }
++
++.sn-body { position: relative; transition: opacity var(--dur-fast, 160ms); }
++.sn-body.is-loading { opacity: 0.45; pointer-events: none; }
++.sn-plot { position: relative; min-height: 200px; border-radius: 12px; outline: none; }
++.sn-plot:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent); }
++.sn-svg { display: block; width: 100%; height: auto; overflow: visible; touch-action: pan-y; }
++.sn-grid { stroke: color-mix(in srgb, var(--text-primary) 9%, transparent); stroke-width: 1; shape-rendering: crispEdges; }
++.sn-axis { fill: var(--text-muted); font-size: 10.5px; font-variant-numeric: tabular-nums; }
++.sn-bar { fill: var(--accent); transform-box: fill-box; transform-origin: bottom; animation: snGrow 520ms cubic-bezier(0.2, 0.8, 0.3, 1) both; transition: fill var(--dur-fast, 160ms); }
++.sn-bar.is-hot { fill: color-mix(in srgb, var(--accent) 70%, #fff); }
++.sn-line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
++.sn-line--context { stroke: color-mix(in srgb, var(--text-primary) 32%, transparent); stroke-width: 1.5; }
++.sn-area { fill: color-mix(in srgb, var(--accent) 10%, transparent); }
++.sn-marks .sn-line:not(.sn-line--context) { stroke-dasharray: 4000; stroke-dashoffset: 4000; animation: snDraw 900ms cubic-bezier(0.3, 0.7, 0.2, 1) forwards; }
++.sn-dot { fill: var(--accent); stroke: var(--bg-card); stroke-width: 2; }
++.sn-cross { stroke: color-mix(in srgb, var(--text-primary) 35%, transparent); stroke-width: 1; shape-rendering: crispEdges; pointer-events: none; }
++.sn-legend { display: flex; gap: 14px; justify-content: flex-end; font-size: 0.72rem; color: var(--text-secondary); padding: 2px 4px 0; }
++.sn-legend-item { display: inline-flex; align-items: center; gap: 6px; }
++.sn-key { display: inline-block; width: 14px; height: 0; border-top: 2px solid var(--accent); border-radius: 2px; }
++.sn-key--peak { border-top: 1.5px solid color-mix(in srgb, var(--text-primary) 32%, transparent); }
++.sn-empty { display: grid; place-items: center; min-height: 200px; color: var(--text-muted); font-size: 0.9rem; }
++
++.sn-tip {
++ position: absolute; top: 0; left: 0; z-index: 2; pointer-events: none; min-width: 90px;
++ padding: 7px 10px; border-radius: 10px; background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
++ border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
++ transition: transform 70ms linear;
++}
++.sn-tip-value { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
++.sn-tip-label { font-size: 0.72rem; color: var(--text-secondary); }
++.sn-tip-extra { font-size: 0.72rem; color: var(--text-muted); }
++
++.sn-table { max-height: 300px; overflow: auto; border: 1px solid var(--border); border-radius: 12px; }
++.sn-table table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
++.sn-table th { position: sticky; top: 0; background: var(--bg-card); text-align: left; color: var(--text-muted); font-weight: 600; padding: 8px 12px; border-bottom: 1px solid var(--border); }
++.sn-table td { padding: 6px 12px; border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); color: var(--text-secondary); }
++.sn-table td.num, .sn-table th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
++.sn-table td.num { color: var(--text-primary); }
++
++.sn-swipe-hint { display: none; }
++.sn-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; font-size: 0.72rem; color: var(--text-muted); }
++.sn-csv { background: none; border: 0; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 0.74rem; display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 8px; }
++.sn-csv:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
++
++@keyframes snFade { from { opacity: 0; } }
++@keyframes snFadeOut { to { opacity: 0; } }
++@keyframes snPop { from { opacity: 0; transform: translateY(10px) scale(0.98); } }
++@keyframes snGrow { from { transform: scaleY(0); } }
++@keyframes snDraw { to { stroke-dashoffset: 0; } }
++
++@media (max-width: 640px) {
++ .sn-overlay { padding: 0; align-items: flex-end; }
++ .sn-dialog { border-radius: 20px 20px 0 0; max-height: 92dvh; padding: 14px 14px 12px; }
++ .sn-head { grid-template-columns: 1fr auto; }
++ .sn-prev, .sn-next { display: none; }
++ .sn-head h3 { font-size: 1.1rem; }
++ .sn-table-btn { margin-left: 0; }
++ .sn-tile-value { font-size: 1.1rem; }
++ .sn-tiles { grid-template-columns: repeat(2, 1fr); }
++ .sn-tile:last-child:nth-child(odd) { grid-column: 1 / -1; }
++ .sn-swipe-hint { display: inline; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .sn-overlay, .sn-dialog, .sn-bar, .sn-marks .sn-line { animation: none !important; stroke-dashoffset: 0; }
++}
++@media (forced-colors: active) {
++ .sn-bar, .sn-dot { fill: CanvasText; }
++ .sn-line { stroke: CanvasText; }
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 248a0ba0..ef7229f4 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -10846,14 +10846,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.hero-tip { position: fixed; z-index: 4000; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #1a1a20); border: 1px solid var(--border-light, #3a3a44); box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5); color: var(--text-primary); font-size: 0.8rem; line-height: 1.45; pointer-events: none; opacity: 0; transform: translateY(4px); transition: opacity 0.12s, transform 0.12s; }
.hero-tip-recent b { display: block; font-size: 0.9rem; color: var(--accent-light, #a78bfa); }
.hero-tip-recent span { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
--.hero-chart-overlay { position: fixed; inset: 0; z-index: 4100; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; padding: 20px; animation: heroFadeIn 0.18s ease-out; }
--@keyframes heroFadeIn { from { opacity: 0; } to { opacity: 1; } }
--@keyframes heroPop { from { transform: scale(0.96); opacity: 0; } to { transform: none; opacity: 1; } }
--.hero-chart-head h3 { margin: 0; font-size: 1.15rem; display: flex; align-items: center; gap: 8px; }
--.hero-chart-svg .bar { fill: var(--accent); opacity: 0.85; transition: opacity 0.1s; transform-origin: bottom; animation: heroBarGrow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
--@keyframes heroBarGrow { from { transform: scaleY(0); } to { transform: scaleY(1); } }
--.hero-chart-svg text { fill: var(--text-muted); font-size: 10px; }
--.hero-chart-foot b { color: var(--text-primary); }
@media (max-width: 720px) {
/* (The counterpart !important rule lived here. It forced display:inline-block, which beat the
tile's grid centring and left every glyph sitting off-centre.) */
@@@ -10868,7 +10860,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
to 602px and then snapped to its real 976px. */
.hero-bg-layer.active { animation: none !important; }
}
--@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } }
@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }
.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }
ddiff --git a/public/features.json b/public/features.json
iindex 37bc0ecc..018c6bc2 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -252,6 +252,17 @@
"js": [
"/js/app-docs.js"
]
++ },
++ "statsNerds": {
++ "css": [
++ "/css/features/stats.css"
++ ],
++ "js": [
++ "/js/stats-nerds.js"
++ ],
++ "stubs": [
++ "openStatsNerds"
++ ]
}
},
"routes": [
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex f86bf529..198f271d 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -270,8 +270,8 @@ function _renderHeroStats(stats) {
// ── Right now ────────────────────────────────────────────────
const now = [
-- { key: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
-- { key: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
++ { key: 'liveNow', metric: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
++ { key: 'viewersNow', metric: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },
];
@@@ -332,7 +332,7 @@ function _renderHeroStats(stats) {
{ icon: 'fa-clock', num: stats.streamHours, label: 'Hours', metric: 'hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' },
{ icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', metric: 'aiMoments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },
{ recent: R.emotes, icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', metric: 'emotes', title: 'Custom channel emotes uploaded' },
-- { icon: 'fa-paste', num: stats.pastes, label: 'Pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },
++ { icon: 'fa-paste', num: stats.pastes, label: 'Pastes', metric: 'pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },
],
});
@@@ -401,15 +401,15 @@ function _renderHeroStats(stats) {
// the board is an all-time total that changes once an hour at best, so it lives under the
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
const HEADLINE = [
-- { key: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
-- { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
-- { key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
-- { key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
-- { key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
-- { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },
-- { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },
-- { key: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here', recent: R.streamers },
-- { key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours', title: 'Hours the community has spent watching', recent: R.hours },
++ { key: 'liveNow', metric: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
++ { key: 'viewersNow', metric: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
++ { key: 'weeklyActive', metric: 'active', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
++ { key: 'users', metric: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
++ { key: 'weeklyVisitors', metric: 'visitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
++ { key: 'anons', metric: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },
++ { key: 'chatMessages', metric: 'messages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },
++ { key: 'streamers', metric: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here', recent: R.streamers },
++ { key: 'hoursWatched', metric: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours', title: 'Hours the community has spent watching', recent: R.hours },
];
// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up
// two dozen extra chips before the page had finished loading, for a view the reader asked for
@@@ -418,7 +418,7 @@ function _renderHeroStats(stats) {
wrap.innerHTML = `
<div class="hero-stat-strip">${HEADLINE.map(chip).join('')}</div>
<button type="button" class="hero-stat-more" id="hero-stat-more" aria-expanded="${open}" aria-controls="hero-stat-full">
-- <i class="fa-solid fa-chevron-down"></i><span></span>
++ <span class="hero-stat-more-icon" aria-hidden="true"><i class="fa-solid fa-chart-simple"></i></span><span class="hero-stat-more-text"></span><i class="fa-solid fa-chevron-down hero-stat-more-chev" aria-hidden="true"></i>
</button>
<div class="hero-stat-full" id="hero-stat-full" ${open ? '' : 'hidden'}>
${groups.filter(g => g.rows.length).map(g => `
@@@ -432,7 +432,7 @@ function _renderHeroStats(stats) {
const full = wrap.querySelector('#hero-stat-full');
const strip = wrap.querySelector('.hero-stat-strip');
const total = groups.reduce((n, g) => n + g.rows.length, 0);
-- const label = () => { btn.querySelector('span').textContent = full.hidden ? `All ${total} numbers` : 'Just the highlights'; };
++ const label = () => { btn.querySelector('.hero-stat-more-text').textContent = full.hidden ? `Show all ${total} stats` : 'Show fewer stats'; };
const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };
sync();
@@@ -615,65 +615,15 @@ function _heroBindInteractions(wrap) {
chip.addEventListener('focus', () => _heroTipShow(chip));
chip.addEventListener('blur', _heroTipHide);
if (chip.dataset.metric) {
-- const open = () => { _heroTipHide(); _heroStatModal(chip); };
++ const open = () => { _heroTipHide(); if (typeof openStatsNerds === 'function') openStatsNerds(chip); };
chip.addEventListener('click', open);
++ // Start fetching the stats view on intent, so the dialog opens without a wait.
++ chip.addEventListener('pointerenter', () => { try { window.ov?.prefetch?.('statsNerds'); } catch { /* */ } }, { once: true });
chip.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
}
});
window.addEventListener('scroll', _heroTipHide, { passive: true });
}
--const _HERO_METRIC_ICON = { users: 'fa-users', anons: 'fa-user-secret', visitors: 'fa-user-plus', active: 'fa-fire', follows: 'fa-heart', messages: 'fa-comments', sessions: 'fa-tower-broadcast', streamers: 'fa-satellite-dish', vods: 'fa-film', clips: 'fa-scissors', hours: 'fa-clock', hoursWatched: 'fa-couch', aiMoments: 'fa-brain', vibes: 'fa-hand-holding-dollar', supporters: 'fa-hand-holding-heart', vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift', emotes: 'fa-face-grin-squint' };
--async function _heroStatModal(chip) {
-- let tip; try { tip = JSON.parse(chip.dataset.tip || '{}'); } catch { tip = {}; }
-- const metric = chip.dataset.metric;
-- document.querySelector('.hero-chart-overlay')?.remove();
-- const overlay = document.createElement('div');
-- overlay.className = 'hero-chart-overlay';
-- overlay.innerHTML = `<div class="hero-chart" role="dialog" aria-modal="true" aria-label="${esc(tip.label || metric)} over time">
-- <div class="hero-chart-head"><h3><i class="fa-solid ${_HERO_METRIC_ICON[metric] || 'fa-chart-line'}"></i> ${esc(tip.label || metric)}</h3><button class="hero-chart-close" aria-label="Close">&times;</button></div>
-- <p class="hero-chart-desc">${esc(tip.desc || tip.title || '')}</p>
-- <div class="hero-chart-ranges"><button data-days="7">7 days</button><button data-days="30" class="active">30 days</button><button data-days="90">90 days</button></div>
-- <div class="hero-chart-body"><div class="hero-chart-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
-- <div class="hero-chart-foot"></div>
-- </div>`;
-- document.body.appendChild(overlay);
-- const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
-- const onKey = (e) => { if (e.key === 'Escape') close(); };
-- document.addEventListener('keydown', onKey);
-- overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.closest('.hero-chart-close')) close(); });
-- const unit = tip.recent?.unit || '';
-- const load = async (days) => {
-- const body = overlay.querySelector('.hero-chart-body'), foot = overlay.querySelector('.hero-chart-foot');
-- body.innerHTML = '<div class="hero-chart-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
-- try {
-- const d = await api(`/home/stats/series/${encodeURIComponent(metric)}?days=${days}`);
-- body.innerHTML = _heroChartSvg(d.points, unit);
-- const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });
-- foot.innerHTML = `<span><b>${_fmtCount(d.total)}${unit}</b> in the last ${d.days} days</span><span>avg <b>${_fmtCount(d.total / d.days)}${unit}</b> / day</span>${best.value > 0 ? `<span>best day <b>${_fmtCount(best.value)}${unit}</b> · ${esc(_heroDay(best.day))}</span>` : ''}`;
-- } catch { body.innerHTML = '<div class="hero-chart-loading">No data for this metric yet.</div>'; foot.innerHTML = ''; }
-- };
-- overlay.querySelectorAll('.hero-chart-ranges button').forEach(b => b.addEventListener('click', () => {
-- overlay.querySelectorAll('.hero-chart-ranges button').forEach(x => x.classList.toggle('active', x === b));
-- load(Number(b.dataset.days));
-- }));
-- load(30);
--}
--function _heroDay(iso) { try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso; } }
--function _heroChartSvg(points, unit = '') {
-- const W = 720, H = 220, padL = 40, padR = 10, padT = 12, padB = 26;
-- const n = points.length;
-- const max = Math.max(1, ...points.map(p => p.value));
-- const x = (i) => padL + (i + 0.5) * ((W - padL - padR) / n);
-- const y = (v) => padT + (H - padT - padB) * (1 - v / max);
-- const bw = Math.max(2, ((W - padL - padR) / n) * (n > 40 ? 0.8 : 0.62));
-- const bars = points.map((p, i) => `<rect class="bar" x="${(x(i) - bw / 2).toFixed(1)}" y="${y(p.value).toFixed(1)}" width="${bw.toFixed(1)}" height="${(H - padB - y(p.value)).toFixed(1)}" rx="2" style="animation-delay:${(i * (0.35 / n)).toFixed(3)}s"><title>${esc(_heroDay(p.day))}: ${_fmtCount(p.value)}${unit}</title></rect>`).join('');
-- const line = points.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
-- const grid = [0, 0.5, 1].map(f => { const yy = y(max * f); return `<line class="grid" x1="${padL}" x2="${W - padR}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}"></line><text x="${padL - 6}" y="${(yy + 3).toFixed(1)}" text-anchor="end">${_fmtCount(max * f)}</text>`; }).join('');
-- const labelEvery = n > 40 ? Math.ceil(n / 8) : n > 10 ? Math.ceil(n / 6) : 1;
-- const labels = points.map((p, i) => (i % labelEvery === 0 || i === n - 1) ? `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle">${esc(_heroDay(p.day))}</text>` : '').join('');
-- return `<svg class="hero-chart-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">${grid}${bars}${n > 1 ? `<polyline class="line" points="${line}"></polyline>` : ''}${labels}</svg>`;
--}
--
// ── Hero floating thumbnail collage ─────────────────────────────
const _HERO_BADGE = { live: 'LIVE', vod: 'VOD', clip: 'CLIP', paste: 'PASTE', moment: 'AI MOMENT' };
let _heroCollageTimer = null;
ddiff --git a/public/js/stats-nerds.js b/public/js/stats-nerds.js
nnew file mode 100644
iindex 00000000..9b91a058
---- /dev/null
++++ b/public/js/stats-nerds.js
@@@ -0,0 +1,526 @@
++/**
++ * Stats for nerds — the "over time" view behind every hero number.
++ *
++ * Loaded on the first click of a hero stat (features.json: statsNerds), so the home page does not
++ * pay for it. One dialog, one metric at a time, with the controls in a single row above the chart:
++ *
++ * - time range presets (a day / week / month / quarter / year; the day preset only for readings)
++ * - a view: per day | running total for counters, average | peak for live readings
++ * - a table view, so no value is only reachable by hovering
++ * - ‹ › (and ← →) to walk through every metric the board has
++ *
++ * Counters (users, messages, VODs…) come as daily values plus `before` (everything earlier) and
++ * `prev_total` (the same-length window before this one). Readings (streams live, people watching)
++ * come from the five-minute sampler as per-hour or per-day averages and peaks, with null where
++ * nothing was sampled — drawn as gaps, never as zero.
++ *
++ * Chart rules follow the dataviz method: one axis, thin marks capped at 24px with 4px rounded
++ * ends on a single baseline, hairline solid grid, the accent for the data and a gray for context,
++ * text in text colours, a tooltip that leads with the value, and a refetch that keeps the old
++ * chart (dimmed) instead of flashing a spinner.
++ */
++(function () {
++ 'use strict';
++
++ const ICON = {
++ liveNow: 'fa-circle', viewersNow: 'fa-eye', users: 'fa-users', anons: 'fa-user-secret', visitors: 'fa-user-plus',
++ active: 'fa-fire', follows: 'fa-heart', messages: 'fa-comments', sessions: 'fa-tower-broadcast',
++ streamers: 'fa-satellite-dish', vods: 'fa-film', clips: 'fa-scissors', pastes: 'fa-paste', hours: 'fa-clock',
++ hoursWatched: 'fa-couch', aiMoments: 'fa-brain', vibes: 'fa-hand-holding-dollar', supporters: 'fa-hand-holding-heart',
++ vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift',
++ emotes: 'fa-face-grin-squint',
++ };
++ const RANGES = [
++ { days: 1, label: '24h', readingOnly: true },
++ { days: 7, label: '7d' }, { days: 30, label: '30d' }, { days: 90, label: '90d' }, { days: 365, label: '1y' },
++ ];
++
++ const nf = new Intl.NumberFormat();
++ const fmt = (v, unit = '') => {
++ if (v == null || !Number.isFinite(Number(v))) return '—';
++ const n = Number(v);
++ const abs = Math.abs(n);
++ const s = abs >= 1e6 ? (n / 1e6).toFixed(abs >= 1e7 ? 0 : 1).replace(/\.0$/, '') + 'M'
++ : abs >= 1e4 ? (n / 1e3).toFixed(abs >= 1e5 ? 0 : 1).replace(/\.0$/, '') + 'K'
++ : Number.isInteger(n) ? nf.format(n) : nf.format(Math.round(n * 10) / 10);
++ return s + unit;
++ };
++ const exact = (v, unit = '') => (v == null ? '—' : nf.format(Math.round(Number(v) * 10) / 10) + unit);
++ const dayLabel = (iso, opts = { month: 'short', day: 'numeric' }) => {
++ try { return new Date(iso.length === 10 ? iso + 'T00:00:00Z' : iso).toLocaleDateString(undefined, { ...opts, timeZone: iso.length === 10 ? 'UTC' : undefined }); } catch { return iso; }
++ };
++ const hourLabel = (iso) => {
++ try { return new Date(iso).toLocaleString(undefined, { weekday: 'short', hour: 'numeric' }); } catch { return iso; }
++ };
++ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
++ /** Clean axis: a 1/2/2.5/5 × 10^n step, whole numbers for whole-number data, ~4 intervals. */
++ const niceScale = (max, integer) => {
++ if (!(max > 0)) return { top: integer ? 4 : 1, step: integer ? 1 : 0.25 };
++ const raw = max / 4;
++ const p = Math.pow(10, Math.floor(Math.log10(raw)));
++ let step = 10 * p;
++ for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= raw) { step = m * p; break; }
++ if (integer && step < 1) step = 1;
++ if (integer && !Number.isInteger(step)) step = Math.ceil(step);
++ return { top: Math.ceil(max / step - 1e-9) * step, step };
++ };
++
++ const state = { overlay: null, metrics: [], index: 0, days: 30, view: 'daily', table: false, data: null, req: 0, lastFocus: null, ro: null };
++
++ /** Every chartable chip on the hero, in board order, deduplicated by metric. */
++ function collectMetrics() {
++ const seen = new Set();
++ const out = [];
++ document.querySelectorAll('#hero-stats .hero-stat[data-metric]').forEach((chip) => {
++ const metric = chip.dataset.metric;
++ if (seen.has(metric)) return;
++ seen.add(metric);
++ let tip = {};
++ try { tip = JSON.parse(chip.dataset.tip || '{}'); } catch { /* */ }
++ const numEl = chip.querySelector('.hero-stat-num');
++ out.push({ metric, label: tip.label || metric, desc: tip.desc || tip.title || '', unit: tip.recent?.unit || (metric === 'hours' || metric === 'hoursWatched' ? 'h' : ''), now: numEl ? Number(numEl.dataset.n) : null });
++ });
++ return out;
++ }
++
++ function open(chip) {
++ state.metrics = collectMetrics();
++ const metric = chip?.dataset?.metric;
++ state.index = Math.max(0, state.metrics.findIndex((m) => m.metric === metric));
++ if (!state.metrics.length) return;
++ state.lastFocus = document.activeElement;
++ const isReading = (m) => m.metric === 'liveNow' || m.metric === 'viewersNow';
++ state.days = isReading(state.metrics[state.index]) ? 7 : 30;
++ state.view = isReading(state.metrics[state.index]) ? 'avg' : 'daily';
++ state.table = false;
++ build();
++ load();
++ }
++
++ function close() {
++ if (!state.overlay) return;
++ state.ro?.disconnect();
++ document.removeEventListener('keydown', onKey, true);
++ const ov = state.overlay;
++ state.overlay = null;
++ ov.classList.add('is-closing');
++ setTimeout(() => ov.remove(), 160);
++ try { state.lastFocus?.focus?.({ preventScroll: true }); } catch { /* */ }
++ }
++
++ function onKey(e) {
++ if (!state.overlay) return;
++ if (e.key === 'Escape') { e.preventDefault(); close(); return; }
++ const inChart = e.target.closest?.('.sn-plot');
++ if (!inChart && (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.target.closest?.('input, select, textarea')) {
++ e.preventDefault();
++ step(e.key === 'ArrowRight' ? 1 : -1);
++ }
++ }
++
++ function step(dir) {
++ const n = state.metrics.length;
++ const prevReading = current().kind === 'reading';
++ state.index = (state.index + dir + n) % n;
++ const reading = state.metrics[state.index].metric === 'liveNow' || state.metrics[state.index].metric === 'viewersNow';
++ if (reading !== prevReading) {
++ state.view = reading ? 'avg' : 'daily';
++ if (!reading && state.days === 1) state.days = 7;
++ }
++ renderHead();
++ load();
++ }
++
++ const current = () => {
++ const m = state.metrics[state.index];
++ return { ...m, kind: (m.metric === 'liveNow' || m.metric === 'viewersNow') ? 'reading' : 'count' };
++ };
++
++ function build() {
++ document.querySelector('.sn-overlay')?.remove();
++ const ov = el('div', 'sn-overlay');
++ ov.innerHTML = `
++ <div class="sn-dialog" role="dialog" aria-modal="true" aria-labelledby="sn-title" tabindex="-1">
++ <header class="sn-head">
++ <button type="button" class="sn-nav sn-prev" aria-label="Previous stat"><i class="fa-solid fa-chevron-left"></i></button>
++ <div class="sn-title-wrap">
++ <div class="sn-kicker"><i class="fa-solid fa-flask-vial"></i> Stats for nerds <span class="sn-pos"></span></div>
++ <h3 id="sn-title"><i class="sn-icon fa-solid"></i><span class="sn-name"></span></h3>
++ <p class="sn-desc"></p>
++ </div>
++ <button type="button" class="sn-nav sn-next" aria-label="Next stat"><i class="fa-solid fa-chevron-right"></i></button>
++ <button type="button" class="sn-close" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
++ </header>
++ <div class="sn-controls">
++ <div class="sn-seg sn-ranges" role="group" aria-label="Time range"></div>
++ <div class="sn-seg sn-views" role="group" aria-label="View"></div>
++ <button type="button" class="sn-table-btn" aria-pressed="false"><i class="fa-solid fa-table-list"></i><span>Table</span></button>
++ </div>
++ <div class="sn-tiles"></div>
++ <div class="sn-body">
++ <div class="sn-plot" tabindex="0" role="img"></div>
++ <div class="sn-table" hidden></div>
++ <div class="sn-tip" role="status" aria-live="polite" hidden></div>
++ </div>
++ <footer class="sn-foot"><span class="sn-note"></span><span class="sn-swipe-hint">Swipe for more stats</span><button type="button" class="sn-csv"><i class="fa-solid fa-download"></i> CSV</button></footer>
++ </div>`;
++ document.body.appendChild(ov);
++ state.overlay = ov;
++ ov.addEventListener('click', (e) => { if (e.target === ov) close(); });
++ ov.querySelector('.sn-close').addEventListener('click', close);
++ ov.querySelector('.sn-prev').addEventListener('click', () => step(-1));
++ ov.querySelector('.sn-next').addEventListener('click', () => step(1));
++ ov.querySelector('.sn-table-btn').addEventListener('click', () => { state.table = !state.table; renderBody(); });
++ ov.querySelector('.sn-csv').addEventListener('click', downloadCsv);
++ document.addEventListener('keydown', onKey, true);
++ // Phones have no arrows: a horizontal swipe on the header or tiles walks through the stats.
++ let sx = null, sy = 0;
++ const head = ov.querySelector('.sn-dialog');
++ head.addEventListener('touchstart', (e) => { if (e.target.closest('.sn-plot, .sn-table')) { sx = null; return; } sx = e.touches[0].clientX; sy = e.touches[0].clientY; }, { passive: true });
++ head.addEventListener('touchend', (e) => {
++ if (sx == null) return;
++ const dx = e.changedTouches[0].clientX - sx, dy = e.changedTouches[0].clientY - sy;
++ sx = null;
++ if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);
++ }, { passive: true });
++ const plot = ov.querySelector('.sn-plot');
++ state.ro = new ResizeObserver(() => { if (state.data && !state.table) drawChart(); });
++ state.ro.observe(plot);
++ renderHead();
++ ov.querySelector('.sn-dialog').focus({ preventScroll: true });
++ }
++
++ function renderHead() {
++ const ov = state.overlay; if (!ov) return;
++ const m = current();
++ ov.querySelector('.sn-icon').className = `sn-icon fa-solid ${ICON[m.metric] || 'fa-chart-line'}`;
++ ov.querySelector('.sn-name').textContent = m.label;
++ ov.querySelector('.sn-desc').textContent = m.desc;
++ ov.querySelector('.sn-pos').textContent = `${state.index + 1} / ${state.metrics.length}`;
++ const multi = state.metrics.length > 1;
++ ov.querySelectorAll('.sn-nav').forEach((b) => { b.hidden = !multi; });
++
++ const ranges = ov.querySelector('.sn-ranges');
++ ranges.replaceChildren(...RANGES.filter((r) => !r.readingOnly || m.kind === 'reading').map((r) => {
++ const b = el('button', r.days === state.days ? 'is-on' : '', r.label);
++ b.type = 'button';
++ b.setAttribute('aria-pressed', String(r.days === state.days));
++ b.addEventListener('click', () => { if (state.days !== r.days) { state.days = r.days; renderHead(); load(); } });
++ return b;
++ }));
++ const views = m.kind === 'reading'
++ ? [['avg', 'Average'], ['peak', 'Peak']]
++ : [['daily', 'Per day'], ['total', 'Running total']];
++ const viewSeg = ov.querySelector('.sn-views');
++ viewSeg.replaceChildren(...views.map(([k, label]) => {
++ const b = el('button', k === state.view ? 'is-on' : '', label);
++ b.type = 'button';
++ b.setAttribute('aria-pressed', String(k === state.view));
++ b.addEventListener('click', () => { if (state.view !== k) { state.view = k; renderHead(); renderBody(); } });
++ return b;
++ }));
++ }
++
++ async function load() {
++ const ov = state.overlay; if (!ov) return;
++ const m = current();
++ const req = ++state.req;
++ ov.querySelector('.sn-body').classList.add('is-loading');
++ try {
++ const d = await api(`/home/stats/series/${encodeURIComponent(m.metric)}?days=${state.days}`);
++ if (req !== state.req || !state.overlay) return;
++ state.data = d;
++ } catch (err) {
++ if (req !== state.req || !state.overlay) return;
++ state.data = { error: err?.message || 'No data for this stat yet.' };
++ }
++ ov.querySelector('.sn-body').classList.remove('is-loading');
++ renderBody();
++ }
++
++ /** The values the current view plots, one per bucket (null = gap). */
++ function values() {
++ const d = state.data;
++ if (!d || !d.points) return [];
++ if (d.kind === 'reading') return d.points.map((p) => (state.view === 'peak' ? p.peak : p.value));
++ if (state.view === 'total') {
++ let run = Number(d.before) || 0;
++ return d.points.map((p) => (run += p.value));
++ }
++ return d.points.map((p) => p.value);
++ }
++ const bucketLabel = (p) => (state.data?.bucket === 'hour' ? hourLabel(p.t) : dayLabel(p.day || p.t));
++
++ function renderBody() {
++ const ov = state.overlay; if (!ov) return;
++ const d = state.data;
++ const m = current();
++ const tiles = ov.querySelector('.sn-tiles');
++ const plot = ov.querySelector('.sn-plot');
++ const table = ov.querySelector('.sn-table');
++ const note = ov.querySelector('.sn-note');
++ const tBtn = ov.querySelector('.sn-table-btn');
++ tBtn.setAttribute('aria-pressed', String(state.table));
++ tBtn.classList.toggle('is-on', state.table);
++ ov.querySelector('.sn-csv').hidden = !(d && d.points);
++
++ if (!d || d.error || !d.points) {
++ tiles.replaceChildren();
++ plot.hidden = false; table.hidden = true;
++ plot.replaceChildren(el('div', 'sn-empty', d?.error || 'Loading…'));
++ note.textContent = '';
++ return;
++ }
++
++ tiles.replaceChildren(...tileSpecs(d, m).map(([label, value, extra]) => {
++ const t = el('div', 'sn-tile');
++ t.append(el('span', 'sn-tile-label', label), el('span', 'sn-tile-value', value));
++ if (extra) t.append(extra);
++ return t;
++ }));
++
++ if (d.kind === 'reading') {
++ note.textContent = `Sampled every 5 minutes · ${d.bucket === 'hour' ? 'hourly' : 'daily'} ${state.view === 'peak' ? 'peaks' : 'averages'}${d.coverage < 100 ? ` · ${d.coverage}% of this range has samples` : ''}`;
++ } else {
++ note.textContent = `${d.source === 'media' ? 'From OpenVibe.Media · ' : ''}days in UTC`;
++ }
++
++ plot.hidden = state.table;
++ table.hidden = !state.table;
++ if (state.table) renderTable(); else drawChart();
++ }
++
++ function tileSpecs(d, m) {
++ const unit = m.unit || '';
++ if (d.kind === 'reading') {
++ let peakAt = null;
++ for (const p of d.points) if (p.peak != null && (peakAt == null || p.peak > peakAt.peak)) peakAt = p;
++ return [
++ ['Right now', exact(m.now, unit)],
++ [`Average · ${rangeName()}`, exact(d.avg, unit)],
++ ['Peak', exact(d.peak, unit), peakAt ? el('span', 'sn-tile-sub', bucketLabel(peakAt)) : null],
++ ];
++ }
++ const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });
++ const avg = d.total / d.points.length;
++ const tiles = [
++ [`Added · ${rangeName()}`, exact(d.total, unit), deltaPill(d.total, d.prev_total)],
++ ['Per day', exact(avg, unit)],
++ ['Best day', best.value > 0 ? exact(best.value, unit) : '—', best.value > 0 ? el('span', 'sn-tile-sub', dayLabel(best.day)) : null],
++ ];
++ if (d.before != null) tiles.push(['All time', exact((Number(d.before) || 0) + d.total, unit)]);
++ return tiles;
++ }
++ const rangeName = () => ({ 1: '24h', 7: '7 days', 30: '30 days', 90: '90 days', 365: 'year' }[state.days] || `${state.days}d`);
++
++ /** Change vs the previous window: an arrow and a word, never colour alone. */
++ function deltaPill(now, prev) {
++ if (prev == null) return null;
++ const span = el('span', 'sn-delta');
++ if (!prev) {
++ if (now > 0) { span.classList.add('is-up'); span.textContent = 'new'; return span; }
++ return null;
++ }
++ const pct = Math.round(((now - prev) / prev) * 100);
++ const dir = pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat';
++ span.classList.add(`is-${dir}`);
++ const arrow = el('i', `fa-solid ${dir === 'up' ? 'fa-arrow-trend-up' : dir === 'down' ? 'fa-arrow-trend-down' : 'fa-minus'}`);
++ arrow.setAttribute('aria-hidden', 'true');
++ span.append(arrow, document.createTextNode(` ${dir === 'flat' ? 'flat' : `${Math.abs(pct)}%`} vs previous ${rangeName()}`));
++ return span;
++ }
++
++ // ── Chart ──────────────────────────────────────────────────────────────────
++ const SVGNS = 'http://www.w3.org/2000/svg';
++ const svgEl = (tag, attrs) => { const e = document.createElementNS(SVGNS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };
++
++ function drawChart() {
++ const ov = state.overlay; if (!ov) return;
++ const plot = ov.querySelector('.sn-plot');
++ const d = state.data;
++ const vals = values();
++ const W = Math.max(280, Math.round(plot.clientWidth || 640));
++ const H = W < 480 ? 200 : 250;
++ const padL = 44, padR = 12, padT = 14, padB = 28;
++ const n = vals.length;
++ const present = vals.filter((v) => v != null);
++ const reading = d.kind === 'reading';
++ const asLine = reading || state.view === 'total' || n > 120;
++ const lo = 0;
++ // Average view also draws each bucket's peak as context; both share the one axis.
++ const ceiling = reading && state.view === 'avg' ? Math.max(0, ...d.points.map((p) => p.peak ?? 0)) : 0;
++ const integer = present.every((v) => Number.isInteger(v)) && !(current().unit);
++ const { top, step: tick } = niceScale(Math.max(...present, ceiling, 0), integer);
++ const x = (i) => padL + (asLine ? (n === 1 ? (W - padL - padR) / 2 : i * ((W - padL - padR) / (n - 1))) : (i + 0.5) * ((W - padL - padR) / n));
++ const y = (v) => padT + (H - padT - padB) * (1 - (v - lo) / ((top - lo) || 1));
++
++ const svg = svgEl('svg', { class: 'sn-svg', width: W, height: H, viewBox: `0 0 ${W} ${H}` });
++ const unit = current().unit || '';
++ plot.setAttribute('aria-label', `${current().label}, ${rangeName()}: ${present.length ? `from ${fmt(present[0], unit)} to ${fmt(present[present.length - 1], unit)}, highest ${fmt(Math.max(...present), unit)}` : 'no data'}. Use the table view for every value.`);
++
++ // Hairline grid on clean ticks, labels in the muted text colour.
++ for (let k = 0; k * tick <= top + 1e-9; k++) {
++ const v = k * tick;
++ const yy = Math.round(y(v)) + 0.5;
++ svg.append(svgEl('line', { class: 'sn-grid', x1: padL, x2: W - padR, y1: yy, y2: yy }));
++ const t = svgEl('text', { class: 'sn-axis', x: padL - 8, y: yy + 3.5, 'text-anchor': 'end' });
++ t.textContent = fmt(v);
++ svg.append(t);
++ }
++ // Sparse x labels: about one per 90px.
++ const every = Math.max(1, Math.ceil(n / Math.max(2, Math.floor((W - padL - padR) / 90))));
++ d.points.forEach((p, i) => {
++ if (i % every !== 0 && i !== n - 1) return;
++ if (i === n - 1 && i % every !== 0 && (n - 1) % every < every * 0.6) return; // keep the last label from colliding
++ const t = svgEl('text', { class: 'sn-axis', x: i === n - 1 && !asLine ? x(i) + ((W - padL - padR) / n) / 2 : x(i), y: H - 9, 'text-anchor': i === 0 && asLine ? 'start' : i === n - 1 ? 'end' : 'middle' });
++ t.textContent = d.bucket === 'hour' ? hourLabel(p.t) : dayLabel(p.day || p.t);
++ svg.append(t);
++ });
++
++ const marks = svgEl('g', { class: 'sn-marks' });
++ if (asLine) {
++ // Segments break at gaps (no samples) instead of diving to zero.
++ let path = '', area = '', run = [];
++ const flush = () => {
++ if (!run.length) return;
++ path += 'M' + run.map((i) => `${x(i).toFixed(1)},${y(vals[i]).toFixed(1)}`).join('L');
++ area += `M${x(run[0]).toFixed(1)},${y(lo).toFixed(1)}L` + run.map((i) => `${x(i).toFixed(1)},${y(vals[i]).toFixed(1)}`).join('L') + `L${x(run[run.length - 1]).toFixed(1)},${y(lo).toFixed(1)}Z`;
++ run = [];
++ };
++ vals.forEach((v, i) => { if (v == null) flush(); else run.push(i); });
++ flush();
++ marks.append(svgEl('path', { class: 'sn-area', d: area }), svgEl('path', { class: 'sn-line', d: path }));
++ if (reading && state.view === 'avg') {
++ // Context: the peak for each bucket, in gray behind nothing but labelled in the legend.
++ let pk = '', seg = [];
++ const fl = () => { if (seg.length) pk += 'M' + seg.map((i) => `${x(i).toFixed(1)},${y(d.points[i].peak).toFixed(1)}`).join('L'); seg = []; };
++ d.points.forEach((p, i) => { if (p.peak == null) fl(); else seg.push(i); });
++ fl();
++ marks.insertBefore(svgEl('path', { class: 'sn-line sn-line--context', d: pk }), marks.firstChild);
++ }
++ // End marker on the latest value.
++ const last = vals.length - 1 - [...vals].reverse().findIndex((v) => v != null);
++ if (last >= 0 && last < n) {
++ marks.append(svgEl('circle', { class: 'sn-dot', cx: x(last), cy: y(vals[last]), r: 4 }));
++ }
++ } else {
++ const slot = (W - padL - padR) / n;
++ const bw = Math.max(1, Math.min(24, slot - 2));
++ vals.forEach((v, i) => {
++ if (!v) return;
++ const x0 = x(i) - bw / 2, y0 = y(v), yb = y(0), h = Math.max(1, yb - y0), r = Math.min(4, bw / 2, h);
++ // 4px rounded data end, square at the baseline.
++ const dAttr = `M${x0},${yb}V${y0 + r}Q${x0},${y0} ${x0 + r},${y0}H${x0 + bw - r}Q${x0 + bw},${y0} ${x0 + bw},${y0 + r}V${yb}Z`;
++ marks.append(svgEl('path', { class: 'sn-bar', d: dAttr, 'data-i': i, style: `animation-delay:${Math.min(0.4, i * (0.4 / n)).toFixed(3)}s` }));
++ });
++ }
++ svg.append(marks);
++
++ // Hover layer: crosshair for lines, lifted bar for columns. The whole plot is the hit area.
++ const cross = svgEl('line', { class: 'sn-cross', x1: 0, x2: 0, y1: padT, y2: H - padB, visibility: 'hidden' });
++ const hot = svgEl('circle', { class: 'sn-dot sn-dot--hot', r: 5, visibility: 'hidden' });
++ svg.append(cross, hot);
++ plot.replaceChildren(svg);
++ if (reading && state.view === 'avg') plot.append(legend());
++
++ let active = -1;
++ const tip = ov.querySelector('.sn-tip');
++ const show = (i) => {
++ if (i < 0 || i >= n) return;
++ active = i;
++ const p = d.points[i];
++ const v = vals[i];
++ marks.querySelectorAll('.sn-bar.is-hot').forEach((b) => b.classList.remove('is-hot'));
++ if (asLine) {
++ cross.setAttribute('x1', x(i)); cross.setAttribute('x2', x(i)); cross.setAttribute('visibility', 'visible');
++ if (v != null) { hot.setAttribute('cx', x(i)); hot.setAttribute('cy', y(v)); hot.setAttribute('visibility', 'visible'); } else hot.setAttribute('visibility', 'hidden');
++ } else {
++ marks.querySelector(`.sn-bar[data-i="${i}"]`)?.classList.add('is-hot');
++ }
++ const val = el('div', 'sn-tip-value', v == null ? 'no samples' : exact(v, unit));
++ const rows = [val, el('div', 'sn-tip-label', bucketLabel(p))];
++ if (reading && state.view === 'avg' && p.peak != null) rows.push(el('div', 'sn-tip-extra', `peak ${exact(p.peak, unit)}`));
++ if (!reading && state.view === 'total') rows.push(el('div', 'sn-tip-extra', `+${exact(p.value, unit)} that day`));
++ tip.replaceChildren(...rows);
++ tip.hidden = false;
++ const px = x(i), rect = plot.getBoundingClientRect(), body = plot.parentElement.getBoundingClientRect();
++ const left = Math.min(Math.max(px + (rect.left - body.left) - tip.offsetWidth / 2, 4), body.width - tip.offsetWidth - 4);
++ const yTop = (v == null ? padT : y(v)) + (rect.top - body.top) - tip.offsetHeight - 12;
++ tip.style.transform = `translate(${Math.round(left)}px, ${Math.round(Math.max(0, yTop))}px)`;
++ };
++ const hide = () => {
++ active = -1; tip.hidden = true;
++ cross.setAttribute('visibility', 'hidden'); hot.setAttribute('visibility', 'hidden');
++ marks.querySelectorAll('.sn-bar.is-hot').forEach((b) => b.classList.remove('is-hot'));
++ };
++ const indexAt = (clientX) => {
++ const r = svg.getBoundingClientRect();
++ const px = clientX - r.left;
++ const i = asLine ? Math.round(((px - padL) / (W - padL - padR)) * (n - 1)) : Math.floor(((px - padL) / (W - padL - padR)) * n);
++ return Math.max(0, Math.min(n - 1, i));
++ };
++ svg.addEventListener('pointermove', (e) => show(indexAt(e.clientX)));
++ svg.addEventListener('pointerleave', hide);
++ plot.onkeydown = (e) => {
++ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') return;
++ e.preventDefault(); e.stopPropagation();
++ const i = e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : (active < 0 ? n - 1 : active + (e.key === 'ArrowRight' ? 1 : -1));
++ show(Math.max(0, Math.min(n - 1, i)));
++ };
++ plot.onblur = hide;
++ }
++
++ function legend() {
++ const lg = el('div', 'sn-legend');
++ const item = (cls, text) => { const s = el('span', 'sn-legend-item'); s.append(el('i', cls), document.createTextNode(text)); return s; };
++ lg.append(item('sn-key sn-key--avg', 'Average'), item('sn-key sn-key--peak', 'Peak'));
++ return lg;
++ }
++
++ function renderTable() {
++ const ov = state.overlay; if (!ov) return;
++ const d = state.data;
++ const unit = current().unit || '';
++ const wrap = ov.querySelector('.sn-table');
++ const t = el('table');
++ const head = el('tr');
++ const cols = d.kind === 'reading' ? [d.bucket === 'hour' ? 'Hour' : 'Day', 'Average', 'Peak'] : ['Day', 'Added', 'Running total'];
++ cols.forEach((c) => head.append(el('th', null, c)));
++ const thead = el('thead'); thead.append(head);
++ const tbody = el('tbody');
++ let run = Number(d.before) || 0;
++ const rows = d.points.map((p, i) => {
++ const tr = el('tr');
++ tr.append(el('td', null, bucketLabel(p)));
++ if (d.kind === 'reading') {
++ tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(p.peak, unit)));
++ } else {
++ run += p.value;
++ tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(run, unit)));
++ }
++ return tr;
++ });
++ rows.reverse().forEach((r) => tbody.append(r)); // newest first
++ t.append(thead, tbody);
++ wrap.replaceChildren(t);
++ }
++
++ function downloadCsv() {
++ const d = state.data; if (!d || !d.points) return;
++ const m = current();
++ const lines = [d.kind === 'reading' ? 'bucket,average,peak' : 'day,value,running_total'];
++ let run = Number(d.before) || 0;
++ for (const p of d.points) {
++ if (d.kind === 'reading') lines.push(`${p.t},${p.value ?? ''},${p.peak ?? ''}`);
++ else { run += p.value; lines.push(`${p.day},${p.value},${Math.round(run * 100) / 100}`); }
++ }
++ const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' });
++ const a = el('a');
++ a.href = URL.createObjectURL(blob);
++ a.download = `openvibe-${m.metric}-${state.days}d.csv`;
++ document.body.append(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
++ }
++
++ window.openStatsNerds = open;
++})();
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 8bd9c9e9..e4c27e99 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -3962,7 +3962,9 @@ function recordViewerSample() {
_ensureViewerSamples();
const r = get(`SELECT COALESCE(SUM(viewer_count),0) AS v, COUNT(*) AS n FROM streams WHERE is_live = 1`) || { v: 0, n: 0 };
run('INSERT INTO viewer_samples (viewers, live_streams) VALUES (?, ?)', [r.v || 0, r.n || 0]);
-- run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);
++ // A year of five-minute samples is ~105k small rows; it is what the "over time" charts for the
++ // two live readings are drawn from.
++ run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-400 days')`);
return r;
}
/**
@@@ -4089,7 +4091,7 @@ const HOME_SERIES = {
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' },
};
--const HOME_SERIES_KEYS = Object.keys(HOME_SERIES);
++const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];
function getHomeStatSeries(metric, days = 30) {
const def = HOME_SERIES[metric];
@@@ -4109,7 +4111,54 @@ function getHomeStatSeries(metric, days = 30) {
points.push({ day, value: Number((byDay.get(day) || 0).toFixed(2)) });
}
const total = Number(points.reduce((a, p) => a + p.value, 0).toFixed(2));
-- return { metric, days, points, total, peak: Math.max(0, ...points.map(p => p.value)) };
++ // `before`: everything up to the window, so a running total starts at the real all-time figure.
++ // `prev_total`: the same-length window just before this one, for "vs previous period".
++ const scalar = (extra, params) => {
++ const w = [...(def.where ? [def.where] : []), ...(def.vibesReset ? [`${def.ts} >= '${vibesStatsSince()}'`] : []), extra];
++ try { return Number(get(`SELECT ${def.agg} AS value FROM ${def.table} WHERE ${w.join(' AND ')}`, params)?.value) || 0; } catch { return 0; }
++ };
++ const before = scalar(`${def.ts} < datetime('now', ?)`, [`-${days - 1} days`]);
++ const prevTotal = scalar(`${def.ts} >= datetime('now', ?) AND ${def.ts} < datetime('now', ?)`, [`-${2 * days - 1} days`, `-${days - 1} days`]);
++ return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map(p => p.value)),
++ before: Number(before.toFixed(2)), prev_total: Number(prevTotal.toFixed(2)) };
++}
++
++/**
++ * The two live readings (streams live, people watching) over time, from the five-minute sampler.
++ * Unlike counters they do not add up across a day, so each bucket carries the average reading
++ * and the peak. Up to a week is shown per hour, longer ranges per day. Buckets with no samples
++ * (before the sampler existed, or while the server was down) are null, not zero.
++ */
++const READING_SERIES = { liveNow: 'live_streams', viewersNow: 'viewers' };
++function getReadingSeries(metric, days = 7) {
++ const col = READING_SERIES[metric];
++ if (!col) return null;
++ _ensureViewerSamples();
++ days = Math.max(1, Math.min(365, parseInt(days, 10) || 7));
++ const hourly = days <= 7;
++ const fmt = hourly ? '%Y-%m-%dT%H:00:00Z' : '%Y-%m-%d';
++ let rows = [];
++ try {
++ rows = all(`SELECT strftime('${fmt}', sampled_at) AS b, AVG(${col}) AS avg, MAX(${col}) AS peak, COUNT(*) AS n
++ FROM viewer_samples WHERE sampled_at >= datetime('now', ?) GROUP BY b`, [hourly ? `-${days * 24 - 1} hours` : `-${days - 1} days`]);
++ } catch { rows = []; }
++ const byB = new Map(rows.map(r => [r.b, r]));
++ const points = [];
++ const now = Date.now();
++ const n = hourly ? days * 24 : days;
++ for (let i = n - 1; i >= 0; i--) {
++ const d = new Date(now - i * (hourly ? 3600000 : 86400000));
++ const key = hourly ? d.toISOString().slice(0, 13) + ':00:00Z' : d.toISOString().slice(0, 10);
++ const r = byB.get(key);
++ points.push({ t: key, value: r ? Math.round(Number(r.avg) * 10) / 10 : null, peak: r ? Number(r.peak) : null });
++ }
++ const have = points.filter(p => p.value != null);
++ return {
++ metric, kind: 'reading', bucket: hourly ? 'hour' : 'day', days, points,
++ peak: have.length ? Math.max(...have.map(p => p.peak)) : 0,
++ avg: have.length ? Math.round(have.reduce((a, p) => a + p.value, 0) / have.length * 10) / 10 : 0,
++ coverage: Math.round((have.length / points.length) * 100),
++ };
}
function getHomeStats() {
@@@ -8315,7 +8364,7 @@ module.exports = {
getDb, initDb, run, get, all, close,
mergeChatMessageMetadata, getTimelineSpeechSince,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
-- recordViewerSample, getViewerTrend, getHomePulse, getActiveGoalsForUsers,
++ recordViewerSample, getViewerTrend, getReadingSeries, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
setChannelAlertSound, getChannelAlertSoundsByUser,
// Users
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex ab3fdad9..bab0caba 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -171,10 +171,29 @@ async function _mediaStatsCached() {
}
// GET /api/home/stats/series/:metric?days=30 — daily values behind a hero stat.
--router.get('/stats/series/:metric', (req, res) => {
-- const series = db.getHomeStatSeries(String(req.params.metric), req.query.days);
-- if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: db.HOME_SERIES_KEYS });
++// VODs, clips, pastes and archived hours live in OpenVibe.Media; the local tables stopped at the split.
++const MEDIA_SERIES = new Set(['vods', 'clips', 'pastes', 'hours']);
++const _seriesCache = new Map(); // `${metric}:${days}` → { at, data }
++router.get('/stats/series/:metric', async (req, res) => {
++ const metric = String(req.params.metric);
++ const days = Math.max(1, Math.min(365, parseInt(req.query.days, 10) || 30));
++ const key = `${metric}:${days}`;
++ const hit = _seriesCache.get(key);
res.set('Cache-Control', 'public, max-age=120');
++ if (hit && Date.now() - hit.at < 120_000) return res.json(hit.data);
++ let series = null;
++ if (MEDIA_SERIES.has(metric)) {
++ try {
++ const m = await media.request('GET', `/stats/series/${metric}?days=${days}`);
++ if (m && Array.isArray(m.points)) series = { ...m, kind: 'count', peak: Math.max(0, ...m.points.map(p => p.value)), source: 'media' };
++ } catch (err) { console.warn('[Home] Media series unavailable:', err.message); }
++ if (!series) return res.status(503).json({ error: 'Media stats are unavailable right now' });
++ } else {
++ series = db.getReadingSeries(metric, days) || db.getHomeStatSeries(metric, days);
++ }
++ if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: [...db.HOME_SERIES_KEYS, ...MEDIA_SERIES] });
++ if (_seriesCache.size > 200) _seriesCache.clear();
++ _seriesCache.set(key, { at: Date.now(), data: series });
res.json(series);
});
ddiff --git a/test/home-stats-series.test.js b/test/home-stats-series.test.js
iindex fc6a51e7..108bfefa 100644
---- a/test/home-stats-series.test.js
++++ b/test/home-stats-series.test.js
@@@ -28,8 +28,28 @@ assert.strictEqual(users7.points[6 - 1].value, 1, 'bob yesterday');
assert.strictEqual(users7.points[6].value, 0, 'nobody today');
assert.strictEqual(db.getHomeStatSeries('users', 2).total, 1, 'window shrinks the total');
assert.strictEqual(db.getHomeStatSeries('users', 9999).days, 365, 'days are clamped');
++assert.strictEqual(db.getHomeStatSeries('users', 2).before, 1, 'before: alice, ahead of a 2-day window');
++assert.strictEqual(db.getHomeStatSeries('users', 2).prev_total, 1, 'prev_total: the 2 days before that');
console.log('✅ daily series are zero-filled and windowed');
++// ── readings: live streams / viewers from the five-minute sampler ──
++db.recordViewerSample();
++db.run("INSERT INTO viewer_samples (sampled_at, viewers, live_streams) VALUES (datetime('now', '-2 hours'), 10, 2), (datetime('now', '-2 hours'), 20, 4)");
++db.run("INSERT INTO viewer_samples (sampled_at, viewers, live_streams) VALUES (datetime('now', '-500 days'), 1, 1)");
++const v = db.getReadingSeries('viewersNow', 1);
++assert.strictEqual(v.kind, 'reading');
++assert.strictEqual(v.bucket, 'hour');
++assert.strictEqual(v.points.length, 24);
++const twoAgo = v.points[v.points.length - 3];
++assert.strictEqual(twoAgo.value, 15, 'hour bucket averages its samples');
++assert.strictEqual(twoAgo.peak, 20);
++assert.strictEqual(v.points[0].value, null, 'no samples → null, not zero');
++assert.strictEqual(db.getReadingSeries('liveNow', 30).bucket, 'day');
++assert.strictEqual(db.getReadingSeries('users', 7), null);
++db.recordViewerSample();
++assert.strictEqual(db.get("SELECT COUNT(*) AS n FROM viewer_samples WHERE sampled_at < datetime('now', '-400 days')").n, 0, 'retention trims past 400 days');
++console.log('✅ reading series are bucketed, null-filled and retained for 400 days');
++
// ── vibes: test money before the reset never counts ──
db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 500, 'donation', 'completed', datetime('now', '-10 days'))", [a, b]);
db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 200, 'donation', 'completed', datetime('now', '-2 days'))", [b, a]);