openvibers/openvibe.live Commits
Stats for nerds: every hero number opens its history again
Changes
@@@ -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) {nnew file mode 100644@@@ -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; }++}@@@ -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 thetile'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); }@@@ -252,6 +252,17 @@"js": ["/js/app-docs.js"]++ },++ "statsNerds": {++ "css": [++ "/css/features/stats.css"++ ],++ "js": [++ "/js/stats-nerds.js"++ ],++ "stubs": [++ "openStatsNerds"++ ]}},"routes": [@@@ -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">×</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;nnew file mode 100644@@@ -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;++})();@@@ -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@@@ -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);});@@@ -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]);
@@ -501,30 +501,12 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -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 { 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-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-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) {
@media (max-width: 720px) {
.hero-stat-label { white-space: normal; line-height: 1.15; }
.hero-stat-label { white-space: normal; line-height: 1.15; }
.hero-stat-chart-hint { opacity: 0.6; }
.hero-stat-chart-hint { opacity: 0.6; }
.hero-tip { max-width: calc(100vw - 24px); }
.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; }
.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; }
.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-stats { min-height: 246px; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip[hidden] { display: none !important; }
@@ -550,13 +532,27 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -550,13 +532,27 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-more {
.hero-stat-more {
display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
padding: 8px 12px 9px; cursor: pointer; font: inherit;
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);
background: none; border: 0; border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
transition: color 0.2s, background 0.2s;
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 { border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent); }
.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
@media (max-width: 640px) {
@media (max-width: 640px) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -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; }
+}
@@ -10846,14 +10846,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -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 { 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 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-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) {
@media (max-width: 720px) {
/* (The counterpart !important rule lived here. It forced display:inline-block, which beat the
/* (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.) */
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; }
@@ -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. */
to 602px and then snapped to its real 976px. */
.hero-bg-layer.active { animation: none !important; }
.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; } }
@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); }
.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }
@@ -252,6 +252,17 @@
@@ -252,6 +252,17 @@
"js": [
"js": [
"/js/app-docs.js"
"/js/app-docs.js"
]
]
+ },
+ "statsNerds": {
+ "css": [
+ "/css/features/stats.css"
+ ],
+ "js": [
+ "/js/stats-nerds.js"
+ ],
+ "stubs": [
+ "openStatsNerds"
+ ]
}
}
},
},
"routes": [
"routes": [
@@ -270,8 +270,8 @@ function _renderHeroStats(stats) {
@@ -270,8 +270,8 @@ function _renderHeroStats(stats) {
// ── Right now ────────────────────────────────────────────────
// ── Right now ────────────────────────────────────────────────
const 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: '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' },
{ 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) {
@@ -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-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 },
{ 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' },
{ 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) {
@@ -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
// 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.
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
const HEADLINE = [
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
// 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
// two dozen extra chips before the page had finished loading, for a view the reader asked for
@@ -418,7 +418,7 @@ function _renderHeroStats(stats) {
@@ -418,7 +418,7 @@ function _renderHeroStats(stats) {
wrap.innerHTML = `
wrap.innerHTML = `
<div class="hero-stat-strip">${HEADLINE.map(chip).join('')}</div>
<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">
<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>
</button>
<div class="hero-stat-full" id="hero-stat-full" ${open ? '' : 'hidden'}>
<div class="hero-stat-full" id="hero-stat-full" ${open ? '' : 'hidden'}>
${groups.filter(g => g.rows.length).map(g => `
${groups.filter(g => g.rows.length).map(g => `
@@ -432,7 +432,7 @@ function _renderHeroStats(stats) {
@@ -432,7 +432,7 @@ function _renderHeroStats(stats) {
const full = wrap.querySelector('#hero-stat-full');
const full = wrap.querySelector('#hero-stat-full');
const strip = wrap.querySelector('.hero-stat-strip');
const strip = wrap.querySelector('.hero-stat-strip');
const total = groups.reduce((n, g) => n + g.rows.length, 0);
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(); };
const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };
sync();
sync();
@@ -615,65 +615,15 @@ function _heroBindInteractions(wrap) {
@@ -615,65 +615,15 @@ function _heroBindInteractions(wrap) {
chip.addEventListener('focus', () => _heroTipShow(chip));
chip.addEventListener('focus', () => _heroTipShow(chip));
chip.addEventListener('blur', _heroTipHide);
chip.addEventListener('blur', _heroTipHide);
if (chip.dataset.metric) {
if (chip.dataset.metric) {
- const open = () => { _heroTipHide(); _heroStatModal(chip); };
+ const open = () => { _heroTipHide(); if (typeof openStatsNerds === 'function') openStatsNerds(chip); };
chip.addEventListener('click', open);
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(); } });
chip.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
}
}
});
});
window.addEventListener('scroll', _heroTipHide, { passive: true });
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">×</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 ─────────────────────────────
// ── Hero floating thumbnail collage ─────────────────────────────
const _HERO_BADGE = { live: 'LIVE', vod: 'VOD', clip: 'CLIP', paste: 'PASTE', moment: 'AI MOMENT' };
const _HERO_BADGE = { live: 'LIVE', vod: 'VOD', clip: 'CLIP', paste: 'PASTE', moment: 'AI MOMENT' };
let _heroCollageTimer = null;
let _heroCollageTimer = null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,526 @@
@@ -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;
+})();
@@ -3962,7 +3962,9 @@ function recordViewerSample() {
@@ -3962,7 +3962,9 @@ function recordViewerSample() {
_ensureViewerSamples();
_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 };
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('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;
return r;
}
}
/**
/**
@@ -4089,7 +4091,7 @@ const HOME_SERIES = {
@@ -4089,7 +4091,7 @@ const HOME_SERIES = {
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' },
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) {
function getHomeStatSeries(metric, days = 30) {
const def = HOME_SERIES[metric];
const def = HOME_SERIES[metric];
@@ -4109,7 +4111,54 @@ function getHomeStatSeries(metric, days = 30) {
@@ -4109,7 +4111,54 @@ function getHomeStatSeries(metric, days = 30) {
points.push({ day, value: Number((byDay.get(day) || 0).toFixed(2)) });
points.push({ day, value: Number((byDay.get(day) || 0).toFixed(2)) });
}
}
const total = Number(points.reduce((a, p) => a + p.value, 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() {
function getHomeStats() {
@@ -8315,7 +8364,7 @@ module.exports = {
@@ -8315,7 +8364,7 @@ module.exports = {
getDb, initDb, run, get, all, close,
getDb, initDb, run, get, all, close,
mergeChatMessageMetadata, getTimelineSpeechSince,
mergeChatMessageMetadata, getTimelineSpeechSince,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
- recordViewerSample, getViewerTrend, getHomePulse, getActiveGoalsForUsers,
+ recordViewerSample, getViewerTrend, getReadingSeries, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
setChannelAlertSound, getChannelAlertSoundsByUser,
setChannelAlertSound, getChannelAlertSoundsByUser,
// Users
// Users
@@ -171,10 +171,29 @@ async function _mediaStatsCached() {
@@ -171,10 +171,29 @@ async function _mediaStatsCached() {
}
}
// GET /api/home/stats/series/:metric?days=30 — daily values behind a hero stat.
// 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');
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);
res.json(series);
});
});
@@ -28,8 +28,28 @@ assert.strictEqual(users7.points[6 - 1].value, 1, 'bob yesterday');
@@ -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(users7.points[6].value, 0, 'nobody today');
assert.strictEqual(db.getHomeStatSeries('users', 2).total, 1, 'window shrinks the total');
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', 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');
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 ──
// ── 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 (?, ?, 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]);
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]);