openvibers/openvibe.live Commits
Replace home secret with daily quest and live stats (#715)
Changes
@@@ -189,6 +189,7 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400# recycling, subscriptions, balances and entitlements with its service token (audience openvibe.billing). There is no# other mode (BILLING_AUTHORITY is no longer read).OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600++OV_QUEST_INTERNAL_URL=http://127.0.0.1:4980OV_BILLING_PUBLIC_URL=https://billing.openvibe.network# Cosmetics live in OpenVibe.Inventory (ADR-054 §8): live.* items issued by service:live, Live's item ids as aliases.nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-light.jpg differ@@@ -196,7 +196,6 @@".hero-bg-layer.active",".hero-collage",".hero-content",-- ".hero-egg-titles",".hero-float",".hero-float img",".hero-float--clip .hero-float-badge",@@@ -26,47 +26,27 @@color: var(--accent-light);}.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }--.hero-egg { max-width: 520px; margin: 0 auto 22px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,0.3); text-align: left; overflow: hidden; }--.hero-egg-head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: none; border: 0; cursor: pointer; color: var(--text-primary); font: inherit; }--.hero-egg-icon { font-size: 1.3rem; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); animation: eggWobble 3.5s var(--ov-ease-in-out, ease-in-out) infinite; }--.hero-egg-kicker { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }--.hero-egg-title { font-size: 0.95rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; color: var(--text-primary); }--.hero-egg-status { font-size: 0.72rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); padding: 3px 9px; border-radius: 999px; white-space: nowrap; }--.hero-egg-status.solved { color: #06d6a0; background: color-mix(in srgb, #06d6a0 18%, transparent); }--.hero-egg-chev { color: var(--text-muted); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }--.hero-egg.open .hero-egg-chev { transform: rotate(180deg); }--.hero-egg-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--ov-dur-3, .35s) var(--ov-ease-out, ease); }--.hero-egg.open .hero-egg-body { grid-template-rows: 1fr; }--.hero-egg-inner { min-height: 0; overflow: hidden; padding: 0 14px; }--.hero-egg.open .hero-egg-inner { padding: 0 14px 14px; }--.hero-egg-intro { font-size: 0.78rem; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.45; }--.hero-egg-slots { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }--.egg-slot { width: 38px; height: 42px; border-radius: 10px; display: grid; place-items: center; font-size: 1.15rem; font-weight: 900; color: var(--text-primary); background: var(--bg-tertiary); border: 1px solid var(--border); transition: transform var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), background var(--ov-dur-2, .15s); }--.egg-slot.is-next { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 60%, transparent); }--.hero-egg-slots.is-wrong .egg-slot { border-color: #ff5a5f; background: color-mix(in srgb, #ff5a5f 18%, var(--bg-tertiary)); animation: eggShakeSlot var(--ov-dur-4, .45s); }--.hero-egg-slots.is-right .egg-slot, .hero-egg-slots.is-solved .egg-slot { border-color: #06d6a0; background: color-mix(in srgb, #06d6a0 20%, var(--bg-tertiary)); }--.hero-egg-clues { margin: 0 0 12px; padding: 0; list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; counter-reset: clue; }--.hero-egg-clues li { display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px; font-size: 0.84rem; color: var(--text-primary); padding: 6px 8px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); }--.egg-clue-n { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 0.75rem; font-weight: 800; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }--.egg-clue-text { font-style: italic; line-height: 1.35; }--.egg-clue-key { font-weight: 900; font-size: 0.95rem; color: #06d6a0; background: color-mix(in srgb, #06d6a0 15%, transparent); border-radius: 8px; padding: 2px 9px; }--.hero-egg-clues li.is-revealed { border-color: color-mix(in srgb, #06d6a0 45%, var(--border)); }--.hero-egg-pad { display: grid; gap: 6px; margin-bottom: 10px; }--.hero-egg-pad-arrows { display: flex; gap: 6px; flex-wrap: wrap; }--.hero-egg-pad-letters { display: grid; grid-template-columns: repeat(13, 1fr); gap: 4px; }--.egg-key:hover { border-color: var(--accent); }--.egg-key:active { transform: scale(0.92); background: color-mix(in srgb, var(--accent) 25%, var(--bg-tertiary)); }--.egg-key--ctl { margin-left: auto; color: var(--text-secondary); }--.egg-key--ctl + .egg-key--ctl { margin-left: 0; }--.hero-egg-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }--.hero-egg-reveal { border: 1px solid color-mix(in srgb, #fbbf24 45%, var(--border)); background: color-mix(in srgb, #fbbf24 10%, transparent); color: var(--text-primary); border-radius: 999px; padding: 5px 11px; font: inherit; font-size: 0.76rem; font-weight: 700; cursor: pointer; }--.hero-egg-reveal:disabled { opacity: 0.6; cursor: default; }--.hero-egg-reveal:not(:disabled):hover { border-color: #fbbf24; }--.hero-egg-meta { font-size: 0.72rem; color: var(--text-muted); }--@media (max-width: 480px) { .hero-egg-pad-letters { grid-template-columns: repeat(9, 1fr); } .egg-slot { width: 32px; height: 38px; } }--body.egg-shake { animation: eggShake 0.9s var(--ov-ease-out, cubic-bezier(.36,.07,.19,.97)) both; }--body.egg-rainbow { animation: eggRainbow 2.6s linear; }--@media (prefers-reduced-motion: reduce) { .egg-confetti, body.egg-shake, body.egg-rainbow { animation: none !important; } .egg-confetti { display: none; } }++.home-daily-quest { max-width: 520px; margin: 0 auto 22px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: 16px; background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,.18); text-align: left; overflow: hidden; }++.home-daily-quest[hidden], .home-daily-body[hidden] { display: none; }++.home-daily-head { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 56px; padding: 9px 14px; background: none; border: 0; color: var(--text-primary); font: inherit; text-align: left; cursor: pointer; }++.home-daily-titles { display: flex; flex: 1; flex-direction: column; min-width: 0; }++.home-daily-kicker { color: var(--accent-light, var(--accent)); font-size: .65rem; font-weight: 800; letter-spacing: .09em; }++/* The quest's name in full (two lines on a phone, never "Make your way around …"), and a three-part bar for the steps. */++.home-daily-title { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-size: .95rem; font-weight: 800; line-height: 1.3; }++.home-daily-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 160px; margin-top: 6px; }++.home-daily-bar i { height: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary) 12%, transparent); transition: background .4s ease; }++.home-daily-quest[data-progress="1"] .home-daily-bar i:nth-child(-n+1), .home-daily-quest[data-progress="2"] .home-daily-bar i:nth-child(-n+2), .home-daily-quest[data-progress="3"] .home-daily-bar i { background: var(--accent-light, var(--accent)); }++.home-daily-quest[data-progress="3"] .home-daily-bar i { background: var(--success, #22c55e); }++.home-daily-chip { flex: none; padding: 5px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .72rem; font-weight: 700; }++.home-daily-chev { color: var(--text-muted); transition: transform .2s; }++.home-daily-quest.open .home-daily-chev { transform: rotate(180deg); }++.home-daily-body { padding: 0 14px 14px; }++.home-daily-steps { list-style: none; padding: 0; margin: 0 0 10px; }++.home-daily-steps li { display: flex; align-items: center; gap: 10px; min-height: 52px; border-top: 1px solid var(--border); font-size: .85rem; }++.home-daily-steps li > span:nth-child(2) { flex: 1; }++.home-daily-check { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 50%; background: var(--success, #22c55e); color: #fff; font-size: .75rem; font-weight: 900; }++.home-daily-steps li.is-done > span:nth-child(2) { color: var(--text-muted); }++.home-daily-action { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 10px; background: var(--accent); color: var(--bg-primary); font-weight: 800; text-decoration: none; }@media (max-width: 599px) {/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */@@@ -94,6 +74,19 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);}.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }++.recent-streamer-card { display: flex; flex-direction: column; height: 100%; min-height: 322px; }++.recent-streamer-head { min-height: 58px; padding: 8px 12px; flex-wrap: nowrap; }++.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }++.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }++.recent-streamer-facts { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 12px 8px; min-height: 49px; }++.recent-streamer-chip { align-self: start; padding: 4px 7px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .75rem; line-height: 1.2; }++.recent-streamer-summary { margin: 0 12px; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.recent-streamer-summary.expanded { display: block; }++.recent-streamer-more { align-self: start; min-width: 44px; min-height: 44px; margin: 0 8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: .78rem; cursor: pointer; }++.recent-streamer-history { margin-top: auto; padding-bottom: 0; }++.recent-stream-row { min-height: 61px; }++.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }++.recent-stream-row .streamer-group-stream-info { display: flex; flex-direction: column; }.streamer-group-header { padding: 12px 14px 0; }.streamer-group-identity {display: flex; align-items: center; gap: 8px;@@@ -177,11 +170,22 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }/* Until shipped.js fills it, the line keeps its height without showing an empty pill, so the stats below stay put. */.hero-latest:empty { visibility: hidden; min-height: 33px; }++.pulse-grid { display: flex; flex-wrap: nowrap; overflow-x: auto; gap: 12px; scroll-snap-type: x mandatory; scrollbar-width: none; padding-block: 2px 8px; }++.pulse-grid::-webkit-scrollbar { display: none; }++.pulse-grid > .pulse-fact-card { flex: 0 0 230px; min-height: 192px; scroll-snap-align: start; }++.pulse-fact-card { display: flex; flex-direction: column; gap: 8px; }++.pulse-fact-identity { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 750; }++.pulse-fact-identity > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.pulse-fact-face { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border-radius: 50%; overflow: hidden; background: var(--accent); color: var(--bg-primary); font-weight: 800; }++.pulse-fact-face img { width: 100%; height: 100%; object-fit: cover; }++.pulse-fact-number { margin-top: auto; font-size: clamp(2rem, 3vw, 2.6rem); line-height: 1; font-variant-numeric: tabular-nums; }++.pulse-fact-context { color: var(--text-secondary); font-size: .78rem; }.pulse-card {display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);background: var(--bg-secondary); border: 1px solid var(--border);transition: border-color var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease), transform var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);}++.pulse-card.pulse-fact-card { display: flex; flex-direction: column; gap: 8px; }.pulse-kicker { font-size: 0.75rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }.pulse-title { font-weight: 700; margin-bottom: 7px; }.pulse-sub { font-size: 0.78rem; color: var(--text-muted); margin-top: 5px; }@@@ -1229,10 +1233,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }the word in the middle of its own box. */#page-home .hero-philosophy-btn { min-height: 44px; display: inline-flex; align-items: center; }#page-home .hero-philosophy-link { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }-- /* The easter-egg keypad, open: every key is 44 px on the key itself. The letter grid reflows to as many-- 44 px columns as the panel holds (seven on a 390 px phone), so no key is narrower than a fingertip. */-- #page-home .hero-egg-pad-letters { grid-template-columns: repeat(auto-fit, minmax(44px, 1fr)); gap: 3px; }-- #page-home .hero-egg-reveal { min-height: 44px; padding: 0 16px; display: inline-flex; align-items: center; gap: 8px; }}@media (max-width: 759px) {@@@ -471,29 +471,6 @@ body.ov-nav-overlay .navbar.ovnav-top {transition: all var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease);}--/* ── Daily easter-egg hero widget ─────────────────────────────── */--@keyframes eggWobble { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }--.hero-egg-titles { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; flex: 1; }--/* Body opens to its real height (the old 340px cap clipped the third clue) */--.egg-slot.is-filled { border-color: color-mix(in srgb, var(--accent) 70%, transparent); background: color-mix(in srgb, var(--accent) 16%, var(--bg-tertiary)); animation: eggSlotIn var(--ov-dur-2, .18s) var(--ov-ease-spring, cubic-bezier(.2,1.5,.3,1)); }--@keyframes eggSlotIn { from { transform: scale(0.7); } to { transform: scale(1); } }--@keyframes eggShakeSlot { 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } }--.egg-key { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-weight: 800; cursor: pointer; transition: transform var(--ov-dur-1, .1s), background var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s); }--.egg-key--letter { min-width: 0; height: 30px; padding: 0; font-size: 0.8rem; text-transform: uppercase; }--/* Touch (2026-11-06 phone pass, round two): every key of the easter-egg keypad is a 44 px target on the key-- itself — the four arrows, the two control keys and the 26 letter keys. A mouse keeps the small keys. */--@media (pointer: coarse) {-- .egg-key { min-width: 44px; height: 44px; }-- .egg-key--letter { min-width: 44px; height: 44px; }--}----/* Celebration effects */--.egg-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 100000; overflow: hidden; }--.egg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: eggFall linear forwards; }--@keyframes eggFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }--@keyframes eggShake { 10%,90%{transform:translate3d(-2px,0,0)} 20%,80%{transform:translate3d(4px,0,0)} 30%,50%,70%{transform:translate3d(-8px,0,0)} 40%,60%{transform:translate3d(8px,0,0)} }--@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }--/* ── OpenVibeApp Popover ─────────────────────────────────────────── */.openvibeapp-popover {max-height: 0; overflow: hidden; opacity: 0;@@@ -120,38 +120,20 @@<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) --><div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>-- <div class="hero-egg" id="hero-egg" style="display:none">-- <button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">-- <span class="hero-egg-icon">🥚</span>-- <span class="hero-egg-titles"><span class="hero-egg-kicker">Today's Secret</span><span class="hero-egg-title" id="hero-egg-title"></span></span>-- <span class="hero-egg-status" id="hero-egg-status"></span>-- <i class="fa-solid fa-chevron-down hero-egg-chev"></i>++ <div class="home-daily-quest" id="home-daily-quest" hidden>++ <button class="home-daily-head" type="button" id="home-daily-toggle" aria-expanded="false" aria-controls="home-daily-body">++ <span class="home-daily-icon ov-icon" data-icon="quest" data-size="32" aria-hidden="true"></span>++ <span class="home-daily-titles"><span class="home-daily-kicker" id="home-daily-kicker"></span><span class="home-daily-title" id="home-daily-title"></span><span class="home-daily-bar" aria-hidden="true"><i></i><i></i><i></i></span></span>++ <span class="home-daily-chip" id="home-daily-chip"></span>++ <i class="fa-solid fa-chevron-down home-daily-chev" aria-hidden="true"></i></button>-- <div class="hero-egg-body">-- <div class="hero-egg-inner">-- <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>-- <div class="hero-egg-slots" id="hero-egg-slots" role="group" aria-label="Your key sequence"></div>-- <ol class="hero-egg-clues" id="hero-egg-clues"></ol>-- <div class="hero-egg-pad" id="hero-egg-pad">-- <div class="hero-egg-pad-arrows">-- <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>-- <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>-- <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>-- <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>-- <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>-- <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>-- </div>-- <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>-- </div>-- <div class="hero-egg-foot">-- <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>-- <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>-- </div>-- </div>++ <div class="home-daily-body" id="home-daily-body" hidden>++ <ol class="home-daily-steps" id="home-daily-steps"></ol>++ <a class="home-daily-action" id="home-daily-action" href="https://openvibe.quest/"></a></div></div>-- <!-- Community pulse: goals near completion, latest activity, weekly leaders -->++ <!-- Network pulse: live viewers, stream hours, chat, channel points and daily quests. --><div class="home-section-lazy" id="home-pulse-section" style="display:none"><div class="section-header" style="--sc:#f59e0b"><h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>@@@ -1008,165 +1008,62 @@ function openHomeAbout() {banner.scrollIntoView({ behavior: 'smooth', block: 'start' });}--// ── Daily AI easter egg ─────────────────────────────────────────────────────--let _egg = null, _eggBuf = [], _eggSolved = false, _eggSubmitTimer = null, _eggKeysWired = false;--const _EGG_ARROW = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };--const _EGG_GLYPH = { up: '↑', down: '↓', left: '←', right: '→' };--let _eggRevealed = []; // [{index, token}] revealed for this solver today--let _eggFails = 0, _eggBusy = false;--async function loadHeroEgg() {-- const el = document.getElementById('hero-egg');-- if (!el) return;-- let data; try { data = await api('/easter-egg/daily'); } catch { return; }-- const egg = data && data.egg;-- if (!egg) { el.style.display = 'none'; return; }-- _egg = egg; _eggSolved = !!egg.solved; _eggBuf = []; _eggRevealed = egg.revealed || []; _eggFails = egg.fails || 0;-- el.style.display = '';-- const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };-- set('hero-egg-title', egg.title || 'The Daily Secret');-- // Letter pad (built once)-- const letters = document.getElementById('hero-egg-letters');-- if (letters && !letters.childElementCount) letters.innerHTML = 'abcdefghijklmnopqrstuvwxyz'.split('').map(l => `<button type="button" class="egg-key egg-key--letter" data-tok="${l}">${l}</button>`).join('');-- const pad = document.getElementById('hero-egg-pad');-- if (pad && !pad.dataset.wired) {-- pad.dataset.wired = '1';-- pad.addEventListener('click', (e) => {-- const b = e.target.closest('button'); if (!b) return;-- if (b.dataset.act === 'back') { _eggBuf.pop(); _renderEggSlots(); return; }-- if (b.dataset.act === 'clear') { _eggBuf = []; _renderEggSlots(); return; }-- if (b.dataset.tok) _eggPush(b.dataset.tok);-- });-- }-- _renderEggClues();-- _renderEggSlots();-- _renderEggMeta();-- _renderEggStatus();-- _wireEggKeys();--}--function _eggResetLabel(ts) { const h = Math.round((ts - Date.now()) / 3600000); return h <= 1 ? 'soon' : `in ${h}h`; }--function _renderEggStatus() {-- const s = document.getElementById('hero-egg-status'); if (!s) return;-- s.textContent = _eggSolved ? '✓ Cracked' : `${_egg ? _egg.codeLength : ''} keys`;-- s.className = 'hero-egg-status' + (_eggSolved ? ' solved' : '');--}--function _renderEggMeta() {-- if (!_egg) return;-- const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };-- set('hero-egg-count', `${_egg.foundCount || 0} cracked it today`);-- set('hero-egg-first', (_egg.firstSolvers || []).length ? ` · first: ${_egg.firstSolvers.slice(0, 3).join(', ')}` : '');-- set('hero-egg-reset', _egg.nextResetAt ? ` · new secret ${_eggResetLabel(_egg.nextResetAt)}` : '');-- const rb = document.getElementById('hero-egg-reveal');-- if (rb) {-- const left = Math.max(0, (_egg.revealAfterFails || 2) - _eggFails);-- const done = _eggRevealed.length >= Math.max(0, _egg.codeLength - 1);-- rb.style.display = _eggSolved ? 'none' : '';-- rb.disabled = done || left > 0;-- rb.innerHTML = done ? '<i class="fa-solid fa-lightbulb"></i> The last key is yours'-- : left > 0 ? `<i class="fa-regular fa-lightbulb"></i> Stuck? ${left} more wrong tr${left === 1 ? 'y' : 'ies'} unlocks a reveal`-- : '<i class="fa-solid fa-lightbulb"></i> Stuck? Reveal the next key';-- }--}--function _renderEggClues() {-- const ol = document.getElementById('hero-egg-clues'); if (!ol || !_egg) return;-- const clues = _egg.clues || _egg.hints || [];-- const rev = new Map(_eggRevealed.map(r => [r.index, r.token]));-- ol.innerHTML = clues.map((c, i) => `<li class="${rev.has(i) ? 'is-revealed' : ''}"><span class="egg-clue-n">${i + 1}</span><span class="egg-clue-text">${esc(c)}</span>${rev.has(i) ? `<span class="egg-clue-key" title="Revealed">${esc(_EGG_GLYPH[rev.get(i)] || rev.get(i).toUpperCase())}</span>` : ''}</li>`).join('')-- || '<li><span class="egg-clue-text">No clues today — go on instinct.</span></li>';--}--function _renderEggSlots(state) {-- const host = document.getElementById('hero-egg-slots'); if (!host || !_egg) return;-- const n = _egg.codeLength || 0;-- let html = '';-- for (let i = 0; i < n; i++) {-- const t = _eggBuf[i];-- html += `<span class="egg-slot ${t ? 'is-filled' : ''} ${i === _eggBuf.length ? 'is-next' : ''}">${t ? esc(_EGG_GLYPH[t] || t.toUpperCase()) : ''}</span>`;-- }-- host.innerHTML = html;-- host.className = 'hero-egg-slots' + (state ? ` is-${state}` : '') + (_eggSolved ? ' is-solved' : '');--}--function _eggPush(tok) {-- if (_eggSolved || !_egg || _eggBusy) return;-- _eggBuf.push(tok);-- if (_eggBuf.length > _egg.codeLength) _eggBuf = _eggBuf.slice(-_egg.codeLength);-- _renderEggSlots();-- if (_eggBuf.length === _egg.codeLength) { clearTimeout(_eggSubmitTimer); _eggSubmitTimer = setTimeout(_submitEgg, 200); }--}--function toggleHeroEgg() { document.getElementById('hero-egg')?.classList.toggle('open'); }--function _wireEggKeys() {-- if (_eggKeysWired) return;-- _eggKeysWired = true;-- document.addEventListener('keydown', (e) => {-- if (_eggSolved || !_egg) return;-- const t = e.target;-- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;-- if (e.ctrlKey || e.metaKey || e.altKey) return;-- // Only while the secret is on screen on the home page: everywhere else the keys (arrow-- // scrolling, a player's shortcuts, Backspace) belong to the page.-- const el = document.getElementById('hero-egg');-- const home = document.getElementById('page-home');-- if (!el || !home || !home.classList.contains('active') || el.offsetParent === null) return;-- const inside = !!(t && el.contains(t));-- const r = el.getBoundingClientRect();-- if (!inside && (r.bottom <= 0 || r.top >= window.innerHeight)) return;-- if (e.key === 'Backspace') { if (inside || el.classList.contains('open')) { _eggBuf.pop(); _renderEggSlots(); } return; }-- let tok = _EGG_ARROW[e.key];-- // A focused card rail scrolls with the arrow keys; the secret must not swallow them.-- if (tok && t && t.closest && t.closest('.ov-rail-track')) return;-- if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();-- if (!tok) return;-- // Arrows scroll the page unless someone is playing: the panel is open or has focus.-- if (_EGG_ARROW[e.key]) {-- if (!inside && !el.classList.contains('open')) return;-- e.preventDefault();++// Quest is read by Live's server with the person's Network token. No Quest cookie crosses sites.++async function loadDailyQuest() {++ const card = document.getElementById('home-daily-quest');++ if (!card) return;++ let zone = 'UTC';++ try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || zone; } catch { /* UTC */ }++ let data;++ try { data = await api('/home/daily-quest?time_zone=' + encodeURIComponent(zone)); }++ catch { card.hidden = true; return; }++ const quest = data && data.quest;++ if (!quest || !Array.isArray(quest.steps) || quest.steps.length !== 3) { card.hidden = true; return; }++ const mine = !!data.signed_in;++ const progress = Math.max(0, Math.min(3, Number(quest.progress) || 0));++ card.dataset.progress = mine ? String(progress) : '0'; // the three-part bar under the title++ document.getElementById('home-daily-kicker').textContent = mine ? "TODAY'S QUEST" : 'DAILY QUESTS';++ document.getElementById('home-daily-title').textContent = mine ? quest.title : 'Today: ' + quest.title;++ document.getElementById('home-daily-chip').textContent = mine++ ? quest.completed ? `Done for today · ${quest.streak}-day streak` : `${progress} of 3`++ : 'New every day';++ const list = document.getElementById('home-daily-steps');++ list.replaceChildren();++ for (const step of quest.steps) {++ const row = document.createElement('li');++ const icon = document.createElement('span');++ icon.className = 'ov-icon home-daily-site-icon';++ icon.dataset.icon = String(step.site || '').split('.')[1] || 'quest';++ icon.dataset.size = '32';++ icon.setAttribute('aria-hidden', 'true');++ const label = document.createElement('span');++ label.textContent = String(step.sentence || '');++ row.append(icon, label);++ if (mine && step.done) {++ row.classList.add('is-done');++ const done = document.createElement('span');++ done.className = 'home-daily-check';++ done.textContent = '✓';++ done.setAttribute('aria-label', 'Done');++ row.append(done);}-- _eggPush(tok);-- // Typing counts as engagement: pop the panel open so people see the keys land.-- if (!el.classList.contains('open') && _eggBuf.length === 1) el.classList.add('open');-- });--}--async function _submitEgg() {-- if (_eggSolved || !_egg || _eggBuf.length < _egg.codeLength || _eggBusy) return;-- _eggBusy = true;-- let res; try { res = await api('/easter-egg/solve', { method: 'POST', body: { sequence: _eggBuf.slice() } }); } catch { _eggBusy = false; return; }-- _eggBusy = false;-- if (res && res.solved) {-- _eggSolved = true; _renderEggStatus(); _renderEggSlots('right');-- if (res.foundCount != null) _egg.foundCount = res.foundCount;-- _renderEggMeta();-- _celebrateEgg(res.egg || {});-- return;++ list.append(row);}-- _eggFails = (res && res.fails) || (_eggFails + 1);-- _renderEggSlots('wrong');-- _renderEggMeta();-- setTimeout(() => { _eggBuf = []; _renderEggSlots(); }, 750);--}--async function _eggReveal() {-- if (_eggSolved || !_egg) return;-- let out; try { out = await api('/easter-egg/reveal', { method: 'POST', body: {} }); } catch (e) { try { toast((e && e.message) || 'Not yet', 'info'); } catch { /* */ } return; }-- if (out && out.revealed) { _eggRevealed = out.revealed; _renderEggClues(); _renderEggMeta(); }--}--function _celebrateEgg(egg) {-- try { toast(egg.reward || "You cracked today's secret! 🎉", 'success'); } catch { /* */ }-- const effect = egg.effect || 'confetti';-- try {-- if (effect === 'shake') { document.body.classList.add('egg-shake'); setTimeout(() => document.body.classList.remove('egg-shake'), 900); }-- if (effect === 'rainbow') { document.body.classList.add('egg-rainbow'); setTimeout(() => document.body.classList.remove('egg-rainbow'), 2600); }-- const N = effect === 'fireworks' ? 170 : effect === 'matrix' ? 90 : 130;-- const cont = document.createElement('div'); cont.className = 'egg-confetti';-- const colors = effect === 'matrix' ? ['#00ff41', '#0f0', '#00c030'] : ['#ff5a5f', '#ffd166', '#06d6a0', '#4d96ff', '#c77dff', '#fff'];-- for (let i = 0; i < N; i++) {-- const p = document.createElement('span');-- p.style.left = (Math.random() * 100) + 'vw';-- p.style.background = colors[i % colors.length];-- p.style.animationDelay = (Math.random() * 0.5) + 's';-- p.style.animationDuration = (1.5 + Math.random() * 1.6) + 's';-- if (effect === 'matrix') { p.style.width = '3px'; p.style.height = (12 + Math.random() * 20) + 'px'; }-- cont.appendChild(p);-- }-- document.body.appendChild(cont);-- setTimeout(() => cont.remove(), 3800);-- } catch { /* */ }++ const action = document.getElementById('home-daily-action');++ const next = mine && quest.steps.find(step => !step.done);++ action.textContent = mine ? next ? 'Do the next step' : 'See your streak' : "See today's quest";++ action.href = next && /^https:\/\//.test(next.href || '') ? next.href : 'https://openvibe.quest/';++ const toggle = document.getElementById('home-daily-toggle');++ if (!toggle.dataset.wired) {++ toggle.dataset.wired = '1';++ toggle.addEventListener('click', () => {++ const body = document.getElementById('home-daily-body');++ body.hidden = !body.hidden;++ toggle.setAttribute('aria-expanded', String(!body.hidden));++ card.classList.toggle('open', !body.hidden);++ });++ }++ card.hidden = false;}/**@@@ -1411,7 +1308,7 @@ async function loadHome() {updateNavHeroTransparency(); // transparent nav over the hero at the topstartHeroRotation(); // instant static rotation; loadHeroData upgrades it with AI slogansvoid loadHeroData(); // stats bar, floating-thumbnail collage, AI slogans-- void loadHeroEgg(); // daily AI easter egg widget++ void loadDailyQuest(); // shared daily quest from OpenVibe.Quest// Claim the space every feed is about to fill. Without this the page arrives as a short// column that grows in jumps as each request lands, which reads as slow and shoves content@@@ -1483,39 +1380,49 @@ function _renderHomePulse(p) {const section = document.getElementById('home-pulse-section');if (!grid || !section) return;const cards = [];---- // Goal cards — nearest to completion, with progress bars.-- for (const g of (p.goals || [])) {-- const pct = Math.min(100, Math.round((g.current_amount / g.target_amount) * 100));-- const href = `/@${g.username}`;-- cards.push(`-- <a class="pulse-card pulse-goal" href="${href}" onclick="return handleLinkClick(event, '${href}')">-- <div class="pulse-kicker"><i class="fa-solid fa-bullseye"></i> Goal · ${esc(g.display_name || g.username)}</div>-- <div class="pulse-title">${esc(g.title)}</div>-- <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>-- <div class="pulse-sub">${Number(g.current_amount).toLocaleString()} / ${Number(g.target_amount).toLocaleString()} Vibes · <b>${pct}%</b></div>-- </a>`);-- }---- // Latest activity card (tip + follow together).-- const act = [];-- if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> ${ovUserTag({ username: p.latestTip.from_username, display_name: p.latestTip.from_display }, p.latestTip.from_display || p.latestTip.from_username || 'Someone')} tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to ${ovUserTag({ username: p.latestTip.to_username, display_name: p.latestTip.to_display })} <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);-- if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> ${ovUserTag({ username: p.newestFollow.follower_username, display_name: p.newestFollow.follower_display })} followed ${ovUserTag({ username: p.newestFollow.streamer_username, display_name: p.newestFollow.streamer_display })} <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);-- if (act.length) cards.push(`<div class="pulse-card"><div class="pulse-kicker"><i class="fa-solid fa-wave-square"></i> Latest activity</div>${act.join('')}</div>`);---- // Weekly leader teasers.-- const board = (title, icon, rows, unit) => rows && rows.length ? `-- <div class="pulse-card">-- <div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>-- ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> ${ovUserTag(r)} <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}-- </div>` : '';-- const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');-- const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');-- if (supporters) cards.push(supporters);-- if (earners) cards.push(earners);--++ const add = (kicker, person, value, context, href) => {++ const number = Number(value);++ if (!Number.isFinite(number) || number <= 0) return;++ const card = document.createElement('a');++ card.className = 'pulse-card pulse-fact-card';++ card.href = href;++ const k = document.createElement('span');++ k.className = 'pulse-kicker';++ k.textContent = kicker;++ const identity = document.createElement('span');++ identity.className = 'pulse-fact-identity';++ const face = document.createElement('span');++ face.className = 'pulse-fact-face';++ const name = String(person.display_name || person.username || person.name || 'OpenVibe');++ face.textContent = name.charAt(0).toUpperCase();++ if (person.avatar_url) {++ const img = document.createElement('img');++ img.src = person.avatar_url;++ img.alt = '';++ img.loading = 'lazy';++ img.onerror = () => img.remove();++ face.append(img);++ }++ const label = document.createElement('span');++ label.textContent = name;++ identity.append(face, label);++ const big = document.createElement('strong');++ big.className = 'pulse-fact-number';++ big.textContent = new Intl.NumberFormat().format(number);++ const sub = document.createElement('span');++ sub.className = 'pulse-fact-context';++ sub.textContent = context;++ card.append(k, identity, big, sub);++ cards.push(card);++ };++ if (p.liveNow) add('LIVE NOW', p.liveNow, p.liveNow.viewer_count, 'viewers watching', '/@' + encodeURIComponent(p.liveNow.username));++ if (p.topStreamer) add('TOP STREAMER THIS WEEK', p.topStreamer, p.topStreamer.hours, 'hours streamed · peak ' + Number(p.topStreamer.peak_viewers || 0) + ' viewers', '/@' + encodeURIComponent(p.topStreamer.username));++ if (p.busiestChat) add('BUSIEST CHAT', p.busiestChat, p.busiestChat.messages_per_minute, 'messages a minute · ' + p.busiestChat.window, '/@' + encodeURIComponent(p.busiestChat.username));++ if (p.topFan) add('TOP FANS THIS WEEK', p.topFan, p.topFan.points, 'channel points earned', '/@' + encodeURIComponent(p.topFan.username));++ if (p.openCoinsToday > 0) add('OPENCOINS TODAY', { name: 'OpenVibe' }, p.openCoinsToday, 'earned across the network', 'https://openvibe.network/');++ if (p.dailyFinished > 0) add("TODAY'S QUEST", { name: 'OpenVibe.Quest' }, p.dailyFinished, 'people finished today', 'https://openvibe.quest/');section.style.display = cards.length ? '' : 'none';-- grid.innerHTML = cards.join('');++ grid.replaceChildren(...cards);// AI Moments showcase row.const momentsRow = document.getElementById('home-moments-row');@@@ -1614,66 +1521,71 @@ function _sgThumbFail(img) {function renderRecentlyOnline(containerId, streamers, append) {const container = document.getElementById(containerId);if (!container) return;-- if (!streamers.length) {-- if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';-- return;++ if (!append) container.replaceChildren();++ if (!streamers.length) return;++ const el = (tag, cls, value) => {++ const node = document.createElement(tag);++ if (cls) node.className = cls;++ if (value != null) node.textContent = String(value);++ return node;++ };++ for (const s of streamers) {++ const facts = s.recent_facts || {};++ const channelHref = '/@' + encodeURIComponent(s.username || '');++ const card = el('article', 'streamer-group-card recent-streamer-card');++ const head = el('a', 'streamer-group-identity recent-streamer-head');++ head.href = channelHref;++ const face = el('span', 'recent-streamer-face', (s.display_name || s.username || '?').charAt(0).toUpperCase());++ if (s.avatar_url) {++ const img = el('img'); img.src = s.avatar_url; img.alt = ''; img.loading = 'lazy';++ img.onerror = () => img.remove(); face.append(img);++ }++ const name = el('strong', 'streamer-group-name', s.display_name || s.username);++ const when = el('span', 'streamer-group-last-online', s.live ? 'Live now' : 'Live ' + timeAgo(s.last_online_at));++ head.append(face, name, when);++ card.append(head);++ const chips = el('div', 'recent-streamer-facts');++ const chip = (value) => { if (value) chips.append(el('span', 'recent-streamer-chip', value)); };++ if (facts.streams_this_week) chip(facts.streams_this_week + ' streams this week');++ if (facts.hours_this_week) chip(facts.hours_this_week + ' h this week');++ chip(facts.usual_time);++ if (facts.days_in_a_row >= 3) chip(facts.days_in_a_row + ' days in a row');++ if (facts.last_stream_seconds) chip('Last stream ' + formatDuration(facts.last_stream_seconds));++ card.append(chips);++ const overview = String(s.ai_overview_short || s.ai_overview || '').trim();++ if (overview) {++ const summary = el('p', 'recent-streamer-summary', overview);++ const more = el('button', 'recent-streamer-more', 'More');++ more.type = 'button'; more.setAttribute('aria-expanded', 'false');++ more.addEventListener('click', () => {++ const open = summary.classList.toggle('expanded');++ more.textContent = open ? 'Less' : 'More';++ more.setAttribute('aria-expanded', String(open));++ });++ card.append(summary, more);++ }++ const history = el('div', 'streamer-group-streams recent-streamer-history');++ for (const h of (facts.streams || []).slice(0, 2)) {++ const replay = !h.is_live && h.vod_id;++ const row = el('a', 'streamer-group-stream recent-stream-row');++ row.href = replay ? videoHref('vod', h.vod_id) : channelHref;++ const thumb = el('span', 'streamer-group-stream-thumb');++ const src = _sgThumbSrc(h.thumbnail_url);++ if (src) {++ const img = el('img', 'streamer-group-stream-thumb-img');++ img.src = src; img.alt = ''; img.loading = 'lazy';++ img.onerror = () => img.remove();++ thumb.append(img);++ }++ const info = el('span', 'streamer-group-stream-info');++ info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));++ info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));++ row.append(thumb, info);++ history.append(row);++ }++ card.append(history);++ container.append(card);}-- const _put = (html) => ovPutCards(container, html, append);-- _put(streamers.map(s => {-- const msList = (s.managed_streams || []);-- const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);-- const channelHref = `/@${s.username}`;-- const streamsHtml = msList.length ? msList.map(ms => {-- // An offline stream opens its replay on openvibe.video, the chat playing in time beside it: the row showed a-- // picture of the stream and led to an offline channel (owner, 2026-10-27). Live, or with no recording, the-- // row is the channel; the card's name always is.-- const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;-- const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);-- // A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load-- // becomes the placeholder (_sgThumbFail) instead of a broken image.-- const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);-- const thumb = src-- ? `<img src="${esc(src)}" alt="" loading="lazy" class="streamer-group-stream-thumb-img" onerror="_sgThumbFail(this)">`-- : _SG_THUMB_PLACEHOLDER;-- const when = ms.is_live-- ? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`-- : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}${replay ? ' · <span class="sg-replay">Replay</span>' : ''}`;-- const label = replay ? `Watch the replay of ${ms.title || 'this stream'}` : ms.is_live ? `Watch ${ms.title || 'the stream'} live` : `${ms.title || 'Stream'} on ${s.display_name || s.username}'s channel`;-- return `-- <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}${replay ? ' has-replay' : ''}" href="${href}" aria-label="${esc(label)}" onclick="return handleLinkClick(event, '${href}')">-- <div class="streamer-group-stream-thumb">${thumb}${replay ? '<span class="sg-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>' : ''}</div>-- <div class="streamer-group-stream-info">-- <div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>-- <div class="streamer-group-stream-time muted">${when}</div>-- </div>-- </a>`;-- }).join('') : `<a class="streamer-group-stream" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">-- <div class="streamer-group-stream-info"><div class="streamer-group-stream-title muted">No streams</div></div>-- </a>`;-- return `-- <div class="streamer-group-card">-- <div class="streamer-group-header">-- <a class="streamer-group-identity" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">-- ${avatar}-- <span class="streamer-group-name">${esc(s.display_name || s.username)}</span>-- ${s.live-- ? `<span class="streamer-group-last-online sg-live"><span class="sg-live-dot"></span> LIVE · <i class="fa-solid fa-eye"></i> ${Number(s.live.viewer_count) || 0}</span>`-- : `<span class="streamer-group-last-online muted"><i class="fa-solid fa-clock"></i> ${timeAgo(s.last_online_at)}</span>`}-- </a>-- </div>-- ${_cardAiHTML(s.ai_overview_short, s.ai_overview)}-- ${s.top_goal ? (() => {-- const pct = Math.min(100, Math.round((s.top_goal.current / s.top_goal.target) * 100));-- return `<div class="streamer-group-goal" title="${esc(s.top_goal.title)}: ${Number(s.top_goal.current).toLocaleString()} / ${Number(s.top_goal.target).toLocaleString()} Vibes">-- <span class="sgg-title"><i class="fa-solid fa-bullseye"></i> ${esc(s.top_goal.title)}</span>-- <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>-- <span class="sgg-pct">${pct}%</span>-- </div>`;-- })() : ''}-- <div class="streamer-group-streams">${streamsHtml}</div>-- </div>-- `;-- }).join(''));}async function loadHomeRecentVods(opts) {@@@ -31,7 +31,7 @@ const TERMINAL = new Set(['succeeded', 'cached', 'failed', 'cancelled']);const KIND_TO_WORKFLOW = {ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',-- moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',++ moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', home_star: 'live.home.star',status_check: 'live.status_check',};const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];ddeleted file mode 100644@@@ -1,210 +0,0 @@--/**-- * Daily easter-egg job — once a day the AI invents a fresh "secret code" (Konami-style key-- * sequence) themed on the community's vibe, plus cryptic-but-solvable hints. The code is kept-- * SECRET server-side (never sent to clients); users decode the hints, enter the sequence, and-- * the server validates. Persisted in the `daily_easter_egg` setting; degrades to a deterministic-- * daily code + generic hints when AI is off.-- */--'use strict';--const db = require('../db/database');--const ai = require('./ai-analysis');--const aiService = require('./ai-service');----const SETTING = 'daily_easter_egg';--const DIRS = ['up', 'down', 'left', 'right'];--const LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('');--const TOKENS = [...DIRS, ...LETTERS];--const EFFECTS = ['confetti', 'fireworks', 'matrix', 'rainbow', 'shake'];--let _busy = false;----// Local date key (server-local) — one egg per calendar day.--function _today() { return new Date().toISOString().slice(0, 10); }--async function _load() { try { return JSON.parse(await db.getState(SETTING) || '{}') || {}; } catch { return {}; } }--async function _due() { const p = await _load(); return !p.date || p.date !== _today() || !Array.isArray(p.code) || !p.code.length; }----// Deterministic daily fallback (no AI): a seeded 7-token sequence + generic hints.--function _seededCode() {-- let seed = 0; for (const ch of _today()) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;-- const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };-- const len = 5 + Math.floor(rnd() * 3);-- const code = [];-- // Konami feel: roughly half arrows, half letters, mixed.-- for (let i = 0; i < len; i++) code.push(rnd() < 0.55 ? DIRS[Math.floor(rnd() * DIRS.length)] : LETTERS[Math.floor(rnd() * LETTERS.length)]);-- if (!code.some(t => DIRS.includes(t))) code[0] = DIRS[Math.floor(rnd() * DIRS.length)];-- if (!code.some(t => !DIRS.includes(t))) code[len - 1] = LETTERS[Math.floor(rnd() * LETTERS.length)];-- return code;--}--function _sanitizeCode(arr) {-- if (!Array.isArray(arr)) return null;-- const code = arr.map(t => String(t || '').toLowerCase().trim())-- .map(t => ({ arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right' }[t] || t))-- .filter(t => TOKENS.includes(t));-- return (code.length >= 4 && code.length <= 9) ? code : null;--}----// ── Clue bank: a fair, readable clue for every token (used for the no-AI fallback and to--// patch any AI clue that is missing or does not line up with the code) ─────────────────--const DIR_CLUES = {-- up: ['toward the sky', 'the way a balloon goes', 'north on the compass', 'where the ceiling lives', 'the arrow that climbs'],-- down: ['toward the floor', 'the way rain falls', 'south on the compass', 'where roots grow', 'the arrow that sinks'],-- left: ['the side your heart is on', 'west on the compass', 'the arrow that points at the past', 'port side, sailor', 'where the sun sets'],-- right: ['the side most people write with', 'east on the compass', 'the arrow that points at the future', 'starboard, sailor', 'where the sun rises'],--};--const LETTER_WORDS = { a: 'arena', b: 'broadcast', c: 'clip', d: 'donate', e: 'emote', f: 'follow', g: 'goose', h: 'hype', i: 'irl', j: 'jump', k: 'keyboard', l: 'latency', m: 'mic', n: 'network', o: 'overlay', p: 'paste', q: 'queue', r: 'robot', s: 'stream', t: 'tts', u: 'upload', v: 'vibe', w: 'webrtc', x: 'xp', y: 'yap', z: 'zoom' };--const ORD = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth'];--function _clueFor(tok, i, rnd) {-- if (DIR_CLUES[tok]) { const list = DIR_CLUES[tok]; return list[(i + Math.floor(rnd() * list.length)) % list.length]; }-- const w = LETTER_WORDS[tok] || tok;-- const forms = [`the letter that starts "${w}"`, `"${w}" begins with it`, `the ${ORD[0]} letter of "${w}"`];-- return forms[Math.floor(rnd() * forms.length)];--}--function _rngFor(seedStr) { let seed = 7; for (const ch of seedStr) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0; return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; }--// Does this clue actually point at this token? Arrow clues must use that direction's imagery and--// none of the other three; letter clues must quote a word that starts with the letter (or say--// "the letter X"). The model gets this wrong often enough that unchecked clues made the game--// unwinnable — anything that fails is replaced with a clue-bank line.--const DIR_WORDS = {-- up: /\b(up|upward|upwards|sky|skyward|heaven|heavens|north|ceiling|climb|climbs|rise|rises|rising|ascend|balloon|top|above)\b/i,-- down: /\b(down|downward|downwards|floor|ground|south|fall|falls|falling|sink|sinks|descend|roots|below|gravity|depths)\b/i,-- left: /\b(left|west|sunset|sets|heart|port|past|backward|backwards|rewind)\b/i,-- right: /\b(right|east|sunrise|rises|starboard|future|forward|write|writes)\b/i,--};--function _clueLooksRight(tok, clue) {-- const c = String(clue || '');-- if (!c || c.length < 6) return false;-- if (DIR_WORDS[tok]) {-- if (!DIR_WORDS[tok].test(c)) return false;-- // "rises" is shared by up (rise) and right (sunrise); resolve by the other words present.-- const others = Object.keys(DIR_WORDS).filter(d => d !== tok && new RegExp(`\\b(${d})\\b`, 'i').test(c));-- return others.length === 0;-- }-- if (new RegExp(`\\bletter\\s+["'“‘]?${tok}["'”’]?\\b`, 'i').test(c)) return true;-- const quoted = [...c.matchAll(/["'“‘]([a-z][a-z-]*)["'”’]/gi)].map(m => m[1].toLowerCase());-- return quoted.some(w => w[0] === tok);--}--/** One clue per token, in order — AI clues kept only when they check out, bank clues fill the gaps. */--function _alignClues(code, aiClues) {-- const rnd = _rngFor(_today() + code.join(','));-- const out = [];-- for (let i = 0; i < code.length; i++) {-- const c = Array.isArray(aiClues) && aiClues[i] ? String(aiClues[i]).replace(/\s+/g, ' ').trim().slice(0, 110) : '';-- out.push(c && _clueLooksRight(code[i], c) ? c : _clueFor(code[i], i, rnd));-- }-- return out;--}----async function _generate() {-- // The chat's current vibe: OpenVibe.Chat's global chat-AI overview (roadmap T3).-- let vibe = '';-- try { const g = await require('../chat/insight-client').getGlobal(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }-- if (ai.isEnabled && await ai.isEnabled() && ai.withinBudget && await ai.withinBudget()) {-- // The prompt and its clue rules are OpenVibe.AI's versioned template live.easter_egg (WS-O task 2).-- try {-- const j = await aiService.structured('live.easter_egg', vibe ? { vibe } : {}, { meter: { kind: 'easter_egg', role: 'legacy' } });-- if (j) {-- const code = _sanitizeCode(j.code);-- if (code) {-- return {-- title: String(j.title || 'The Daily Secret').replace(/\s+/g, ' ').trim().slice(0, 60),-- code,-- clues: _alignClues(code, j.clues || j.hints),-- effect: EFFECTS.includes(j.effect) ? j.effect : 'confetti',-- reward: String(j.reward || "You cracked today's secret! 🎉").trim().slice(0, 200),-- ai: true,-- };-- }-- }-- } catch { /* fall through */ }-- }-- // Fallback: deterministic daily code with bank clues — always solvable.-- const code = _seededCode();-- return {-- title: 'The Daily Secret',-- code,-- clues: _alignClues(code, null),-- effect: EFFECTS[code.length % EFFECTS.length],-- reward: "You cracked today's secret! 🎉",-- ai: false,-- };--}----async function tick(opts = {}) {-- if (_busy || (!opts.force && !await _due())) return;-- _busy = true;-- try {-- const egg = await _generate();-- await db.setState(SETTING, JSON.stringify({ date: _today(), ...egg, updated_at: Date.now() }));-- console.log(`[EasterEgg] New daily egg "${egg.title}" (${egg.code.length} keys, ${egg.ai ? 'AI' : 'fallback'})`);-- } catch (e) {-- console.warn('[EasterEgg] tick error:', e.message);-- } finally { _busy = false; }--}----// Public (safe) view — never leaks the code, only its length + hints.--async function getPublic() {-- const p = await _load();-- if (!p.code || p.date !== _today()) return null;-- const now = new Date();-- const nextReset = new Date(now); nextReset.setHours(24, 0, 0, 0);-- const clues = Array.isArray(p.clues) && p.clues.length === p.code.length ? p.clues : _alignClues(p.code, p.clues || p.hints);-- return {-- date: p.date,-- title: p.title || 'The Daily Secret',-- clues,-- hints: clues, // back-compat for older clients-- codeLength: p.code.length,-- effect: p.effect || 'confetti',-- nextResetAt: nextReset.getTime(),-- ai: !!p.ai,-- };--}----// ── "Stuck?" reveals: after a couple of wrong attempts a solver may reveal keys one at a time--// (never the last one) — enough to keep it fun, not enough to make typing pointless. Per-day,--// per-solver, in memory (a restart just makes people try twice more).--const _fails = new Map(); // solverKey → { date, n, revealed:Set }--function _rec(solverKey) {-- const d = _today(); let r = _fails.get(solverKey);-- if (!r || r.date !== d) { r = { date: d, n: 0, revealed: new Set() }; _fails.set(solverKey, r); }-- if (_fails.size > 5000) { const k = _fails.keys().next().value; _fails.delete(k); }-- return r;--}--function noteFail(solverKey) { const r = _rec(solverKey); r.n++; return r.n; }--const REVEAL_AFTER_FAILS = 2;--async function reveal(solverKey, index) {-- const p = await _load();-- if (!p.code || p.date !== _today()) return { error: 'No secret today' };-- const r = _rec(solverKey);-- const i = Number.isInteger(index) ? index : [...Array(p.code.length).keys()].find(k => !r.revealed.has(k));-- if (i == null || i < 0 || i >= p.code.length) return { error: 'Nothing left to reveal' };-- if (i === p.code.length - 1 && !r.revealed.has(i)) return { error: 'The last key is yours to find', last: true };-- if (r.n < REVEAL_AFTER_FAILS && !r.revealed.has(i)) return { error: `Try ${REVEAL_AFTER_FAILS - r.n} more time${REVEAL_AFTER_FAILS - r.n === 1 ? '' : 's'} first`, needFails: REVEAL_AFTER_FAILS - r.n };-- r.revealed.add(i);-- return { index: i, token: p.code[i], revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) };--}--async function revealedFor(solverKey) { const p = await _load(); const r = _fails.get(solverKey); if (!p.code || !r || r.date !== _today()) return { fails: r ? r.n : 0, revealed: [] }; return { fails: r.n, revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) }; }----// Validate an attempt against today's secret code (server-side only).--async function checkSolution(sequence) {-- const p = await _load();-- if (!p.code || p.date !== _today() || !Array.isArray(sequence)) return null;-- const norm = sequence.map(t => String(t || '').toLowerCase().trim()).filter(Boolean);-- // Match if the tail of the attempt equals the code (so trailing extra keys are fine).-- if (norm.length < p.code.length) return { ok: false };-- const tail = norm.slice(norm.length - p.code.length);-- const ok = tail.every((t, i) => t === p.code[i]);-- return { ok, egg: ok ? { title: p.title, effect: p.effect, reward: p.reward, date: p.date } : null };--}----function start() {-- setTimeout(() => { tick().catch(() => {}); }, 15 * 1000);-- setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000); // self-gates on the calendar day-- console.log('[EasterEgg] Daily easter-egg job started');--}----module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS, _generate };----// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`--if (require.main === module) {-- require('../db/database').initDb({ script: true }).then(() => tick({ force: true })).then(async () => { const p = await _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });--}ddeleted file mode 100644@@@ -1,78 +0,0 @@--/**-- * easter-egg-routes.js — read + solve API for the daily easter egg.-- * GET /api/easter-egg/daily → title, hints, code LENGTH (never the code), effect, counts-- * POST /api/easter-egg/solve → validate a key sequence server-side; record the solve-- */--'use strict';--const express = require('express');--const router = express.Router();--const db = require('../db/database');--const eggJob = require('./easter-egg-job');--let optionalAuth = (req, res, next) => next();--try { ({ optionalAuth } = require('../auth/auth')); } catch { /* */ }----function _solverKey(req) { return (req.user && req.user.id) ? ('u' + req.user.id) : ('a' + (req.ip || 'anon')); }----router.get('/daily', optionalAuth, async (req, res) => {-- try {-- const pub = await eggJob.getPublic();-- if (!pub) return res.json({ egg: null });-- const mine = await eggJob.revealedFor(_solverKey(req));-- let firstSolvers = [];-- try { firstSolvers = (await db.all('SELECT u.username, u.display_name FROM easter_egg_solves s JOIN users u ON u.id = s.user_id WHERE s.egg_date = ? ORDER BY s.id ASC LIMIT 3', [pub.date]) || []).map(r => r.display_name || r.username); } catch { firstSolvers = []; }-- res.json({-- egg: {-- ...pub,-- foundCount: await db.countEasterEggSolves(pub.date),-- solved: await db.hasSolvedEasterEgg(pub.date, _solverKey(req)),-- firstSolvers,-- fails: mine.fails,-- revealed: mine.revealed,-- revealAfterFails: eggJob.REVEAL_AFTER_FAILS,-- },-- });-- } catch (err) {-- res.status(500).json({ error: 'Failed to load easter egg' });-- }--});----// Light per-IP rate limit so the secret can't be brute-forced/spammed.--const _attempts = new Map();--router.post('/solve', optionalAuth, async (req, res) => {-- try {-- const ip = req.ip || 'anon';-- const now = Date.now();-- const rec = _attempts.get(ip) || { n: 0, t: now };-- if (now - rec.t > 60000) { rec.n = 0; rec.t = now; }-- rec.n++; _attempts.set(ip, rec);-- if (rec.n > 60) return res.status(429).json({ error: 'Too many attempts — slow down.' });---- const seq = Array.isArray(req.body && req.body.sequence) ? req.body.sequence.slice(-30) : null;-- if (!seq) return res.status(400).json({ error: 'sequence required' });---- const result = await eggJob.checkSolution(seq);-- if (!result || !result.ok) { const fails = eggJob.noteFail(_solverKey(req)); return res.json({ solved: false, fails, canReveal: fails >= eggJob.REVEAL_AFTER_FAILS }); }---- const firstTime = await db.recordEasterEggSolve(result.egg.date, _solverKey(req), (req.user && req.user.id) || null);-- res.json({-- solved: true,-- firstTime,-- egg: { title: result.egg.title, effect: result.egg.effect, reward: result.egg.reward },-- foundCount: await db.countEasterEggSolves(result.egg.date),-- });-- } catch (err) {-- res.status(500).json({ error: 'Failed to check solution' });-- }--});----// "Stuck?" — reveal one key (after a couple of wrong tries; never the last key).--router.post('/reveal', optionalAuth, async (req, res) => {-- try {-- const idx = req.body && Number.isInteger(req.body.index) ? req.body.index : null;-- const out = await eggJob.reveal(_solverKey(req), idx);-- if (out.error) return res.status(out.needFails ? 425 : 403).json(out);-- res.json(out);-- } catch (err) { res.status(500).json({ error: 'Failed to reveal' }); }--});----module.exports = router;@@@ -1469,41 +1469,58 @@ async function getViewerTrend(hours = 24, maxPoints = 48) {return out;}--// ── Home "pulse": goals near completion, latest activity, weekly leaders ──────++// ── Home pulse: current highlights and one-release fields for older clients ──────async function getHomePulse() {-- const safe = async (fn, dflt) => { try { return await fn() ?? dflt; } catch { return dflt; } };-- return {-- // Active goals closest to completion, site-wide.-- goals: await safe(async () => await all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,++ const safe = async (fn, fallback = null) => { try { return await fn() || fallback; } catch { return fallback; } };++ const liveChannels = await safe(() => all(`SELECT s.id AS stream_id, s.viewer_count, u.username, u.display_name, u.avatar_url, u.profile_color++ FROM streams s JOIN users u ON u.id=s.user_id++ WHERE s.is_live=1 AND COALESCE(s.is_nsfw,0)=0 AND COALESCE(u.is_banned,0)=0++ ORDER BY s.viewer_count DESC LIMIT 8`), []);++ const topStreamer = await safe(() => get(`SELECT u.username,u.display_name,u.avatar_url,u.profile_color,++ ROUND(SUM(CASE WHEN s.duration_seconds>0 THEN s.duration_seconds++ WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at)-julianday(s.started_at))*86400++ ELSE (julianday('now')-julianday(s.started_at))*86400 END)/3600.0,1) AS hours,++ MAX(s.peak_viewers) AS peak_viewers++ FROM streams s JOIN users u ON u.id=s.user_id++ WHERE s.started_at >= datetime('now','-7 days') AND COALESCE(s.is_nsfw,0)=0 AND COALESCE(u.is_banned,0)=0++ GROUP BY u.id ORDER BY hours DESC LIMIT 1`));++ const topFan = await safe(() => get(`SELECT u.username,u.display_name,u.avatar_url,u.profile_color,++ SUM(p.delta) AS points FROM channel_points_log p JOIN users u ON u.id=p.user_id++ WHERE p.delta>0 AND p.created_at >= datetime('now','-7 days') AND COALESCE(u.is_banned,0)=0++ GROUP BY u.id ORDER BY points DESC LIMIT 1`));++ // The previous home client still reads these fields during mixed-version deploys.++ // The new rail ignores them; keep their original data and types for one release.++ const [goals, latestTip, newestFollow, topSupporters, topEarners] = await Promise.all([++ safe(() => all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,u.username, u.display_nameFROM donation_goals g JOIN users u ON u.id = g.user_idWHERE g.is_active = 1 AND g.target_amount > 0ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),-- latestTip: await safe(async () => await get(`SELECT t.amount, t.created_at,++ safe(() => get(`SELECT t.amount, t.created_at,fu.username AS from_username, COALESCE(fu.display_name, t.username) AS from_display,tu.username AS to_username, tu.display_name AS to_displayFROM channel_tips tLEFT JOIN users fu ON fu.id = t.from_user_idJOIN users tu ON tu.id = t.to_user_id-- WHERE t.amount IS NOT NULL ORDER BY t.id DESC LIMIT 1`), null),-- newestFollow: await safe(async () => await get(`SELECT f.created_at,++ WHERE t.amount IS NOT NULL ORDER BY t.id DESC LIMIT 1`)),++ safe(() => get(`SELECT f.created_at,fu.username AS follower_username, fu.display_name AS follower_display,su.username AS streamer_username, su.display_name AS streamer_displayFROM follows fJOIN users fu ON fu.id = f.follower_idJOIN users su ON su.id = f.streamer_id-- ORDER BY f.id DESC LIMIT 1`), null),-- // This week's leaders.-- topSupporters: await safe(async () => await all(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name,++ ORDER BY f.id DESC LIMIT 1`)),++ safe(() => all(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name,MAX(u.avatar_url) AS avatar_url, SUM(t.amount)::float8 AS totalFROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_idWHERE t.amount IS NOT NULL AND t.created_at >= datetime('now', '-7 days')GROUP BY ${TIP_SUPPORTER_T} ORDER BY total DESC LIMIT 3`), []),-- topEarners: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS total++ safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS totalFROM coin_transactions c JOIN users u ON u.id = c.user_idWHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')GROUP BY u.id ORDER BY total DESC LIMIT 3`), []),-- };++ ]);++ return { liveChannels, topStreamer, topFan, goals, latestTip, newestFollow, topSupporters, topEarners };}// Nearest active goal per streamer (for Recently Online cards) — one query.@@@ -2323,21 +2340,6 @@ async function cleanupPowerchatDeliveries(days = 3) {catch { return null; }}--// ── Daily easter egg solves ──────────────────────────────────--async function recordEasterEggSolve(eggDate, solverKey, userId) {-- try {-- const res = await run('INSERT INTO easter_egg_solves (egg_date, solver_key, user_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING',-- [eggDate, String(solverKey).slice(0, 80), userId || null]);-- return !!(res && res.changes); // true = newly solved (first time today)-- } catch { return false; }--}--async function hasSolvedEasterEgg(eggDate, solverKey) {-- try { return !!await get('SELECT 1 FROM easter_egg_solves WHERE egg_date = ? AND solver_key = ?', [eggDate, String(solverKey).slice(0, 80)]); } catch { return false; }--}--async function countEasterEggSolves(eggDate) {-- try { return (await get('SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?', [eggDate]))?.n || 0; } catch { return 0; }--}--async function getUserProfile(userId) {const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,created_at, last_seen@@@ -3773,7 +3775,6 @@ module.exports = {deletePowerchatConnection, powerchatDeliveryIsNew, cleanupPowerchatDeliveries,isActiveSubscriber,getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,-- recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,// ProfilesgetUserProfile, updateUserAvatar,getKickChannelCache, setKickChannelCache,nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++++/** Quest calls carry only a Network bearer token. The browser never sends Quest a Live cookie. */++async function readDailyQuest({ token = null, timeZone = 'UTC', fetchImpl = globalThis.fetch,++ baseUrl = process.env.OV_QUEST_INTERNAL_URL || 'https://openvibe.quest' } = {}) {++ const base = String(baseUrl).replace(/\/+$/, '');++ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };++ const get = async (path, options = {}) => {++ const res = await fetchImpl(base + path, { ...options, headers: { ...headers, ...(options.headers || {}) },++ signal: AbortSignal.timeout(4500) });++ if (!res.ok) return null;++ return res.json();++ };++ try {++ if (!token) {++ const quest = await get('/api/v1/daily');++ return quest && { signed_in: false, quest };++ }++ if (timeZone && /^[A-Za-z0-9_+\/-]{1,64}$/.test(timeZone)) {++ await get('/api/v1/me/daily/time-zone', { method: 'PUT', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ time_zone: timeZone }) });++ }++ const quest = await get('/api/v1/me/daily');++ if (!quest) return null;++ return { signed_in: true, quest };++ } catch { return null; }++}++module.exports = { readDailyQuest };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++function moment(value) {++ if (!value) return NaN;++ const text = String(value);++ const ms = Date.parse(/Z$|[+-]\d\d:?\d\d$/.test(text) ? text : text.replace(' ', 'T') + 'Z');++ return Number.isFinite(ms) ? ms : NaN;++}++function duration(row, now) {++ const stored = Number(row.duration_seconds) || 0;++ if (stored > 0) return stored;++ const start = moment(row.started_at);++ const end = row.is_live ? now : moment(row.ended_at);++ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : 0;++}++function summarizeHistory(rows, now = Date.now()) {++ const sorted = (rows || []).filter((r) => Number.isFinite(moment(r.started_at)))++ .sort((a, b) => moment(b.started_at) - moment(a.started_at));++ const week = sorted.filter((r) => moment(r.started_at) >= now - 7 * 86400000 && moment(r.started_at) <= now);++ const hours = Math.round(week.reduce((n, r) => n + duration(r, now), 0) / 360) / 10;++ const starts = week.map((r) => new Date(moment(r.started_at)).getUTCHours());++ const bins = [0, 0, 0, 0];++ for (const h of starts) bins[h < 5 ? 0 : h < 12 ? 1 : h < 17 ? 2 : 3]++;++ const bucket = bins.indexOf(Math.max(...bins));++ const usual = starts.length >= 2 ? 'Usually live ' + ['late nights', 'mornings', 'afternoons', 'evenings'][bucket] : null;++ const dates = new Set(sorted.map((r) => new Date(moment(r.started_at)).toISOString().slice(0, 10)));++ let run = 0;++ const latest = sorted[0] && new Date(moment(sorted[0].started_at)).toISOString().slice(0, 10);++ if (latest) for (let d = Date.parse(latest + 'T00:00:00Z'); dates.has(new Date(d).toISOString().slice(0, 10)); d -= 86400000) run++;++ return {++ streams_this_week: week.length, hours_this_week: hours, usual_time: usual,++ days_in_a_row: run >= 3 ? run : null, last_stream_seconds: sorted[0] ? duration(sorted[0], now) : null,++ streams: sorted.slice(0, 2).map((r) => ({ id: r.id, title: r.title || 'Stream', started_at: r.started_at,++ length_seconds: duration(r, now), is_live: !!r.is_live, thumbnail_url: r.thumbnail_url || null,++ managed_stream_id: r.managed_stream_id || null })),++ };++}++module.exports = { summarizeHistory, moment, duration };@@@ -427,14 +427,29 @@ router.get('/star', async (req, res) => {}});--// ── Community pulse: goals near completion, latest activity, weekly leaders,--// AI moment showcase, and the newest shipped update. Cached ~30s server-side.++// ── Home pulse: real activity counts, AI moments and the newest shipped update. Cached ~30s.let _pulseCache = { at: 0, data: null };router.get('/pulse', async (req, res) => {try {res.set('Cache-Control', 'public, max-age=30');if (Date.now() - _pulseCache.at < 30_000 && _pulseCache.data) return res.json(_pulseCache.data);const pulse = await db.getHomePulse();++ const channels = pulse.liveChannels || [];++ pulse.liveNow = channels[0] || null;++ pulse.busiestChat = null;++ if (channels.length) {++ const reads = require('../chat/chat-reads');++ const recent = await Promise.all(channels.map(async (c) => {++ const minute = await reads.windowStats({ since: Date.now() - 60_000, streamId: c.stream_id }).catch(() => null);++ if (Number(minute && minute.messages) > 0) return { ...c, messages_per_minute: Number(minute.messages), window: 'now' };++ const hour = await reads.windowStats({ since: Date.now() - 3600_000, streamId: c.stream_id }).catch(() => null);++ return { ...c, messages_per_minute: Math.round(Number(hour && hour.messages || 0) / 60), window: 'last hour' };++ }));++ pulse.busiestChat = recent.sort((a, b) => b.messages_per_minute - a.messages_per_minute)[0] || null;++ }++ delete pulse.liveChannels;++ const quest = await require('./daily-quest').readDailyQuest();++ pulse.dailyFinished = Number(quest && quest.quest && quest.quest.finished_today) || 0;pulse.moments = (await heroMoments()).slice(0, 10);pulse.latestUpdate = _latestUpdate();_pulseCache = { at: Date.now(), data: pulse };@@@ -483,6 +498,13 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in// users), the numbers, and the best Rundown takes.const { requireAuth, optionalAuth } = require('../auth/auth');++router.get('/daily-quest', optionalAuth, async (req, res) => {++ const token = req.user ? require('../net/subject-assertion').personToken(req.headers) : null;++ const data = await require('./daily-quest').readDailyQuest({ token, timeZone: String(req.query.time_zone || 'UTC') });++ res.set('Cache-Control', 'no-store');++ if (!data) return res.status(204).end();++ return res.json(data);++});router.get('/digest', optionalAuth, async (req, res) => {try {const now = Date.now();@@@ -870,7 +870,6 @@ const searchMedia = require('./events/search-media-documents');app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);app.use('/api/chat-ai', require('./ai/chat-ai-routes'));--app.use('/api/easter-egg', require('./ai/easter-egg-routes'));app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)app.use('/api/today', require('./today/routes').createTodayRoutes()); // a channel's schedule, read from OpenVibe.Todayapp.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)@@@ -912,6 +911,14 @@ app.use('/api/img-proxy', require('./media/external-image-proxy'));const VIBE_CODING_OFF = Object.freeze({ enabled: 0, widget_title: 'Vibe Coding', viewer_depth: 'standard', show_prompts: 1, show_responses: 1, show_thinking: 0, show_tool_calls: 1, show_tool_arguments: 0, show_file_events: 1, show_file_snippets: 0, redact_file_paths: 1, paused: 0, delay_ms: 0, max_events: 18, max_prompt_chars: 220, max_response_chars: 360, max_thinking_chars: 140, max_tool_chars: 140, max_snippet_chars: 140 });app.get('/api/vibe-coding/channel/:username/:slot/events', (req, res) => res.json({ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF, publisher: null, events: [], moved_to: 'https://openvibe.chat/feeds' }));app.use('/api/vibe-coding', (req, res) => res.status(410).json({ error: 'Vibe coding feeds moved to OpenVibe.Chat', moved_to: 'https://openvibe.chat/feeds', api: 'https://openvibe.chat/api/chat/feeds' }));++// Previous Live clients still request the retired daily secret during the mixed-version window.++// Keep its empty state and validation responses for one release, without restarting the feature.++app.get('/api/easter-egg/daily', (_req, res) => res.json({ egg: null }));++app.post('/api/easter-egg/solve', (req, res) => {++ if (!req.body || !req.body.sequence) return res.status(400).json({ error: 'sequence required' });++ return res.status(410).json({ error: 'Daily secret retired', moved_to: 'https://openvibe.quest/' });++});++app.post('/api/easter-egg/reveal', (_req, res) => res.status(403).json({ error: 'No daily secret is active' }));const analyticsRoutes = require('./streaming/analytics-routes');app.use('/api/analytics', analyticsRoutes);const newsRoutes = require('./news/news-routes');@@@ -1577,7 +1584,6 @@ async function start() {try { require('./streaming/recordings').start(); } catch (e) { console.warn('[Recordings] sweep not started:', e.message); }// When each picture-in-picture was on screen, so a VOD draws it again (streaming/pip-history.js).try { require('./streaming/pip-history').start(); } catch (e) { console.warn('[PiP history] not started:', e.message); }-- try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }@@@ -1396,14 +1396,23 @@ router.get('/recently-online', async (req, res) => {}}} catch (err) { console.warn('[Streaming] recently-online live status failed:', err.message); }-- // Each streamer's nearest active donation goal → progress bar on the card.-- try {-- const goals = await db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));++ // The facts and two stream rows come from actual Live stream history, not saved channel slots.++ const ids = streamers.map(s => s.user_id).filter(Boolean);++ if (ids.length) {++ const rows = await db.all(`SELECT * FROM (++ SELECT id,user_id,title,started_at,ended_at,duration_seconds,thumbnail_url,managed_stream_id,is_live,++ ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY started_at DESC) AS history_rank++ FROM streams WHERE user_id IN (${ids.map(() => '?').join(',')}) AND started_at IS NOT NULL AND COALESCE(is_nsfw,0)=0++ ) history WHERE history_rank <= 40`, ids);++ const summarize = require('../home/recent-facts').summarizeHistory;for (const s of streamers) {-- const g = goals[s.user_id];-- if (g) s.top_goal = { title: g.title, current: g.current_amount, target: g.target_amount };++ s.recent_facts = summarize(rows.filter(r => r.user_id === s.user_id));++ for (const h of s.recent_facts.streams) {++ const slot = (s.managed_streams || []).find(ms => Number(ms.managed_stream_id) === Number(h.managed_stream_id));++ if (slot) { h.vod_id = slot.vod_id || null; h.thumbnail_url = h.thumbnail_url || slot.vod_thumbnail || null; }++ }}-- } catch { /* best-effort */ }++ }res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });} catch (err) {console.error('[Streaming]', err.message);@@@ -1,7 +1,7 @@'use strict';// The home slogans, the daily secret and the Star of OpenVibe are OpenVibe.AI templates (roadmap WS-O task 2): against// a stub Network (token endpoint) and a stub AI, each job sends its data (never a prompt) to live.hero.slogans,--// live.easter_egg or live.home.star and keeps its own rules on the answer (clean lists, clues that point at their++// live.home.star and keeps its own rules on the answer// token, only a real candidate); no answer keeps the old fallback.// node test/ai-templates-jobs.test.jsconst assert = require('assert');@@@ -50,15 +50,6 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();await db.initDb();await db.setSetting('ai_enabled', 'true');try {-- // The daily secret: AI's code, Live's clue check.-- answers['live.easter_egg'] = { title: 'Goose Run', code: ['up', 'g', 'left', 'down', 'v'], clues: ['toward the sky', "'goose' begins with it", 'the way rain falls', 'the way rain falls', "'vibe' begins with it"], effect: 'rainbow', reward: 'Honk!' };-- const egg = await require('../server/ai/easter-egg-job')._generate();-- assert.deepStrictEqual(last('live.easter_egg').input, {}, 'no vibe: an empty input, no prompt');-- assert.deepStrictEqual([egg.ai, egg.title, egg.code, egg.effect], [true, 'Goose Run', ['up', 'g', 'left', 'down', 'v'], 'rainbow']);-- assert.notStrictEqual(egg.clues[2], 'the way rain falls', 'a clue that does not point at its token is replaced');-- delete answers['live.easter_egg'];-- assert.ok(!(await require('../server/ai/easter-egg-job')._generate()).ai, 'no answer: the seeded code');--// The star: only a real, eligible candidate.const star = require('../server/home/star-job');const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, takes: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },@@@ -75,6 +75,8 @@ console.warn = () => {};// The home pulse and stats count every rail.const pulse = await db.getHomePulse();++ // The new home rail no longer draws these; the pulse still answers them for one release, for tabs on the previous++ // client (test/n-1.test.js). They go in the next release.assert.strictEqual(pulse.latestTip.from_username, 'ann', 'the newest tip with an amount');assert.strictEqual(Number(pulse.latestTip.amount), 400);assert.deepStrictEqual(pulse.topSupporters.map((r) => [r.username, r.display_name, Number(r.total)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++const assert = require('node:assert/strict');++const { readDailyQuest } = require('../server/home/daily-quest');++const { summarizeHistory } = require('../server/home/recent-facts');++++(async () => {++ const calls = [];++ const fetchImpl = async (url, options) => {++ calls.push({ url, options });++ if (url.endsWith('/api/v1/me/daily/time-zone')) return { ok: true, json: async () => ({ time_zone: 'America/Los_Angeles' }) };++ if (url.endsWith('/api/v1/me/daily')) return { ok: true, json: async () => ({++ title: 'Across OpenVibe', steps: [{ done: false }, { done: true }, { done: false }],++ progress: 1, streak: 3, time_zone: calls.some(c => c.url.endsWith('/time-zone')) ? 'America/Los_Angeles' : 'UTC',++ }) };++ if (url.endsWith('/api/v1/daily')) return { ok: true, json: async () => ({ title: 'Across OpenVibe', steps: [{}, {}, {}] }) };++ return { ok: false };++ };++ const signedOut = await readDailyQuest({ fetchImpl, baseUrl: 'https://quest.test' });++ assert.equal(signedOut.signed_in, false);++ assert.equal(calls[0].options.headers.Authorization, undefined);++ const signedIn = await readDailyQuest({ token: 'person-token', timeZone: 'America/Los_Angeles', fetchImpl, baseUrl: 'https://quest.test' });++ assert.equal(signedIn.signed_in, true);++ assert.equal(signedIn.quest.time_zone, 'America/Los_Angeles');++ assert.equal(signedIn.quest.progress, 1);++ assert.ok(calls.slice(1).every(c => c.options.headers.Authorization === 'Bearer person-token' && !('Cookie' in c.options.headers)));++ assert.equal(await readDailyQuest({ fetchImpl: async () => { throw Error('down'); } }), null);++++ const now = Date.parse('2026-11-08T22:00:00Z');++ const rows = [++ { id: 1, started_at: '2026-11-08T18:00:00Z', ended_at: '2026-11-08T20:00:00Z', duration_seconds: 7200, title: 'Today' },++ { id: 2, started_at: '2026-11-07T18:00:00Z', ended_at: '2026-11-07T19:00:00Z', duration_seconds: 3600, title: 'Yesterday' },++ { id: 3, started_at: '2026-11-06T18:00:00Z', ended_at: '2026-11-06T19:00:00Z', duration_seconds: 3600, title: 'Earlier' },++ ];++ const facts = summarizeHistory(rows, now);++ assert.equal(facts.streams_this_week, 3);++ assert.equal(facts.hours_this_week, 4);++ assert.equal(facts.usual_time, 'Usually live evenings');++ assert.equal(facts.days_in_a_row, 3);++ assert.equal(facts.last_stream_seconds, 7200);++ assert.deepEqual(facts.streams.map(s => s.title), ['Today', 'Yesterday']);++ process.stdout.write('home daily and stream history: all checks passed\n');++})().catch(e => { process.stderr.write(String(e.stack || e) + '\n'); process.exit(1); });@@@ -54,14 +54,15 @@ check('each rail keeps its header right before its grid (the density control reaassert.ok(home.slice(header, recent).includes('Recently Online'));});--check('nothing was removed', () => {-- for (const id of ['home-featured', 'stream-grid-live', 'home-digest', 'home-star-section', 'hero-egg', 'home-pulse-section',++check('the daily quest replaces the secret without moving the other sections', () => {++ for (const id of ['home-featured', 'stream-grid-live', 'home-digest', 'home-star-section', 'home-daily-quest', 'home-pulse-section','stream-grid-recent', 'stream-grid-recent-pagination', 'home-recent-vods-header', 'home-recent-vods-grid', 'home-clips-header','home-clips-grid', 'home-pastes-header', 'home-pastes-list', 'home-moments-section', 'home-tour-mount', 'home-cta-banner','home-about-body']) {at(`id="${id}"`);}assert.ok(home.includes('<div class="ovnet" id="home-network"'));++ assert.ok(!home.includes('hero-egg'));});console.log(failures ? `\n${failures} check(s) failed` : '\nhome order: all checks passed');nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs');++const vm = require('node:vm');++const { parseHTML } = require('linkedom');++++const source = fs.readFileSync(require('node:path').join(__dirname, '../public/js/app-home.js'), 'utf8');++const start = source.indexOf('function _renderHomePulse(');++const end = source.indexOf('/* ── "While you were away"', start);++assert.ok(start >= 0 && end > start);++const { document } = parseHTML('<div id="home-pulse-section"><div id="pulse-grid"></div></div>');++const context = { document, window: {}, Intl, Number, String, encodeURIComponent };++vm.runInNewContext(source.slice(start, end) + '\nthis.render = _renderHomePulse;', context);++context.render({ goals: [{ title: 'Do not show', current_amount: 1, target_amount: 2 }],++ liveNow: { username: 'ann', display_name: 'Ann', viewer_count: 12 },++ topFan: { username: 'ben', display_name: 'Ben', points: 0 },++ dailyFinished: 2 });++const cards = [...document.querySelectorAll('.pulse-fact-card')];++assert.equal(cards.length, 2);++assert.match(cards[0].textContent, /LIVE NOW.*Ann.*12.*viewers watching/s);++assert.match(cards[1].textContent, /TODAY'S QUEST.*2.*people finished today/s);++assert.ok(!document.body.textContent.includes('Do not show'));++assert.ok(!document.body.textContent.includes('Ben'));++context.render({ goals: [{ title: 'Still hidden' }], dailyFinished: 0 });++assert.equal(document.querySelectorAll('.pulse-fact-card').length, 0);++assert.equal(document.getElementById('home-pulse-section').style.display, 'none');++process.stdout.write('home pulse rail: all checks passed\n');@@@ -97,7 +97,7 @@ const intro = home.slice(introStart, introEnd);check('Around OpenVibe shows its three sections, in their order, with the egg where it was, and no tabs', () => {assert.ok(aroundStart > 0 && aroundEnd > aroundStart, 'the block opens and closes on the home page');-- const order = ['id="home-digest"', 'id="home-star-section"', 'id="hero-egg"', 'id="home-pulse-section"'].map((n) => around.indexOf(n));++ const order = ['id="home-digest"', 'id="home-star-section"', 'id="home-daily-quest"', 'id="home-pulse-section"'].map((n) => around.indexOf(n));assert.ok(order.every((i) => i > 0), 'every section is inside it');assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'in the same order as before');assert.ok(home.indexOf('id="home-around"') < home.indexOf('id="home-catchup"'), 'the block sits where the digest was');@@@ -29,7 +29,7 @@ console.warn = () => {};assert.match(src, /const shown = keepHeroFull\(moments, prev\.moments, TARGET_N\);\s*await db\.setState\(SETTING, JSON\.stringify\(\{ moments: shown,/);// Every job's command line opens the database first, as a script: the serving pool, no migrations, no boot recovery.-- for (const f of ['ai-moments-job.js', 'auto-clip-job.js', 'easter-egg-job.js']) {++ for (const f of ['ai-moments-job.js', 'auto-clip-job.js']) {const s = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', f), 'utf8');const cli = s.slice(s.indexOf('if (require.main === module)'));assert.match(cli, /initDb\(\{ script: true \}\)\.then\(/, `${f}: its command line opens the database`);@@@ -71,19 +71,20 @@ const db = require('../server/db/database');const fx = body.streamers.find((s) => s.username === 'finditfixit');assert.deepStrictEqual(fx.managed_streams.map((m) => m.slug), [null, 'bench'], 'the newest one is listed, first');assert.strictEqual(body.total, 6);++ assert.ok(body.streamers.every(s => s.recent_facts && Array.isArray(s.recent_facts.streams)));++ assert.ok(body.streamers.find(s => s.username === 'izor').recent_facts.streams.length <= 2);console.log('recently-online live: all checks passed');} finally { server.close(); }process.exit(0);})().catch((e) => { console.error(e); process.exit(1); });--// The card (public/js/app-home.js renderRecentlyOnline): an offline row with a recording opens its replay on--// openvibe.video, with a play mark; a live row, or one with no recording, is the channel (owner, 2026-10-27).++// The card renders recent streams from actual history and sends a recording to its replay.{const fs = require('fs');const path = require('path');const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');const body = src.slice(src.indexOf('function renderRecentlyOnline('), src.indexOf('async function loadHomeRecentVods('));-- assert.ok(body.includes("const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;"));-- assert.ok(body.includes('const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);'));-- assert.ok(body.includes('<span class="sg-play" aria-hidden="true">') && body.includes('<span class="sg-replay">Replay</span>'));++ assert.ok(body.includes('const replay = !h.is_live && h.vod_id;'));++ assert.ok(body.includes("row.href = replay ? videoHref('vod', h.vod_id) : channelHref;"));++ assert.ok(body.includes("(h.is_live ? 'Live' : 'Replay')"));}@@@ -76,7 +76,6 @@ try {// Tables the newer release fills.await d.prepare("INSERT INTO search_doc_pushes (user_id, hash, revision) VALUES (?, 'h', 1)").run(u.id);await d.prepare("INSERT INTO search_media_pushes (kind, media_id, hash, revision) VALUES ('vod', 1, 'h', 1)").run();-- await db.recordEasterEggSolve('2026-01-01', 'rollback-probe', u.id);const tables = await d.value("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()");await db.close();process.stdout.write(JSON.stringify({ ok: true, user: u.id, tables: Number(tables) }) + '\\n');
@@ -189,6 +189,7 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
@@ -189,6 +189,7 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
# recycling, subscriptions, balances and entitlements with its service token (audience openvibe.billing). There is no
# recycling, subscriptions, balances and entitlements with its service token (audience openvibe.billing). There is no
# other mode (BILLING_AUTHORITY is no longer read).
# other mode (BILLING_AUTHORITY is no longer read).
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
+OV_QUEST_INTERNAL_URL=http://127.0.0.1:4980
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
# Cosmetics live in OpenVibe.Inventory (ADR-054 §8): live.* items issued by service:live, Live's item ids as aliases.
# Cosmetics live in OpenVibe.Inventory (ADR-054 §8): live.* items issued by service:live, Live's item ids as aliases.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-light.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-light.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-light.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-dark.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-light.jpg differ
Binary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-light.jpg differ
@@ -196,7 +196,6 @@
@@ -196,7 +196,6 @@
".hero-bg-layer.active",
".hero-bg-layer.active",
".hero-collage",
".hero-collage",
".hero-content",
".hero-content",
- ".hero-egg-titles",
".hero-float",
".hero-float",
".hero-float img",
".hero-float img",
".hero-float--clip .hero-float-badge",
".hero-float--clip .hero-float-badge",
@@ -26,47 +26,27 @@
@@ -26,47 +26,27 @@
color: var(--accent-light);
color: var(--accent-light);
}
}
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
-.hero-egg { max-width: 520px; margin: 0 auto 22px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,0.3); text-align: left; overflow: hidden; }
-.hero-egg-head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: none; border: 0; cursor: pointer; color: var(--text-primary); font: inherit; }
-.hero-egg-icon { font-size: 1.3rem; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); animation: eggWobble 3.5s var(--ov-ease-in-out, ease-in-out) infinite; }
-.hero-egg-kicker { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }
-.hero-egg-title { font-size: 0.95rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; color: var(--text-primary); }
-.hero-egg-status { font-size: 0.72rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
-.hero-egg-status.solved { color: #06d6a0; background: color-mix(in srgb, #06d6a0 18%, transparent); }
-.hero-egg-chev { color: var(--text-muted); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
-.hero-egg.open .hero-egg-chev { transform: rotate(180deg); }
-.hero-egg-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--ov-dur-3, .35s) var(--ov-ease-out, ease); }
-.hero-egg.open .hero-egg-body { grid-template-rows: 1fr; }
-.hero-egg-inner { min-height: 0; overflow: hidden; padding: 0 14px; }
-.hero-egg.open .hero-egg-inner { padding: 0 14px 14px; }
-.hero-egg-intro { font-size: 0.78rem; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.45; }
-.hero-egg-slots { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }
-.egg-slot { width: 38px; height: 42px; border-radius: 10px; display: grid; place-items: center; font-size: 1.15rem; font-weight: 900; color: var(--text-primary); background: var(--bg-tertiary); border: 1px solid var(--border); transition: transform var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), background var(--ov-dur-2, .15s); }
-.egg-slot.is-next { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 60%, transparent); }
-.hero-egg-slots.is-wrong .egg-slot { border-color: #ff5a5f; background: color-mix(in srgb, #ff5a5f 18%, var(--bg-tertiary)); animation: eggShakeSlot var(--ov-dur-4, .45s); }
-.hero-egg-slots.is-right .egg-slot, .hero-egg-slots.is-solved .egg-slot { border-color: #06d6a0; background: color-mix(in srgb, #06d6a0 20%, var(--bg-tertiary)); }
-.hero-egg-clues { margin: 0 0 12px; padding: 0; list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; counter-reset: clue; }
-.hero-egg-clues li { display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px; font-size: 0.84rem; color: var(--text-primary); padding: 6px 8px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); }
-.egg-clue-n { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 0.75rem; font-weight: 800; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
-.egg-clue-text { font-style: italic; line-height: 1.35; }
-.egg-clue-key { font-weight: 900; font-size: 0.95rem; color: #06d6a0; background: color-mix(in srgb, #06d6a0 15%, transparent); border-radius: 8px; padding: 2px 9px; }
-.hero-egg-clues li.is-revealed { border-color: color-mix(in srgb, #06d6a0 45%, var(--border)); }
-.hero-egg-pad { display: grid; gap: 6px; margin-bottom: 10px; }
-.hero-egg-pad-arrows { display: flex; gap: 6px; flex-wrap: wrap; }
-.hero-egg-pad-letters { display: grid; grid-template-columns: repeat(13, 1fr); gap: 4px; }
-.egg-key:hover { border-color: var(--accent); }
-.egg-key:active { transform: scale(0.92); background: color-mix(in srgb, var(--accent) 25%, var(--bg-tertiary)); }
-.egg-key--ctl { margin-left: auto; color: var(--text-secondary); }
-.egg-key--ctl + .egg-key--ctl { margin-left: 0; }
-.hero-egg-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
-.hero-egg-reveal { border: 1px solid color-mix(in srgb, #fbbf24 45%, var(--border)); background: color-mix(in srgb, #fbbf24 10%, transparent); color: var(--text-primary); border-radius: 999px; padding: 5px 11px; font: inherit; font-size: 0.76rem; font-weight: 700; cursor: pointer; }
-.hero-egg-reveal:disabled { opacity: 0.6; cursor: default; }
-.hero-egg-reveal:not(:disabled):hover { border-color: #fbbf24; }
-.hero-egg-meta { font-size: 0.72rem; color: var(--text-muted); }
-@media (max-width: 480px) { .hero-egg-pad-letters { grid-template-columns: repeat(9, 1fr); } .egg-slot { width: 32px; height: 38px; } }
-body.egg-shake { animation: eggShake 0.9s var(--ov-ease-out, cubic-bezier(.36,.07,.19,.97)) both; }
-body.egg-rainbow { animation: eggRainbow 2.6s linear; }
-@media (prefers-reduced-motion: reduce) { .egg-confetti, body.egg-shake, body.egg-rainbow { animation: none !important; } .egg-confetti { display: none; } }
+.home-daily-quest { max-width: 520px; margin: 0 auto 22px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: 16px; background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,.18); text-align: left; overflow: hidden; }
+.home-daily-quest[hidden], .home-daily-body[hidden] { display: none; }
+.home-daily-head { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 56px; padding: 9px 14px; background: none; border: 0; color: var(--text-primary); font: inherit; text-align: left; cursor: pointer; }
+.home-daily-titles { display: flex; flex: 1; flex-direction: column; min-width: 0; }
+.home-daily-kicker { color: var(--accent-light, var(--accent)); font-size: .65rem; font-weight: 800; letter-spacing: .09em; }
+/* The quest's name in full (two lines on a phone, never "Make your way around …"), and a three-part bar for the steps. */
+.home-daily-title { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-size: .95rem; font-weight: 800; line-height: 1.3; }
+.home-daily-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 160px; margin-top: 6px; }
+.home-daily-bar i { height: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary) 12%, transparent); transition: background .4s ease; }
+.home-daily-quest[data-progress="1"] .home-daily-bar i:nth-child(-n+1), .home-daily-quest[data-progress="2"] .home-daily-bar i:nth-child(-n+2), .home-daily-quest[data-progress="3"] .home-daily-bar i { background: var(--accent-light, var(--accent)); }
+.home-daily-quest[data-progress="3"] .home-daily-bar i { background: var(--success, #22c55e); }
+.home-daily-chip { flex: none; padding: 5px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .72rem; font-weight: 700; }
+.home-daily-chev { color: var(--text-muted); transition: transform .2s; }
+.home-daily-quest.open .home-daily-chev { transform: rotate(180deg); }
+.home-daily-body { padding: 0 14px 14px; }
+.home-daily-steps { list-style: none; padding: 0; margin: 0 0 10px; }
+.home-daily-steps li { display: flex; align-items: center; gap: 10px; min-height: 52px; border-top: 1px solid var(--border); font-size: .85rem; }
+.home-daily-steps li > span:nth-child(2) { flex: 1; }
+.home-daily-check { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 50%; background: var(--success, #22c55e); color: #fff; font-size: .75rem; font-weight: 900; }
+.home-daily-steps li.is-done > span:nth-child(2) { color: var(--text-muted); }
+.home-daily-action { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 10px; background: var(--accent); color: var(--bg-primary); font-weight: 800; text-decoration: none; }
@media (max-width: 599px) {
@media (max-width: 599px) {
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
@@ -94,6 +74,19 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -94,6 +74,19 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);
transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);
}
}
.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }
.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }
+.recent-streamer-card { display: flex; flex-direction: column; height: 100%; min-height: 322px; }
+.recent-streamer-head { min-height: 58px; padding: 8px 12px; flex-wrap: nowrap; }
+.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }
+.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }
+.recent-streamer-facts { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 12px 8px; min-height: 49px; }
+.recent-streamer-chip { align-self: start; padding: 4px 7px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .75rem; line-height: 1.2; }
+.recent-streamer-summary { margin: 0 12px; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.recent-streamer-summary.expanded { display: block; }
+.recent-streamer-more { align-self: start; min-width: 44px; min-height: 44px; margin: 0 8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: .78rem; cursor: pointer; }
+.recent-streamer-history { margin-top: auto; padding-bottom: 0; }
+.recent-stream-row { min-height: 61px; }
+.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }
+.recent-stream-row .streamer-group-stream-info { display: flex; flex-direction: column; }
.streamer-group-header { padding: 12px 14px 0; }
.streamer-group-header { padding: 12px 14px 0; }
.streamer-group-identity {
.streamer-group-identity {
display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; gap: 8px;
@@ -177,11 +170,22 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -177,11 +170,22 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }
.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }
/* Until shipped.js fills it, the line keeps its height without showing an empty pill, so the stats below stay put. */
/* Until shipped.js fills it, the line keeps its height without showing an empty pill, so the stats below stay put. */
.hero-latest:empty { visibility: hidden; min-height: 33px; }
.hero-latest:empty { visibility: hidden; min-height: 33px; }
+.pulse-grid { display: flex; flex-wrap: nowrap; overflow-x: auto; gap: 12px; scroll-snap-type: x mandatory; scrollbar-width: none; padding-block: 2px 8px; }
+.pulse-grid::-webkit-scrollbar { display: none; }
+.pulse-grid > .pulse-fact-card { flex: 0 0 230px; min-height: 192px; scroll-snap-align: start; }
+.pulse-fact-card { display: flex; flex-direction: column; gap: 8px; }
+.pulse-fact-identity { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 750; }
+.pulse-fact-identity > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pulse-fact-face { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border-radius: 50%; overflow: hidden; background: var(--accent); color: var(--bg-primary); font-weight: 800; }
+.pulse-fact-face img { width: 100%; height: 100%; object-fit: cover; }
+.pulse-fact-number { margin-top: auto; font-size: clamp(2rem, 3vw, 2.6rem); line-height: 1; font-variant-numeric: tabular-nums; }
+.pulse-fact-context { color: var(--text-secondary); font-size: .78rem; }
.pulse-card {
.pulse-card {
display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
background: var(--bg-secondary); border: 1px solid var(--border);
background: var(--bg-secondary); border: 1px solid var(--border);
transition: border-color var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease), transform var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);
transition: border-color var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease), transform var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);
}
}
+.pulse-card.pulse-fact-card { display: flex; flex-direction: column; gap: 8px; }
.pulse-kicker { font-size: 0.75rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }
.pulse-kicker { font-size: 0.75rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }
.pulse-title { font-weight: 700; margin-bottom: 7px; }
.pulse-title { font-weight: 700; margin-bottom: 7px; }
.pulse-sub { font-size: 0.78rem; color: var(--text-muted); margin-top: 5px; }
.pulse-sub { font-size: 0.78rem; color: var(--text-muted); margin-top: 5px; }
@@ -1229,10 +1233,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1229,10 +1233,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
the word in the middle of its own box. */
the word in the middle of its own box. */
#page-home .hero-philosophy-btn { min-height: 44px; display: inline-flex; align-items: center; }
#page-home .hero-philosophy-btn { min-height: 44px; display: inline-flex; align-items: center; }
#page-home .hero-philosophy-link { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
#page-home .hero-philosophy-link { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
- /* The easter-egg keypad, open: every key is 44 px on the key itself. The letter grid reflows to as many
- 44 px columns as the panel holds (seven on a 390 px phone), so no key is narrower than a fingertip. */
- #page-home .hero-egg-pad-letters { grid-template-columns: repeat(auto-fit, minmax(44px, 1fr)); gap: 3px; }
- #page-home .hero-egg-reveal { min-height: 44px; padding: 0 16px; display: inline-flex; align-items: center; gap: 8px; }
}
}
@media (max-width: 759px) {
@media (max-width: 759px) {
@@ -471,29 +471,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -471,29 +471,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
transition: all var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease);
transition: all var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease);
}
}
-/* ── Daily easter-egg hero widget ─────────────────────────────── */
-@keyframes eggWobble { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
-.hero-egg-titles { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; flex: 1; }
-/* Body opens to its real height (the old 340px cap clipped the third clue) */
-.egg-slot.is-filled { border-color: color-mix(in srgb, var(--accent) 70%, transparent); background: color-mix(in srgb, var(--accent) 16%, var(--bg-tertiary)); animation: eggSlotIn var(--ov-dur-2, .18s) var(--ov-ease-spring, cubic-bezier(.2,1.5,.3,1)); }
-@keyframes eggSlotIn { from { transform: scale(0.7); } to { transform: scale(1); } }
-@keyframes eggShakeSlot { 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } }
-.egg-key { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-weight: 800; cursor: pointer; transition: transform var(--ov-dur-1, .1s), background var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s); }
-.egg-key--letter { min-width: 0; height: 30px; padding: 0; font-size: 0.8rem; text-transform: uppercase; }
-/* Touch (2026-11-06 phone pass, round two): every key of the easter-egg keypad is a 44 px target on the key
- itself — the four arrows, the two control keys and the 26 letter keys. A mouse keeps the small keys. */
-@media (pointer: coarse) {
- .egg-key { min-width: 44px; height: 44px; }
- .egg-key--letter { min-width: 44px; height: 44px; }
-}
-
-/* Celebration effects */
-.egg-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 100000; overflow: hidden; }
-.egg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: eggFall linear forwards; }
-@keyframes eggFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
-@keyframes eggShake { 10%,90%{transform:translate3d(-2px,0,0)} 20%,80%{transform:translate3d(4px,0,0)} 30%,50%,70%{transform:translate3d(-8px,0,0)} 40%,60%{transform:translate3d(8px,0,0)} }
-@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }
-
/* ── OpenVibeApp Popover ─────────────────────────────────────────── */
/* ── OpenVibeApp Popover ─────────────────────────────────────────── */
.openvibeapp-popover {
.openvibeapp-popover {
max-height: 0; overflow: hidden; opacity: 0;
max-height: 0; overflow: hidden; opacity: 0;
@@ -120,38 +120,20 @@
@@ -120,38 +120,20 @@
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
<div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
<div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
- <div class="hero-egg" id="hero-egg" style="display:none">
- <button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
- <span class="hero-egg-icon">🥚</span>
- <span class="hero-egg-titles"><span class="hero-egg-kicker">Today's Secret</span><span class="hero-egg-title" id="hero-egg-title"></span></span>
- <span class="hero-egg-status" id="hero-egg-status"></span>
- <i class="fa-solid fa-chevron-down hero-egg-chev"></i>
+ <div class="home-daily-quest" id="home-daily-quest" hidden>
+ <button class="home-daily-head" type="button" id="home-daily-toggle" aria-expanded="false" aria-controls="home-daily-body">
+ <span class="home-daily-icon ov-icon" data-icon="quest" data-size="32" aria-hidden="true"></span>
+ <span class="home-daily-titles"><span class="home-daily-kicker" id="home-daily-kicker"></span><span class="home-daily-title" id="home-daily-title"></span><span class="home-daily-bar" aria-hidden="true"><i></i><i></i><i></i></span></span>
+ <span class="home-daily-chip" id="home-daily-chip"></span>
+ <i class="fa-solid fa-chevron-down home-daily-chev" aria-hidden="true"></i>
</button>
</button>
- <div class="hero-egg-body">
- <div class="hero-egg-inner">
- <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
- <div class="hero-egg-slots" id="hero-egg-slots" role="group" aria-label="Your key sequence"></div>
- <ol class="hero-egg-clues" id="hero-egg-clues"></ol>
- <div class="hero-egg-pad" id="hero-egg-pad">
- <div class="hero-egg-pad-arrows">
- <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>
- <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>
- <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>
- <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>
- <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>
- <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>
- </div>
- <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>
- </div>
- <div class="hero-egg-foot">
- <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>
- <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>
- </div>
- </div>
+ <div class="home-daily-body" id="home-daily-body" hidden>
+ <ol class="home-daily-steps" id="home-daily-steps"></ol>
+ <a class="home-daily-action" id="home-daily-action" href="https://openvibe.quest/"></a>
</div>
</div>
</div>
</div>
- <!-- Community pulse: goals near completion, latest activity, weekly leaders -->
+ <!-- Network pulse: live viewers, stream hours, chat, channel points and daily quests. -->
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
<div class="section-header" style="--sc:#f59e0b">
<div class="section-header" style="--sc:#f59e0b">
<h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>
<h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>
@@ -1008,165 +1008,62 @@ function openHomeAbout() {
@@ -1008,165 +1008,62 @@ function openHomeAbout() {
banner.scrollIntoView({ behavior: 'smooth', block: 'start' });
banner.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
-// ── Daily AI easter egg ─────────────────────────────────────────────────────
-let _egg = null, _eggBuf = [], _eggSolved = false, _eggSubmitTimer = null, _eggKeysWired = false;
-const _EGG_ARROW = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
-const _EGG_GLYPH = { up: '↑', down: '↓', left: '←', right: '→' };
-let _eggRevealed = []; // [{index, token}] revealed for this solver today
-let _eggFails = 0, _eggBusy = false;
-async function loadHeroEgg() {
- const el = document.getElementById('hero-egg');
- if (!el) return;
- let data; try { data = await api('/easter-egg/daily'); } catch { return; }
- const egg = data && data.egg;
- if (!egg) { el.style.display = 'none'; return; }
- _egg = egg; _eggSolved = !!egg.solved; _eggBuf = []; _eggRevealed = egg.revealed || []; _eggFails = egg.fails || 0;
- el.style.display = '';
- const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
- set('hero-egg-title', egg.title || 'The Daily Secret');
- // Letter pad (built once)
- const letters = document.getElementById('hero-egg-letters');
- if (letters && !letters.childElementCount) letters.innerHTML = 'abcdefghijklmnopqrstuvwxyz'.split('').map(l => `<button type="button" class="egg-key egg-key--letter" data-tok="${l}">${l}</button>`).join('');
- const pad = document.getElementById('hero-egg-pad');
- if (pad && !pad.dataset.wired) {
- pad.dataset.wired = '1';
- pad.addEventListener('click', (e) => {
- const b = e.target.closest('button'); if (!b) return;
- if (b.dataset.act === 'back') { _eggBuf.pop(); _renderEggSlots(); return; }
- if (b.dataset.act === 'clear') { _eggBuf = []; _renderEggSlots(); return; }
- if (b.dataset.tok) _eggPush(b.dataset.tok);
- });
- }
- _renderEggClues();
- _renderEggSlots();
- _renderEggMeta();
- _renderEggStatus();
- _wireEggKeys();
-}
-function _eggResetLabel(ts) { const h = Math.round((ts - Date.now()) / 3600000); return h <= 1 ? 'soon' : `in ${h}h`; }
-function _renderEggStatus() {
- const s = document.getElementById('hero-egg-status'); if (!s) return;
- s.textContent = _eggSolved ? '✓ Cracked' : `${_egg ? _egg.codeLength : ''} keys`;
- s.className = 'hero-egg-status' + (_eggSolved ? ' solved' : '');
-}
-function _renderEggMeta() {
- if (!_egg) return;
- const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
- set('hero-egg-count', `${_egg.foundCount || 0} cracked it today`);
- set('hero-egg-first', (_egg.firstSolvers || []).length ? ` · first: ${_egg.firstSolvers.slice(0, 3).join(', ')}` : '');
- set('hero-egg-reset', _egg.nextResetAt ? ` · new secret ${_eggResetLabel(_egg.nextResetAt)}` : '');
- const rb = document.getElementById('hero-egg-reveal');
- if (rb) {
- const left = Math.max(0, (_egg.revealAfterFails || 2) - _eggFails);
- const done = _eggRevealed.length >= Math.max(0, _egg.codeLength - 1);
- rb.style.display = _eggSolved ? 'none' : '';
- rb.disabled = done || left > 0;
- rb.innerHTML = done ? '<i class="fa-solid fa-lightbulb"></i> The last key is yours'
- : left > 0 ? `<i class="fa-regular fa-lightbulb"></i> Stuck? ${left} more wrong tr${left === 1 ? 'y' : 'ies'} unlocks a reveal`
- : '<i class="fa-solid fa-lightbulb"></i> Stuck? Reveal the next key';
- }
-}
-function _renderEggClues() {
- const ol = document.getElementById('hero-egg-clues'); if (!ol || !_egg) return;
- const clues = _egg.clues || _egg.hints || [];
- const rev = new Map(_eggRevealed.map(r => [r.index, r.token]));
- ol.innerHTML = clues.map((c, i) => `<li class="${rev.has(i) ? 'is-revealed' : ''}"><span class="egg-clue-n">${i + 1}</span><span class="egg-clue-text">${esc(c)}</span>${rev.has(i) ? `<span class="egg-clue-key" title="Revealed">${esc(_EGG_GLYPH[rev.get(i)] || rev.get(i).toUpperCase())}</span>` : ''}</li>`).join('')
- || '<li><span class="egg-clue-text">No clues today — go on instinct.</span></li>';
-}
-function _renderEggSlots(state) {
- const host = document.getElementById('hero-egg-slots'); if (!host || !_egg) return;
- const n = _egg.codeLength || 0;
- let html = '';
- for (let i = 0; i < n; i++) {
- const t = _eggBuf[i];
- html += `<span class="egg-slot ${t ? 'is-filled' : ''} ${i === _eggBuf.length ? 'is-next' : ''}">${t ? esc(_EGG_GLYPH[t] || t.toUpperCase()) : ''}</span>`;
- }
- host.innerHTML = html;
- host.className = 'hero-egg-slots' + (state ? ` is-${state}` : '') + (_eggSolved ? ' is-solved' : '');
-}
-function _eggPush(tok) {
- if (_eggSolved || !_egg || _eggBusy) return;
- _eggBuf.push(tok);
- if (_eggBuf.length > _egg.codeLength) _eggBuf = _eggBuf.slice(-_egg.codeLength);
- _renderEggSlots();
- if (_eggBuf.length === _egg.codeLength) { clearTimeout(_eggSubmitTimer); _eggSubmitTimer = setTimeout(_submitEgg, 200); }
-}
-function toggleHeroEgg() { document.getElementById('hero-egg')?.classList.toggle('open'); }
-function _wireEggKeys() {
- if (_eggKeysWired) return;
- _eggKeysWired = true;
- document.addEventListener('keydown', (e) => {
- if (_eggSolved || !_egg) return;
- const t = e.target;
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
- if (e.ctrlKey || e.metaKey || e.altKey) return;
- // Only while the secret is on screen on the home page: everywhere else the keys (arrow
- // scrolling, a player's shortcuts, Backspace) belong to the page.
- const el = document.getElementById('hero-egg');
- const home = document.getElementById('page-home');
- if (!el || !home || !home.classList.contains('active') || el.offsetParent === null) return;
- const inside = !!(t && el.contains(t));
- const r = el.getBoundingClientRect();
- if (!inside && (r.bottom <= 0 || r.top >= window.innerHeight)) return;
- if (e.key === 'Backspace') { if (inside || el.classList.contains('open')) { _eggBuf.pop(); _renderEggSlots(); } return; }
- let tok = _EGG_ARROW[e.key];
- // A focused card rail scrolls with the arrow keys; the secret must not swallow them.
- if (tok && t && t.closest && t.closest('.ov-rail-track')) return;
- if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();
- if (!tok) return;
- // Arrows scroll the page unless someone is playing: the panel is open or has focus.
- if (_EGG_ARROW[e.key]) {
- if (!inside && !el.classList.contains('open')) return;
- e.preventDefault();
+// Quest is read by Live's server with the person's Network token. No Quest cookie crosses sites.
+async function loadDailyQuest() {
+ const card = document.getElementById('home-daily-quest');
+ if (!card) return;
+ let zone = 'UTC';
+ try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || zone; } catch { /* UTC */ }
+ let data;
+ try { data = await api('/home/daily-quest?time_zone=' + encodeURIComponent(zone)); }
+ catch { card.hidden = true; return; }
+ const quest = data && data.quest;
+ if (!quest || !Array.isArray(quest.steps) || quest.steps.length !== 3) { card.hidden = true; return; }
+ const mine = !!data.signed_in;
+ const progress = Math.max(0, Math.min(3, Number(quest.progress) || 0));
+ card.dataset.progress = mine ? String(progress) : '0'; // the three-part bar under the title
+ document.getElementById('home-daily-kicker').textContent = mine ? "TODAY'S QUEST" : 'DAILY QUESTS';
+ document.getElementById('home-daily-title').textContent = mine ? quest.title : 'Today: ' + quest.title;
+ document.getElementById('home-daily-chip').textContent = mine
+ ? quest.completed ? `Done for today · ${quest.streak}-day streak` : `${progress} of 3`
+ : 'New every day';
+ const list = document.getElementById('home-daily-steps');
+ list.replaceChildren();
+ for (const step of quest.steps) {
+ const row = document.createElement('li');
+ const icon = document.createElement('span');
+ icon.className = 'ov-icon home-daily-site-icon';
+ icon.dataset.icon = String(step.site || '').split('.')[1] || 'quest';
+ icon.dataset.size = '32';
+ icon.setAttribute('aria-hidden', 'true');
+ const label = document.createElement('span');
+ label.textContent = String(step.sentence || '');
+ row.append(icon, label);
+ if (mine && step.done) {
+ row.classList.add('is-done');
+ const done = document.createElement('span');
+ done.className = 'home-daily-check';
+ done.textContent = '✓';
+ done.setAttribute('aria-label', 'Done');
+ row.append(done);
}
}
- _eggPush(tok);
- // Typing counts as engagement: pop the panel open so people see the keys land.
- if (!el.classList.contains('open') && _eggBuf.length === 1) el.classList.add('open');
- });
-}
-async function _submitEgg() {
- if (_eggSolved || !_egg || _eggBuf.length < _egg.codeLength || _eggBusy) return;
- _eggBusy = true;
- let res; try { res = await api('/easter-egg/solve', { method: 'POST', body: { sequence: _eggBuf.slice() } }); } catch { _eggBusy = false; return; }
- _eggBusy = false;
- if (res && res.solved) {
- _eggSolved = true; _renderEggStatus(); _renderEggSlots('right');
- if (res.foundCount != null) _egg.foundCount = res.foundCount;
- _renderEggMeta();
- _celebrateEgg(res.egg || {});
- return;
+ list.append(row);
}
}
- _eggFails = (res && res.fails) || (_eggFails + 1);
- _renderEggSlots('wrong');
- _renderEggMeta();
- setTimeout(() => { _eggBuf = []; _renderEggSlots(); }, 750);
-}
-async function _eggReveal() {
- if (_eggSolved || !_egg) return;
- let out; try { out = await api('/easter-egg/reveal', { method: 'POST', body: {} }); } catch (e) { try { toast((e && e.message) || 'Not yet', 'info'); } catch { /* */ } return; }
- if (out && out.revealed) { _eggRevealed = out.revealed; _renderEggClues(); _renderEggMeta(); }
-}
-function _celebrateEgg(egg) {
- try { toast(egg.reward || "You cracked today's secret! 🎉", 'success'); } catch { /* */ }
- const effect = egg.effect || 'confetti';
- try {
- if (effect === 'shake') { document.body.classList.add('egg-shake'); setTimeout(() => document.body.classList.remove('egg-shake'), 900); }
- if (effect === 'rainbow') { document.body.classList.add('egg-rainbow'); setTimeout(() => document.body.classList.remove('egg-rainbow'), 2600); }
- const N = effect === 'fireworks' ? 170 : effect === 'matrix' ? 90 : 130;
- const cont = document.createElement('div'); cont.className = 'egg-confetti';
- const colors = effect === 'matrix' ? ['#00ff41', '#0f0', '#00c030'] : ['#ff5a5f', '#ffd166', '#06d6a0', '#4d96ff', '#c77dff', '#fff'];
- for (let i = 0; i < N; i++) {
- const p = document.createElement('span');
- p.style.left = (Math.random() * 100) + 'vw';
- p.style.background = colors[i % colors.length];
- p.style.animationDelay = (Math.random() * 0.5) + 's';
- p.style.animationDuration = (1.5 + Math.random() * 1.6) + 's';
- if (effect === 'matrix') { p.style.width = '3px'; p.style.height = (12 + Math.random() * 20) + 'px'; }
- cont.appendChild(p);
- }
- document.body.appendChild(cont);
- setTimeout(() => cont.remove(), 3800);
- } catch { /* */ }
+ const action = document.getElementById('home-daily-action');
+ const next = mine && quest.steps.find(step => !step.done);
+ action.textContent = mine ? next ? 'Do the next step' : 'See your streak' : "See today's quest";
+ action.href = next && /^https:\/\//.test(next.href || '') ? next.href : 'https://openvibe.quest/';
+ const toggle = document.getElementById('home-daily-toggle');
+ if (!toggle.dataset.wired) {
+ toggle.dataset.wired = '1';
+ toggle.addEventListener('click', () => {
+ const body = document.getElementById('home-daily-body');
+ body.hidden = !body.hidden;
+ toggle.setAttribute('aria-expanded', String(!body.hidden));
+ card.classList.toggle('open', !body.hidden);
+ });
+ }
+ card.hidden = false;
}
}
/**
/**
@@ -1411,7 +1308,7 @@ async function loadHome() {
@@ -1411,7 +1308,7 @@ async function loadHome() {
updateNavHeroTransparency(); // transparent nav over the hero at the top
updateNavHeroTransparency(); // transparent nav over the hero at the top
startHeroRotation(); // instant static rotation; loadHeroData upgrades it with AI slogans
startHeroRotation(); // instant static rotation; loadHeroData upgrades it with AI slogans
void loadHeroData(); // stats bar, floating-thumbnail collage, AI slogans
void loadHeroData(); // stats bar, floating-thumbnail collage, AI slogans
- void loadHeroEgg(); // daily AI easter egg widget
+ void loadDailyQuest(); // shared daily quest from OpenVibe.Quest
// Claim the space every feed is about to fill. Without this the page arrives as a short
// Claim the space every feed is about to fill. Without this the page arrives as a short
// column that grows in jumps as each request lands, which reads as slow and shoves content
// column that grows in jumps as each request lands, which reads as slow and shoves content
@@ -1483,39 +1380,49 @@ function _renderHomePulse(p) {
@@ -1483,39 +1380,49 @@ function _renderHomePulse(p) {
const section = document.getElementById('home-pulse-section');
const section = document.getElementById('home-pulse-section');
if (!grid || !section) return;
if (!grid || !section) return;
const cards = [];
const cards = [];
-
- // Goal cards — nearest to completion, with progress bars.
- for (const g of (p.goals || [])) {
- const pct = Math.min(100, Math.round((g.current_amount / g.target_amount) * 100));
- const href = `/@${g.username}`;
- cards.push(`
- <a class="pulse-card pulse-goal" href="${href}" onclick="return handleLinkClick(event, '${href}')">
- <div class="pulse-kicker"><i class="fa-solid fa-bullseye"></i> Goal · ${esc(g.display_name || g.username)}</div>
- <div class="pulse-title">${esc(g.title)}</div>
- <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>
- <div class="pulse-sub">${Number(g.current_amount).toLocaleString()} / ${Number(g.target_amount).toLocaleString()} Vibes · <b>${pct}%</b></div>
- </a>`);
- }
-
- // Latest activity card (tip + follow together).
- const act = [];
- if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> ${ovUserTag({ username: p.latestTip.from_username, display_name: p.latestTip.from_display }, p.latestTip.from_display || p.latestTip.from_username || 'Someone')} tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to ${ovUserTag({ username: p.latestTip.to_username, display_name: p.latestTip.to_display })} <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);
- if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> ${ovUserTag({ username: p.newestFollow.follower_username, display_name: p.newestFollow.follower_display })} followed ${ovUserTag({ username: p.newestFollow.streamer_username, display_name: p.newestFollow.streamer_display })} <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);
- if (act.length) cards.push(`<div class="pulse-card"><div class="pulse-kicker"><i class="fa-solid fa-wave-square"></i> Latest activity</div>${act.join('')}</div>`);
-
- // Weekly leader teasers.
- const board = (title, icon, rows, unit) => rows && rows.length ? `
- <div class="pulse-card">
- <div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>
- ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> ${ovUserTag(r)} <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}
- </div>` : '';
- const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');
- const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');
- if (supporters) cards.push(supporters);
- if (earners) cards.push(earners);
-
+ const add = (kicker, person, value, context, href) => {
+ const number = Number(value);
+ if (!Number.isFinite(number) || number <= 0) return;
+ const card = document.createElement('a');
+ card.className = 'pulse-card pulse-fact-card';
+ card.href = href;
+ const k = document.createElement('span');
+ k.className = 'pulse-kicker';
+ k.textContent = kicker;
+ const identity = document.createElement('span');
+ identity.className = 'pulse-fact-identity';
+ const face = document.createElement('span');
+ face.className = 'pulse-fact-face';
+ const name = String(person.display_name || person.username || person.name || 'OpenVibe');
+ face.textContent = name.charAt(0).toUpperCase();
+ if (person.avatar_url) {
+ const img = document.createElement('img');
+ img.src = person.avatar_url;
+ img.alt = '';
+ img.loading = 'lazy';
+ img.onerror = () => img.remove();
+ face.append(img);
+ }
+ const label = document.createElement('span');
+ label.textContent = name;
+ identity.append(face, label);
+ const big = document.createElement('strong');
+ big.className = 'pulse-fact-number';
+ big.textContent = new Intl.NumberFormat().format(number);
+ const sub = document.createElement('span');
+ sub.className = 'pulse-fact-context';
+ sub.textContent = context;
+ card.append(k, identity, big, sub);
+ cards.push(card);
+ };
+ if (p.liveNow) add('LIVE NOW', p.liveNow, p.liveNow.viewer_count, 'viewers watching', '/@' + encodeURIComponent(p.liveNow.username));
+ if (p.topStreamer) add('TOP STREAMER THIS WEEK', p.topStreamer, p.topStreamer.hours, 'hours streamed · peak ' + Number(p.topStreamer.peak_viewers || 0) + ' viewers', '/@' + encodeURIComponent(p.topStreamer.username));
+ if (p.busiestChat) add('BUSIEST CHAT', p.busiestChat, p.busiestChat.messages_per_minute, 'messages a minute · ' + p.busiestChat.window, '/@' + encodeURIComponent(p.busiestChat.username));
+ if (p.topFan) add('TOP FANS THIS WEEK', p.topFan, p.topFan.points, 'channel points earned', '/@' + encodeURIComponent(p.topFan.username));
+ if (p.openCoinsToday > 0) add('OPENCOINS TODAY', { name: 'OpenVibe' }, p.openCoinsToday, 'earned across the network', 'https://openvibe.network/');
+ if (p.dailyFinished > 0) add("TODAY'S QUEST", { name: 'OpenVibe.Quest' }, p.dailyFinished, 'people finished today', 'https://openvibe.quest/');
section.style.display = cards.length ? '' : 'none';
section.style.display = cards.length ? '' : 'none';
- grid.innerHTML = cards.join('');
+ grid.replaceChildren(...cards);
// AI Moments showcase row.
// AI Moments showcase row.
const momentsRow = document.getElementById('home-moments-row');
const momentsRow = document.getElementById('home-moments-row');
@@ -1614,66 +1521,71 @@ function _sgThumbFail(img) {
@@ -1614,66 +1521,71 @@ function _sgThumbFail(img) {
function renderRecentlyOnline(containerId, streamers, append) {
function renderRecentlyOnline(containerId, streamers, append) {
const container = document.getElementById(containerId);
const container = document.getElementById(containerId);
if (!container) return;
if (!container) return;
- if (!streamers.length) {
- if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';
- return;
+ if (!append) container.replaceChildren();
+ if (!streamers.length) return;
+ const el = (tag, cls, value) => {
+ const node = document.createElement(tag);
+ if (cls) node.className = cls;
+ if (value != null) node.textContent = String(value);
+ return node;
+ };
+ for (const s of streamers) {
+ const facts = s.recent_facts || {};
+ const channelHref = '/@' + encodeURIComponent(s.username || '');
+ const card = el('article', 'streamer-group-card recent-streamer-card');
+ const head = el('a', 'streamer-group-identity recent-streamer-head');
+ head.href = channelHref;
+ const face = el('span', 'recent-streamer-face', (s.display_name || s.username || '?').charAt(0).toUpperCase());
+ if (s.avatar_url) {
+ const img = el('img'); img.src = s.avatar_url; img.alt = ''; img.loading = 'lazy';
+ img.onerror = () => img.remove(); face.append(img);
+ }
+ const name = el('strong', 'streamer-group-name', s.display_name || s.username);
+ const when = el('span', 'streamer-group-last-online', s.live ? 'Live now' : 'Live ' + timeAgo(s.last_online_at));
+ head.append(face, name, when);
+ card.append(head);
+ const chips = el('div', 'recent-streamer-facts');
+ const chip = (value) => { if (value) chips.append(el('span', 'recent-streamer-chip', value)); };
+ if (facts.streams_this_week) chip(facts.streams_this_week + ' streams this week');
+ if (facts.hours_this_week) chip(facts.hours_this_week + ' h this week');
+ chip(facts.usual_time);
+ if (facts.days_in_a_row >= 3) chip(facts.days_in_a_row + ' days in a row');
+ if (facts.last_stream_seconds) chip('Last stream ' + formatDuration(facts.last_stream_seconds));
+ card.append(chips);
+ const overview = String(s.ai_overview_short || s.ai_overview || '').trim();
+ if (overview) {
+ const summary = el('p', 'recent-streamer-summary', overview);
+ const more = el('button', 'recent-streamer-more', 'More');
+ more.type = 'button'; more.setAttribute('aria-expanded', 'false');
+ more.addEventListener('click', () => {
+ const open = summary.classList.toggle('expanded');
+ more.textContent = open ? 'Less' : 'More';
+ more.setAttribute('aria-expanded', String(open));
+ });
+ card.append(summary, more);
+ }
+ const history = el('div', 'streamer-group-streams recent-streamer-history');
+ for (const h of (facts.streams || []).slice(0, 2)) {
+ const replay = !h.is_live && h.vod_id;
+ const row = el('a', 'streamer-group-stream recent-stream-row');
+ row.href = replay ? videoHref('vod', h.vod_id) : channelHref;
+ const thumb = el('span', 'streamer-group-stream-thumb');
+ const src = _sgThumbSrc(h.thumbnail_url);
+ if (src) {
+ const img = el('img', 'streamer-group-stream-thumb-img');
+ img.src = src; img.alt = ''; img.loading = 'lazy';
+ img.onerror = () => img.remove();
+ thumb.append(img);
+ }
+ const info = el('span', 'streamer-group-stream-info');
+ info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));
+ info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));
+ row.append(thumb, info);
+ history.append(row);
+ }
+ card.append(history);
+ container.append(card);
}
}
- const _put = (html) => ovPutCards(container, html, append);
- _put(streamers.map(s => {
- const msList = (s.managed_streams || []);
- const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
- const channelHref = `/@${s.username}`;
- const streamsHtml = msList.length ? msList.map(ms => {
- // An offline stream opens its replay on openvibe.video, the chat playing in time beside it: the row showed a
- // picture of the stream and led to an offline channel (owner, 2026-10-27). Live, or with no recording, the
- // row is the channel; the card's name always is.
- const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;
- const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);
- // A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load
- // becomes the placeholder (_sgThumbFail) instead of a broken image.
- const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);
- const thumb = src
- ? `<img src="${esc(src)}" alt="" loading="lazy" class="streamer-group-stream-thumb-img" onerror="_sgThumbFail(this)">`
- : _SG_THUMB_PLACEHOLDER;
- const when = ms.is_live
- ? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`
- : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}${replay ? ' · <span class="sg-replay">Replay</span>' : ''}`;
- const label = replay ? `Watch the replay of ${ms.title || 'this stream'}` : ms.is_live ? `Watch ${ms.title || 'the stream'} live` : `${ms.title || 'Stream'} on ${s.display_name || s.username}'s channel`;
- return `
- <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}${replay ? ' has-replay' : ''}" href="${href}" aria-label="${esc(label)}" onclick="return handleLinkClick(event, '${href}')">
- <div class="streamer-group-stream-thumb">${thumb}${replay ? '<span class="sg-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>' : ''}</div>
- <div class="streamer-group-stream-info">
- <div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>
- <div class="streamer-group-stream-time muted">${when}</div>
- </div>
- </a>`;
- }).join('') : `<a class="streamer-group-stream" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">
- <div class="streamer-group-stream-info"><div class="streamer-group-stream-title muted">No streams</div></div>
- </a>`;
- return `
- <div class="streamer-group-card">
- <div class="streamer-group-header">
- <a class="streamer-group-identity" href="${channelHref}" onclick="return handleLinkClick(event, '${channelHref}')">
- ${avatar}
- <span class="streamer-group-name">${esc(s.display_name || s.username)}</span>
- ${s.live
- ? `<span class="streamer-group-last-online sg-live"><span class="sg-live-dot"></span> LIVE · <i class="fa-solid fa-eye"></i> ${Number(s.live.viewer_count) || 0}</span>`
- : `<span class="streamer-group-last-online muted"><i class="fa-solid fa-clock"></i> ${timeAgo(s.last_online_at)}</span>`}
- </a>
- </div>
- ${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
- ${s.top_goal ? (() => {
- const pct = Math.min(100, Math.round((s.top_goal.current / s.top_goal.target) * 100));
- return `<div class="streamer-group-goal" title="${esc(s.top_goal.title)}: ${Number(s.top_goal.current).toLocaleString()} / ${Number(s.top_goal.target).toLocaleString()} Vibes">
- <span class="sgg-title"><i class="fa-solid fa-bullseye"></i> ${esc(s.top_goal.title)}</span>
- <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>
- <span class="sgg-pct">${pct}%</span>
- </div>`;
- })() : ''}
- <div class="streamer-group-streams">${streamsHtml}</div>
- </div>
- `;
- }).join(''));
}
}
async function loadHomeRecentVods(opts) {
async function loadHomeRecentVods(opts) {
@@ -31,7 +31,7 @@ const TERMINAL = new Set(['succeeded', 'cached', 'failed', 'cancelled']);
@@ -31,7 +31,7 @@ const TERMINAL = new Set(['succeeded', 'cached', 'failed', 'cancelled']);
const KIND_TO_WORKFLOW = {
const KIND_TO_WORKFLOW = {
ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
- moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
+ moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', home_star: 'live.home.star',
status_check: 'live.status_check',
status_check: 'live.status_check',
};
};
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
deleted file mode 100644
deleted file mode 100644
@@ -1,210 +0,0 @@
@@ -1,210 +0,0 @@
-/**
- * Daily easter-egg job — once a day the AI invents a fresh "secret code" (Konami-style key
- * sequence) themed on the community's vibe, plus cryptic-but-solvable hints. The code is kept
- * SECRET server-side (never sent to clients); users decode the hints, enter the sequence, and
- * the server validates. Persisted in the `daily_easter_egg` setting; degrades to a deterministic
- * daily code + generic hints when AI is off.
- */
-'use strict';
-const db = require('../db/database');
-const ai = require('./ai-analysis');
-const aiService = require('./ai-service');
-
-const SETTING = 'daily_easter_egg';
-const DIRS = ['up', 'down', 'left', 'right'];
-const LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('');
-const TOKENS = [...DIRS, ...LETTERS];
-const EFFECTS = ['confetti', 'fireworks', 'matrix', 'rainbow', 'shake'];
-let _busy = false;
-
-// Local date key (server-local) — one egg per calendar day.
-function _today() { return new Date().toISOString().slice(0, 10); }
-async function _load() { try { return JSON.parse(await db.getState(SETTING) || '{}') || {}; } catch { return {}; } }
-async function _due() { const p = await _load(); return !p.date || p.date !== _today() || !Array.isArray(p.code) || !p.code.length; }
-
-// Deterministic daily fallback (no AI): a seeded 7-token sequence + generic hints.
-function _seededCode() {
- let seed = 0; for (const ch of _today()) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
- const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
- const len = 5 + Math.floor(rnd() * 3);
- const code = [];
- // Konami feel: roughly half arrows, half letters, mixed.
- for (let i = 0; i < len; i++) code.push(rnd() < 0.55 ? DIRS[Math.floor(rnd() * DIRS.length)] : LETTERS[Math.floor(rnd() * LETTERS.length)]);
- if (!code.some(t => DIRS.includes(t))) code[0] = DIRS[Math.floor(rnd() * DIRS.length)];
- if (!code.some(t => !DIRS.includes(t))) code[len - 1] = LETTERS[Math.floor(rnd() * LETTERS.length)];
- return code;
-}
-function _sanitizeCode(arr) {
- if (!Array.isArray(arr)) return null;
- const code = arr.map(t => String(t || '').toLowerCase().trim())
- .map(t => ({ arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right' }[t] || t))
- .filter(t => TOKENS.includes(t));
- return (code.length >= 4 && code.length <= 9) ? code : null;
-}
-
-// ── Clue bank: a fair, readable clue for every token (used for the no-AI fallback and to
-// patch any AI clue that is missing or does not line up with the code) ─────────────────
-const DIR_CLUES = {
- up: ['toward the sky', 'the way a balloon goes', 'north on the compass', 'where the ceiling lives', 'the arrow that climbs'],
- down: ['toward the floor', 'the way rain falls', 'south on the compass', 'where roots grow', 'the arrow that sinks'],
- left: ['the side your heart is on', 'west on the compass', 'the arrow that points at the past', 'port side, sailor', 'where the sun sets'],
- right: ['the side most people write with', 'east on the compass', 'the arrow that points at the future', 'starboard, sailor', 'where the sun rises'],
-};
-const LETTER_WORDS = { a: 'arena', b: 'broadcast', c: 'clip', d: 'donate', e: 'emote', f: 'follow', g: 'goose', h: 'hype', i: 'irl', j: 'jump', k: 'keyboard', l: 'latency', m: 'mic', n: 'network', o: 'overlay', p: 'paste', q: 'queue', r: 'robot', s: 'stream', t: 'tts', u: 'upload', v: 'vibe', w: 'webrtc', x: 'xp', y: 'yap', z: 'zoom' };
-const ORD = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth'];
-function _clueFor(tok, i, rnd) {
- if (DIR_CLUES[tok]) { const list = DIR_CLUES[tok]; return list[(i + Math.floor(rnd() * list.length)) % list.length]; }
- const w = LETTER_WORDS[tok] || tok;
- const forms = [`the letter that starts "${w}"`, `"${w}" begins with it`, `the ${ORD[0]} letter of "${w}"`];
- return forms[Math.floor(rnd() * forms.length)];
-}
-function _rngFor(seedStr) { let seed = 7; for (const ch of seedStr) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0; return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; }
-// Does this clue actually point at this token? Arrow clues must use that direction's imagery and
-// none of the other three; letter clues must quote a word that starts with the letter (or say
-// "the letter X"). The model gets this wrong often enough that unchecked clues made the game
-// unwinnable — anything that fails is replaced with a clue-bank line.
-const DIR_WORDS = {
- up: /\b(up|upward|upwards|sky|skyward|heaven|heavens|north|ceiling|climb|climbs|rise|rises|rising|ascend|balloon|top|above)\b/i,
- down: /\b(down|downward|downwards|floor|ground|south|fall|falls|falling|sink|sinks|descend|roots|below|gravity|depths)\b/i,
- left: /\b(left|west|sunset|sets|heart|port|past|backward|backwards|rewind)\b/i,
- right: /\b(right|east|sunrise|rises|starboard|future|forward|write|writes)\b/i,
-};
-function _clueLooksRight(tok, clue) {
- const c = String(clue || '');
- if (!c || c.length < 6) return false;
- if (DIR_WORDS[tok]) {
- if (!DIR_WORDS[tok].test(c)) return false;
- // "rises" is shared by up (rise) and right (sunrise); resolve by the other words present.
- const others = Object.keys(DIR_WORDS).filter(d => d !== tok && new RegExp(`\\b(${d})\\b`, 'i').test(c));
- return others.length === 0;
- }
- if (new RegExp(`\\bletter\\s+["'“‘]?${tok}["'”’]?\\b`, 'i').test(c)) return true;
- const quoted = [...c.matchAll(/["'“‘]([a-z][a-z-]*)["'”’]/gi)].map(m => m[1].toLowerCase());
- return quoted.some(w => w[0] === tok);
-}
-/** One clue per token, in order — AI clues kept only when they check out, bank clues fill the gaps. */
-function _alignClues(code, aiClues) {
- const rnd = _rngFor(_today() + code.join(','));
- const out = [];
- for (let i = 0; i < code.length; i++) {
- const c = Array.isArray(aiClues) && aiClues[i] ? String(aiClues[i]).replace(/\s+/g, ' ').trim().slice(0, 110) : '';
- out.push(c && _clueLooksRight(code[i], c) ? c : _clueFor(code[i], i, rnd));
- }
- return out;
-}
-
-async function _generate() {
- // The chat's current vibe: OpenVibe.Chat's global chat-AI overview (roadmap T3).
- let vibe = '';
- try { const g = await require('../chat/insight-client').getGlobal(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
- if (ai.isEnabled && await ai.isEnabled() && ai.withinBudget && await ai.withinBudget()) {
- // The prompt and its clue rules are OpenVibe.AI's versioned template live.easter_egg (WS-O task 2).
- try {
- const j = await aiService.structured('live.easter_egg', vibe ? { vibe } : {}, { meter: { kind: 'easter_egg', role: 'legacy' } });
- if (j) {
- const code = _sanitizeCode(j.code);
- if (code) {
- return {
- title: String(j.title || 'The Daily Secret').replace(/\s+/g, ' ').trim().slice(0, 60),
- code,
- clues: _alignClues(code, j.clues || j.hints),
- effect: EFFECTS.includes(j.effect) ? j.effect : 'confetti',
- reward: String(j.reward || "You cracked today's secret! 🎉").trim().slice(0, 200),
- ai: true,
- };
- }
- }
- } catch { /* fall through */ }
- }
- // Fallback: deterministic daily code with bank clues — always solvable.
- const code = _seededCode();
- return {
- title: 'The Daily Secret',
- code,
- clues: _alignClues(code, null),
- effect: EFFECTS[code.length % EFFECTS.length],
- reward: "You cracked today's secret! 🎉",
- ai: false,
- };
-}
-
-async function tick(opts = {}) {
- if (_busy || (!opts.force && !await _due())) return;
- _busy = true;
- try {
- const egg = await _generate();
- await db.setState(SETTING, JSON.stringify({ date: _today(), ...egg, updated_at: Date.now() }));
- console.log(`[EasterEgg] New daily egg "${egg.title}" (${egg.code.length} keys, ${egg.ai ? 'AI' : 'fallback'})`);
- } catch (e) {
- console.warn('[EasterEgg] tick error:', e.message);
- } finally { _busy = false; }
-}
-
-// Public (safe) view — never leaks the code, only its length + hints.
-async function getPublic() {
- const p = await _load();
- if (!p.code || p.date !== _today()) return null;
- const now = new Date();
- const nextReset = new Date(now); nextReset.setHours(24, 0, 0, 0);
- const clues = Array.isArray(p.clues) && p.clues.length === p.code.length ? p.clues : _alignClues(p.code, p.clues || p.hints);
- return {
- date: p.date,
- title: p.title || 'The Daily Secret',
- clues,
- hints: clues, // back-compat for older clients
- codeLength: p.code.length,
- effect: p.effect || 'confetti',
- nextResetAt: nextReset.getTime(),
- ai: !!p.ai,
- };
-}
-
-// ── "Stuck?" reveals: after a couple of wrong attempts a solver may reveal keys one at a time
-// (never the last one) — enough to keep it fun, not enough to make typing pointless. Per-day,
-// per-solver, in memory (a restart just makes people try twice more).
-const _fails = new Map(); // solverKey → { date, n, revealed:Set }
-function _rec(solverKey) {
- const d = _today(); let r = _fails.get(solverKey);
- if (!r || r.date !== d) { r = { date: d, n: 0, revealed: new Set() }; _fails.set(solverKey, r); }
- if (_fails.size > 5000) { const k = _fails.keys().next().value; _fails.delete(k); }
- return r;
-}
-function noteFail(solverKey) { const r = _rec(solverKey); r.n++; return r.n; }
-const REVEAL_AFTER_FAILS = 2;
-async function reveal(solverKey, index) {
- const p = await _load();
- if (!p.code || p.date !== _today()) return { error: 'No secret today' };
- const r = _rec(solverKey);
- const i = Number.isInteger(index) ? index : [...Array(p.code.length).keys()].find(k => !r.revealed.has(k));
- if (i == null || i < 0 || i >= p.code.length) return { error: 'Nothing left to reveal' };
- if (i === p.code.length - 1 && !r.revealed.has(i)) return { error: 'The last key is yours to find', last: true };
- if (r.n < REVEAL_AFTER_FAILS && !r.revealed.has(i)) return { error: `Try ${REVEAL_AFTER_FAILS - r.n} more time${REVEAL_AFTER_FAILS - r.n === 1 ? '' : 's'} first`, needFails: REVEAL_AFTER_FAILS - r.n };
- r.revealed.add(i);
- return { index: i, token: p.code[i], revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) };
-}
-async function revealedFor(solverKey) { const p = await _load(); const r = _fails.get(solverKey); if (!p.code || !r || r.date !== _today()) return { fails: r ? r.n : 0, revealed: [] }; return { fails: r.n, revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) }; }
-
-// Validate an attempt against today's secret code (server-side only).
-async function checkSolution(sequence) {
- const p = await _load();
- if (!p.code || p.date !== _today() || !Array.isArray(sequence)) return null;
- const norm = sequence.map(t => String(t || '').toLowerCase().trim()).filter(Boolean);
- // Match if the tail of the attempt equals the code (so trailing extra keys are fine).
- if (norm.length < p.code.length) return { ok: false };
- const tail = norm.slice(norm.length - p.code.length);
- const ok = tail.every((t, i) => t === p.code[i]);
- return { ok, egg: ok ? { title: p.title, effect: p.effect, reward: p.reward, date: p.date } : null };
-}
-
-function start() {
- setTimeout(() => { tick().catch(() => {}); }, 15 * 1000);
- setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000); // self-gates on the calendar day
- console.log('[EasterEgg] Daily easter-egg job started');
-}
-
-module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS, _generate };
-
-// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
-if (require.main === module) {
- require('../db/database').initDb({ script: true }).then(() => tick({ force: true })).then(async () => { const p = await _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,78 +0,0 @@
@@ -1,78 +0,0 @@
-/**
- * easter-egg-routes.js — read + solve API for the daily easter egg.
- * GET /api/easter-egg/daily → title, hints, code LENGTH (never the code), effect, counts
- * POST /api/easter-egg/solve → validate a key sequence server-side; record the solve
- */
-'use strict';
-const express = require('express');
-const router = express.Router();
-const db = require('../db/database');
-const eggJob = require('./easter-egg-job');
-let optionalAuth = (req, res, next) => next();
-try { ({ optionalAuth } = require('../auth/auth')); } catch { /* */ }
-
-function _solverKey(req) { return (req.user && req.user.id) ? ('u' + req.user.id) : ('a' + (req.ip || 'anon')); }
-
-router.get('/daily', optionalAuth, async (req, res) => {
- try {
- const pub = await eggJob.getPublic();
- if (!pub) return res.json({ egg: null });
- const mine = await eggJob.revealedFor(_solverKey(req));
- let firstSolvers = [];
- try { firstSolvers = (await db.all('SELECT u.username, u.display_name FROM easter_egg_solves s JOIN users u ON u.id = s.user_id WHERE s.egg_date = ? ORDER BY s.id ASC LIMIT 3', [pub.date]) || []).map(r => r.display_name || r.username); } catch { firstSolvers = []; }
- res.json({
- egg: {
- ...pub,
- foundCount: await db.countEasterEggSolves(pub.date),
- solved: await db.hasSolvedEasterEgg(pub.date, _solverKey(req)),
- firstSolvers,
- fails: mine.fails,
- revealed: mine.revealed,
- revealAfterFails: eggJob.REVEAL_AFTER_FAILS,
- },
- });
- } catch (err) {
- res.status(500).json({ error: 'Failed to load easter egg' });
- }
-});
-
-// Light per-IP rate limit so the secret can't be brute-forced/spammed.
-const _attempts = new Map();
-router.post('/solve', optionalAuth, async (req, res) => {
- try {
- const ip = req.ip || 'anon';
- const now = Date.now();
- const rec = _attempts.get(ip) || { n: 0, t: now };
- if (now - rec.t > 60000) { rec.n = 0; rec.t = now; }
- rec.n++; _attempts.set(ip, rec);
- if (rec.n > 60) return res.status(429).json({ error: 'Too many attempts — slow down.' });
-
- const seq = Array.isArray(req.body && req.body.sequence) ? req.body.sequence.slice(-30) : null;
- if (!seq) return res.status(400).json({ error: 'sequence required' });
-
- const result = await eggJob.checkSolution(seq);
- if (!result || !result.ok) { const fails = eggJob.noteFail(_solverKey(req)); return res.json({ solved: false, fails, canReveal: fails >= eggJob.REVEAL_AFTER_FAILS }); }
-
- const firstTime = await db.recordEasterEggSolve(result.egg.date, _solverKey(req), (req.user && req.user.id) || null);
- res.json({
- solved: true,
- firstTime,
- egg: { title: result.egg.title, effect: result.egg.effect, reward: result.egg.reward },
- foundCount: await db.countEasterEggSolves(result.egg.date),
- });
- } catch (err) {
- res.status(500).json({ error: 'Failed to check solution' });
- }
-});
-
-// "Stuck?" — reveal one key (after a couple of wrong tries; never the last key).
-router.post('/reveal', optionalAuth, async (req, res) => {
- try {
- const idx = req.body && Number.isInteger(req.body.index) ? req.body.index : null;
- const out = await eggJob.reveal(_solverKey(req), idx);
- if (out.error) return res.status(out.needFails ? 425 : 403).json(out);
- res.json(out);
- } catch (err) { res.status(500).json({ error: 'Failed to reveal' }); }
-});
-
-module.exports = router;
@@ -1469,41 +1469,58 @@ async function getViewerTrend(hours = 24, maxPoints = 48) {
@@ -1469,41 +1469,58 @@ async function getViewerTrend(hours = 24, maxPoints = 48) {
return out;
return out;
}
}
-// ── Home "pulse": goals near completion, latest activity, weekly leaders ──────
+// ── Home pulse: current highlights and one-release fields for older clients ──────
async function getHomePulse() {
async function getHomePulse() {
- const safe = async (fn, dflt) => { try { return await fn() ?? dflt; } catch { return dflt; } };
- return {
- // Active goals closest to completion, site-wide.
- goals: await safe(async () => await all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,
+ const safe = async (fn, fallback = null) => { try { return await fn() || fallback; } catch { return fallback; } };
+ const liveChannels = await safe(() => all(`SELECT s.id AS stream_id, s.viewer_count, u.username, u.display_name, u.avatar_url, u.profile_color
+ FROM streams s JOIN users u ON u.id=s.user_id
+ WHERE s.is_live=1 AND COALESCE(s.is_nsfw,0)=0 AND COALESCE(u.is_banned,0)=0
+ ORDER BY s.viewer_count DESC LIMIT 8`), []);
+ const topStreamer = await safe(() => get(`SELECT u.username,u.display_name,u.avatar_url,u.profile_color,
+ ROUND(SUM(CASE WHEN s.duration_seconds>0 THEN s.duration_seconds
+ WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at)-julianday(s.started_at))*86400
+ ELSE (julianday('now')-julianday(s.started_at))*86400 END)/3600.0,1) AS hours,
+ MAX(s.peak_viewers) AS peak_viewers
+ FROM streams s JOIN users u ON u.id=s.user_id
+ WHERE s.started_at >= datetime('now','-7 days') AND COALESCE(s.is_nsfw,0)=0 AND COALESCE(u.is_banned,0)=0
+ GROUP BY u.id ORDER BY hours DESC LIMIT 1`));
+ const topFan = await safe(() => get(`SELECT u.username,u.display_name,u.avatar_url,u.profile_color,
+ SUM(p.delta) AS points FROM channel_points_log p JOIN users u ON u.id=p.user_id
+ WHERE p.delta>0 AND p.created_at >= datetime('now','-7 days') AND COALESCE(u.is_banned,0)=0
+ GROUP BY u.id ORDER BY points DESC LIMIT 1`));
+ // The previous home client still reads these fields during mixed-version deploys.
+ // The new rail ignores them; keep their original data and types for one release.
+ const [goals, latestTip, newestFollow, topSupporters, topEarners] = await Promise.all([
+ safe(() => all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,
u.username, u.display_name
u.username, u.display_name
FROM donation_goals g JOIN users u ON u.id = g.user_id
FROM donation_goals g JOIN users u ON u.id = g.user_id
WHERE g.is_active = 1 AND g.target_amount > 0
WHERE g.is_active = 1 AND g.target_amount > 0
ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),
ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),
- latestTip: await safe(async () => await get(`SELECT t.amount, t.created_at,
+ safe(() => get(`SELECT t.amount, t.created_at,
fu.username AS from_username, COALESCE(fu.display_name, t.username) AS from_display,
fu.username AS from_username, COALESCE(fu.display_name, t.username) AS from_display,
tu.username AS to_username, tu.display_name AS to_display
tu.username AS to_username, tu.display_name AS to_display
FROM channel_tips t
FROM channel_tips t
LEFT JOIN users fu ON fu.id = t.from_user_id
LEFT JOIN users fu ON fu.id = t.from_user_id
JOIN users tu ON tu.id = t.to_user_id
JOIN users tu ON tu.id = t.to_user_id
- WHERE t.amount IS NOT NULL ORDER BY t.id DESC LIMIT 1`), null),
- newestFollow: await safe(async () => await get(`SELECT f.created_at,
+ WHERE t.amount IS NOT NULL ORDER BY t.id DESC LIMIT 1`)),
+ safe(() => get(`SELECT f.created_at,
fu.username AS follower_username, fu.display_name AS follower_display,
fu.username AS follower_username, fu.display_name AS follower_display,
su.username AS streamer_username, su.display_name AS streamer_display
su.username AS streamer_username, su.display_name AS streamer_display
FROM follows f
FROM follows f
JOIN users fu ON fu.id = f.follower_id
JOIN users fu ON fu.id = f.follower_id
JOIN users su ON su.id = f.streamer_id
JOIN users su ON su.id = f.streamer_id
- ORDER BY f.id DESC LIMIT 1`), null),
- // This week's leaders.
- topSupporters: await safe(async () => await all(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name,
+ ORDER BY f.id DESC LIMIT 1`)),
+ safe(() => all(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name,
MAX(u.avatar_url) AS avatar_url, SUM(t.amount)::float8 AS total
MAX(u.avatar_url) AS avatar_url, SUM(t.amount)::float8 AS total
FROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_id
FROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_id
WHERE t.amount IS NOT NULL AND t.created_at >= datetime('now', '-7 days')
WHERE t.amount IS NOT NULL AND t.created_at >= datetime('now', '-7 days')
GROUP BY ${TIP_SUPPORTER_T} ORDER BY total DESC LIMIT 3`), []),
GROUP BY ${TIP_SUPPORTER_T} ORDER BY total DESC LIMIT 3`), []),
- topEarners: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS total
+ safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS total
FROM coin_transactions c JOIN users u ON u.id = c.user_id
FROM coin_transactions c JOIN users u ON u.id = c.user_id
WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')
WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')
GROUP BY u.id ORDER BY total DESC LIMIT 3`), []),
GROUP BY u.id ORDER BY total DESC LIMIT 3`), []),
- };
+ ]);
+ return { liveChannels, topStreamer, topFan, goals, latestTip, newestFollow, topSupporters, topEarners };
}
}
// Nearest active goal per streamer (for Recently Online cards) — one query.
// Nearest active goal per streamer (for Recently Online cards) — one query.
@@ -2323,21 +2340,6 @@ async function cleanupPowerchatDeliveries(days = 3) {
@@ -2323,21 +2340,6 @@ async function cleanupPowerchatDeliveries(days = 3) {
catch { return null; }
catch { return null; }
}
}
-// ── Daily easter egg solves ──────────────────────────────────
-async function recordEasterEggSolve(eggDate, solverKey, userId) {
- try {
- const res = await run('INSERT INTO easter_egg_solves (egg_date, solver_key, user_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING',
- [eggDate, String(solverKey).slice(0, 80), userId || null]);
- return !!(res && res.changes); // true = newly solved (first time today)
- } catch { return false; }
-}
-async function hasSolvedEasterEgg(eggDate, solverKey) {
- try { return !!await get('SELECT 1 FROM easter_egg_solves WHERE egg_date = ? AND solver_key = ?', [eggDate, String(solverKey).slice(0, 80)]); } catch { return false; }
-}
-async function countEasterEggSolves(eggDate) {
- try { return (await get('SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?', [eggDate]))?.n || 0; } catch { return 0; }
-}
-
async function getUserProfile(userId) {
async function getUserProfile(userId) {
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
created_at, last_seen
created_at, last_seen
@@ -3773,7 +3775,6 @@ module.exports = {
@@ -3773,7 +3775,6 @@ module.exports = {
deletePowerchatConnection, powerchatDeliveryIsNew, cleanupPowerchatDeliveries,
deletePowerchatConnection, powerchatDeliveryIsNew, cleanupPowerchatDeliveries,
isActiveSubscriber,
isActiveSubscriber,
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
- recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
// Profiles
// Profiles
getUserProfile, updateUserAvatar,
getUserProfile, updateUserAvatar,
getKickChannelCache, setKickChannelCache,
getKickChannelCache, setKickChannelCache,
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+
+/** Quest calls carry only a Network bearer token. The browser never sends Quest a Live cookie. */
+async function readDailyQuest({ token = null, timeZone = 'UTC', fetchImpl = globalThis.fetch,
+ baseUrl = process.env.OV_QUEST_INTERNAL_URL || 'https://openvibe.quest' } = {}) {
+ const base = String(baseUrl).replace(/\/+$/, '');
+ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
+ const get = async (path, options = {}) => {
+ const res = await fetchImpl(base + path, { ...options, headers: { ...headers, ...(options.headers || {}) },
+ signal: AbortSignal.timeout(4500) });
+ if (!res.ok) return null;
+ return res.json();
+ };
+ try {
+ if (!token) {
+ const quest = await get('/api/v1/daily');
+ return quest && { signed_in: false, quest };
+ }
+ if (timeZone && /^[A-Za-z0-9_+\/-]{1,64}$/.test(timeZone)) {
+ await get('/api/v1/me/daily/time-zone', { method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ time_zone: timeZone }) });
+ }
+ const quest = await get('/api/v1/me/daily');
+ if (!quest) return null;
+ return { signed_in: true, quest };
+ } catch { return null; }
+}
+module.exports = { readDailyQuest };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+function moment(value) {
+ if (!value) return NaN;
+ const text = String(value);
+ const ms = Date.parse(/Z$|[+-]\d\d:?\d\d$/.test(text) ? text : text.replace(' ', 'T') + 'Z');
+ return Number.isFinite(ms) ? ms : NaN;
+}
+function duration(row, now) {
+ const stored = Number(row.duration_seconds) || 0;
+ if (stored > 0) return stored;
+ const start = moment(row.started_at);
+ const end = row.is_live ? now : moment(row.ended_at);
+ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : 0;
+}
+function summarizeHistory(rows, now = Date.now()) {
+ const sorted = (rows || []).filter((r) => Number.isFinite(moment(r.started_at)))
+ .sort((a, b) => moment(b.started_at) - moment(a.started_at));
+ const week = sorted.filter((r) => moment(r.started_at) >= now - 7 * 86400000 && moment(r.started_at) <= now);
+ const hours = Math.round(week.reduce((n, r) => n + duration(r, now), 0) / 360) / 10;
+ const starts = week.map((r) => new Date(moment(r.started_at)).getUTCHours());
+ const bins = [0, 0, 0, 0];
+ for (const h of starts) bins[h < 5 ? 0 : h < 12 ? 1 : h < 17 ? 2 : 3]++;
+ const bucket = bins.indexOf(Math.max(...bins));
+ const usual = starts.length >= 2 ? 'Usually live ' + ['late nights', 'mornings', 'afternoons', 'evenings'][bucket] : null;
+ const dates = new Set(sorted.map((r) => new Date(moment(r.started_at)).toISOString().slice(0, 10)));
+ let run = 0;
+ const latest = sorted[0] && new Date(moment(sorted[0].started_at)).toISOString().slice(0, 10);
+ if (latest) for (let d = Date.parse(latest + 'T00:00:00Z'); dates.has(new Date(d).toISOString().slice(0, 10)); d -= 86400000) run++;
+ return {
+ streams_this_week: week.length, hours_this_week: hours, usual_time: usual,
+ days_in_a_row: run >= 3 ? run : null, last_stream_seconds: sorted[0] ? duration(sorted[0], now) : null,
+ streams: sorted.slice(0, 2).map((r) => ({ id: r.id, title: r.title || 'Stream', started_at: r.started_at,
+ length_seconds: duration(r, now), is_live: !!r.is_live, thumbnail_url: r.thumbnail_url || null,
+ managed_stream_id: r.managed_stream_id || null })),
+ };
+}
+module.exports = { summarizeHistory, moment, duration };
@@ -427,14 +427,29 @@ router.get('/star', async (req, res) => {
@@ -427,14 +427,29 @@ router.get('/star', async (req, res) => {
}
}
});
});
-// ── Community pulse: goals near completion, latest activity, weekly leaders,
-// AI moment showcase, and the newest shipped update. Cached ~30s server-side.
+// ── Home pulse: real activity counts, AI moments and the newest shipped update. Cached ~30s.
let _pulseCache = { at: 0, data: null };
let _pulseCache = { at: 0, data: null };
router.get('/pulse', async (req, res) => {
router.get('/pulse', async (req, res) => {
try {
try {
res.set('Cache-Control', 'public, max-age=30');
res.set('Cache-Control', 'public, max-age=30');
if (Date.now() - _pulseCache.at < 30_000 && _pulseCache.data) return res.json(_pulseCache.data);
if (Date.now() - _pulseCache.at < 30_000 && _pulseCache.data) return res.json(_pulseCache.data);
const pulse = await db.getHomePulse();
const pulse = await db.getHomePulse();
+ const channels = pulse.liveChannels || [];
+ pulse.liveNow = channels[0] || null;
+ pulse.busiestChat = null;
+ if (channels.length) {
+ const reads = require('../chat/chat-reads');
+ const recent = await Promise.all(channels.map(async (c) => {
+ const minute = await reads.windowStats({ since: Date.now() - 60_000, streamId: c.stream_id }).catch(() => null);
+ if (Number(minute && minute.messages) > 0) return { ...c, messages_per_minute: Number(minute.messages), window: 'now' };
+ const hour = await reads.windowStats({ since: Date.now() - 3600_000, streamId: c.stream_id }).catch(() => null);
+ return { ...c, messages_per_minute: Math.round(Number(hour && hour.messages || 0) / 60), window: 'last hour' };
+ }));
+ pulse.busiestChat = recent.sort((a, b) => b.messages_per_minute - a.messages_per_minute)[0] || null;
+ }
+ delete pulse.liveChannels;
+ const quest = await require('./daily-quest').readDailyQuest();
+ pulse.dailyFinished = Number(quest && quest.quest && quest.quest.finished_today) || 0;
pulse.moments = (await heroMoments()).slice(0, 10);
pulse.moments = (await heroMoments()).slice(0, 10);
pulse.latestUpdate = _latestUpdate();
pulse.latestUpdate = _latestUpdate();
_pulseCache = { at: Date.now(), data: pulse };
_pulseCache = { at: Date.now(), data: pulse };
@@ -483,6 +498,13 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.
@@ -483,6 +498,13 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.
// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
// users), the numbers, and the best Rundown takes.
// users), the numbers, and the best Rundown takes.
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
+router.get('/daily-quest', optionalAuth, async (req, res) => {
+ const token = req.user ? require('../net/subject-assertion').personToken(req.headers) : null;
+ const data = await require('./daily-quest').readDailyQuest({ token, timeZone: String(req.query.time_zone || 'UTC') });
+ res.set('Cache-Control', 'no-store');
+ if (!data) return res.status(204).end();
+ return res.json(data);
+});
router.get('/digest', optionalAuth, async (req, res) => {
router.get('/digest', optionalAuth, async (req, res) => {
try {
try {
const now = Date.now();
const now = Date.now();
@@ -870,7 +870,6 @@ const searchMedia = require('./events/search-media-documents');
@@ -870,7 +870,6 @@ const searchMedia = require('./events/search-media-documents');
app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);
app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);
app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
-app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)
app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)
app.use('/api/today', require('./today/routes').createTodayRoutes()); // a channel's schedule, read from OpenVibe.Today
app.use('/api/today', require('./today/routes').createTodayRoutes()); // a channel's schedule, read from OpenVibe.Today
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
@@ -912,6 +911,14 @@ app.use('/api/img-proxy', require('./media/external-image-proxy'));
@@ -912,6 +911,14 @@ app.use('/api/img-proxy', require('./media/external-image-proxy'));
const VIBE_CODING_OFF = Object.freeze({ enabled: 0, widget_title: 'Vibe Coding', viewer_depth: 'standard', show_prompts: 1, show_responses: 1, show_thinking: 0, show_tool_calls: 1, show_tool_arguments: 0, show_file_events: 1, show_file_snippets: 0, redact_file_paths: 1, paused: 0, delay_ms: 0, max_events: 18, max_prompt_chars: 220, max_response_chars: 360, max_thinking_chars: 140, max_tool_chars: 140, max_snippet_chars: 140 });
const VIBE_CODING_OFF = Object.freeze({ enabled: 0, widget_title: 'Vibe Coding', viewer_depth: 'standard', show_prompts: 1, show_responses: 1, show_thinking: 0, show_tool_calls: 1, show_tool_arguments: 0, show_file_events: 1, show_file_snippets: 0, redact_file_paths: 1, paused: 0, delay_ms: 0, max_events: 18, max_prompt_chars: 220, max_response_chars: 360, max_thinking_chars: 140, max_tool_chars: 140, max_snippet_chars: 140 });
app.get('/api/vibe-coding/channel/:username/:slot/events', (req, res) => res.json({ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF, publisher: null, events: [], moved_to: 'https://openvibe.chat/feeds' }));
app.get('/api/vibe-coding/channel/:username/:slot/events', (req, res) => res.json({ managed_stream: null, live_stream_id: null, settings: VIBE_CODING_OFF, publisher: null, events: [], moved_to: 'https://openvibe.chat/feeds' }));
app.use('/api/vibe-coding', (req, res) => res.status(410).json({ error: 'Vibe coding feeds moved to OpenVibe.Chat', moved_to: 'https://openvibe.chat/feeds', api: 'https://openvibe.chat/api/chat/feeds' }));
app.use('/api/vibe-coding', (req, res) => res.status(410).json({ error: 'Vibe coding feeds moved to OpenVibe.Chat', moved_to: 'https://openvibe.chat/feeds', api: 'https://openvibe.chat/api/chat/feeds' }));
+// Previous Live clients still request the retired daily secret during the mixed-version window.
+// Keep its empty state and validation responses for one release, without restarting the feature.
+app.get('/api/easter-egg/daily', (_req, res) => res.json({ egg: null }));
+app.post('/api/easter-egg/solve', (req, res) => {
+ if (!req.body || !req.body.sequence) return res.status(400).json({ error: 'sequence required' });
+ return res.status(410).json({ error: 'Daily secret retired', moved_to: 'https://openvibe.quest/' });
+});
+app.post('/api/easter-egg/reveal', (_req, res) => res.status(403).json({ error: 'No daily secret is active' }));
const analyticsRoutes = require('./streaming/analytics-routes');
const analyticsRoutes = require('./streaming/analytics-routes');
app.use('/api/analytics', analyticsRoutes);
app.use('/api/analytics', analyticsRoutes);
const newsRoutes = require('./news/news-routes');
const newsRoutes = require('./news/news-routes');
@@ -1577,7 +1584,6 @@ async function start() {
@@ -1577,7 +1584,6 @@ async function start() {
try { require('./streaming/recordings').start(); } catch (e) { console.warn('[Recordings] sweep not started:', e.message); }
try { require('./streaming/recordings').start(); } catch (e) { console.warn('[Recordings] sweep not started:', e.message); }
// When each picture-in-picture was on screen, so a VOD draws it again (streaming/pip-history.js).
// When each picture-in-picture was on screen, so a VOD draws it again (streaming/pip-history.js).
try { require('./streaming/pip-history').start(); } catch (e) { console.warn('[PiP history] not started:', e.message); }
try { require('./streaming/pip-history').start(); } catch (e) { console.warn('[PiP history] not started:', e.message); }
- try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
@@ -1396,14 +1396,23 @@ router.get('/recently-online', async (req, res) => {
@@ -1396,14 +1396,23 @@ router.get('/recently-online', async (req, res) => {
}
}
}
}
} catch (err) { console.warn('[Streaming] recently-online live status failed:', err.message); }
} catch (err) { console.warn('[Streaming] recently-online live status failed:', err.message); }
- // Each streamer's nearest active donation goal → progress bar on the card.
- try {
- const goals = await db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));
+ // The facts and two stream rows come from actual Live stream history, not saved channel slots.
+ const ids = streamers.map(s => s.user_id).filter(Boolean);
+ if (ids.length) {
+ const rows = await db.all(`SELECT * FROM (
+ SELECT id,user_id,title,started_at,ended_at,duration_seconds,thumbnail_url,managed_stream_id,is_live,
+ ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY started_at DESC) AS history_rank
+ FROM streams WHERE user_id IN (${ids.map(() => '?').join(',')}) AND started_at IS NOT NULL AND COALESCE(is_nsfw,0)=0
+ ) history WHERE history_rank <= 40`, ids);
+ const summarize = require('../home/recent-facts').summarizeHistory;
for (const s of streamers) {
for (const s of streamers) {
- const g = goals[s.user_id];
- if (g) s.top_goal = { title: g.title, current: g.current_amount, target: g.target_amount };
+ s.recent_facts = summarize(rows.filter(r => r.user_id === s.user_id));
+ for (const h of s.recent_facts.streams) {
+ const slot = (s.managed_streams || []).find(ms => Number(ms.managed_stream_id) === Number(h.managed_stream_id));
+ if (slot) { h.vod_id = slot.vod_id || null; h.thumbnail_url = h.thumbnail_url || slot.vod_thumbnail || null; }
+ }
}
}
- } catch { /* best-effort */ }
+ }
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
} catch (err) {
} catch (err) {
console.error('[Streaming]', err.message);
console.error('[Streaming]', err.message);
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The home slogans, the daily secret and the Star of OpenVibe are OpenVibe.AI templates (roadmap WS-O task 2): against
// The home slogans, the daily secret and the Star of OpenVibe are OpenVibe.AI templates (roadmap WS-O task 2): against
// a stub Network (token endpoint) and a stub AI, each job sends its data (never a prompt) to live.hero.slogans,
// a stub Network (token endpoint) and a stub AI, each job sends its data (never a prompt) to live.hero.slogans,
-// live.easter_egg or live.home.star and keeps its own rules on the answer (clean lists, clues that point at their
+// live.home.star and keeps its own rules on the answer
// token, only a real candidate); no answer keeps the old fallback.
// token, only a real candidate); no answer keeps the old fallback.
// node test/ai-templates-jobs.test.js
// node test/ai-templates-jobs.test.js
const assert = require('assert');
const assert = require('assert');
@@ -50,15 +50,6 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
@@ -50,15 +50,6 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
await db.initDb();
await db.initDb();
await db.setSetting('ai_enabled', 'true');
await db.setSetting('ai_enabled', 'true');
try {
try {
- // The daily secret: AI's code, Live's clue check.
- answers['live.easter_egg'] = { title: 'Goose Run', code: ['up', 'g', 'left', 'down', 'v'], clues: ['toward the sky', "'goose' begins with it", 'the way rain falls', 'the way rain falls', "'vibe' begins with it"], effect: 'rainbow', reward: 'Honk!' };
- const egg = await require('../server/ai/easter-egg-job')._generate();
- assert.deepStrictEqual(last('live.easter_egg').input, {}, 'no vibe: an empty input, no prompt');
- assert.deepStrictEqual([egg.ai, egg.title, egg.code, egg.effect], [true, 'Goose Run', ['up', 'g', 'left', 'down', 'v'], 'rainbow']);
- assert.notStrictEqual(egg.clues[2], 'the way rain falls', 'a clue that does not point at its token is replaced');
- delete answers['live.easter_egg'];
- assert.ok(!(await require('../server/ai/easter-egg-job')._generate()).ai, 'no answer: the seeded code');
-
// The star: only a real, eligible candidate.
// The star: only a real, eligible candidate.
const star = require('../server/home/star-job');
const star = require('../server/home/star-job');
const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, takes: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },
const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, takes: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },
@@ -75,6 +75,8 @@ console.warn = () => {};
@@ -75,6 +75,8 @@ console.warn = () => {};
// The home pulse and stats count every rail.
// The home pulse and stats count every rail.
const pulse = await db.getHomePulse();
const pulse = await db.getHomePulse();
+ // The new home rail no longer draws these; the pulse still answers them for one release, for tabs on the previous
+ // client (test/n-1.test.js). They go in the next release.
assert.strictEqual(pulse.latestTip.from_username, 'ann', 'the newest tip with an amount');
assert.strictEqual(pulse.latestTip.from_username, 'ann', 'the newest tip with an amount');
assert.strictEqual(Number(pulse.latestTip.amount), 400);
assert.strictEqual(Number(pulse.latestTip.amount), 400);
assert.deepStrictEqual(pulse.topSupporters.map((r) => [r.username, r.display_name, Number(r.total)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);
assert.deepStrictEqual(pulse.topSupporters.map((r) => [r.username, r.display_name, Number(r.total)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { readDailyQuest } = require('../server/home/daily-quest');
+const { summarizeHistory } = require('../server/home/recent-facts');
+
+(async () => {
+ const calls = [];
+ const fetchImpl = async (url, options) => {
+ calls.push({ url, options });
+ if (url.endsWith('/api/v1/me/daily/time-zone')) return { ok: true, json: async () => ({ time_zone: 'America/Los_Angeles' }) };
+ if (url.endsWith('/api/v1/me/daily')) return { ok: true, json: async () => ({
+ title: 'Across OpenVibe', steps: [{ done: false }, { done: true }, { done: false }],
+ progress: 1, streak: 3, time_zone: calls.some(c => c.url.endsWith('/time-zone')) ? 'America/Los_Angeles' : 'UTC',
+ }) };
+ if (url.endsWith('/api/v1/daily')) return { ok: true, json: async () => ({ title: 'Across OpenVibe', steps: [{}, {}, {}] }) };
+ return { ok: false };
+ };
+ const signedOut = await readDailyQuest({ fetchImpl, baseUrl: 'https://quest.test' });
+ assert.equal(signedOut.signed_in, false);
+ assert.equal(calls[0].options.headers.Authorization, undefined);
+ const signedIn = await readDailyQuest({ token: 'person-token', timeZone: 'America/Los_Angeles', fetchImpl, baseUrl: 'https://quest.test' });
+ assert.equal(signedIn.signed_in, true);
+ assert.equal(signedIn.quest.time_zone, 'America/Los_Angeles');
+ assert.equal(signedIn.quest.progress, 1);
+ assert.ok(calls.slice(1).every(c => c.options.headers.Authorization === 'Bearer person-token' && !('Cookie' in c.options.headers)));
+ assert.equal(await readDailyQuest({ fetchImpl: async () => { throw Error('down'); } }), null);
+
+ const now = Date.parse('2026-11-08T22:00:00Z');
+ const rows = [
+ { id: 1, started_at: '2026-11-08T18:00:00Z', ended_at: '2026-11-08T20:00:00Z', duration_seconds: 7200, title: 'Today' },
+ { id: 2, started_at: '2026-11-07T18:00:00Z', ended_at: '2026-11-07T19:00:00Z', duration_seconds: 3600, title: 'Yesterday' },
+ { id: 3, started_at: '2026-11-06T18:00:00Z', ended_at: '2026-11-06T19:00:00Z', duration_seconds: 3600, title: 'Earlier' },
+ ];
+ const facts = summarizeHistory(rows, now);
+ assert.equal(facts.streams_this_week, 3);
+ assert.equal(facts.hours_this_week, 4);
+ assert.equal(facts.usual_time, 'Usually live evenings');
+ assert.equal(facts.days_in_a_row, 3);
+ assert.equal(facts.last_stream_seconds, 7200);
+ assert.deepEqual(facts.streams.map(s => s.title), ['Today', 'Yesterday']);
+ process.stdout.write('home daily and stream history: all checks passed\n');
+})().catch(e => { process.stderr.write(String(e.stack || e) + '\n'); process.exit(1); });
@@ -54,14 +54,15 @@ check('each rail keeps its header right before its grid (the density control rea
@@ -54,14 +54,15 @@ check('each rail keeps its header right before its grid (the density control rea
assert.ok(home.slice(header, recent).includes('Recently Online'));
assert.ok(home.slice(header, recent).includes('Recently Online'));
});
});
-check('nothing was removed', () => {
- for (const id of ['home-featured', 'stream-grid-live', 'home-digest', 'home-star-section', 'hero-egg', 'home-pulse-section',
+check('the daily quest replaces the secret without moving the other sections', () => {
+ for (const id of ['home-featured', 'stream-grid-live', 'home-digest', 'home-star-section', 'home-daily-quest', 'home-pulse-section',
'stream-grid-recent', 'stream-grid-recent-pagination', 'home-recent-vods-header', 'home-recent-vods-grid', 'home-clips-header',
'stream-grid-recent', 'stream-grid-recent-pagination', 'home-recent-vods-header', 'home-recent-vods-grid', 'home-clips-header',
'home-clips-grid', 'home-pastes-header', 'home-pastes-list', 'home-moments-section', 'home-tour-mount', 'home-cta-banner',
'home-clips-grid', 'home-pastes-header', 'home-pastes-list', 'home-moments-section', 'home-tour-mount', 'home-cta-banner',
'home-about-body']) {
'home-about-body']) {
at(`id="${id}"`);
at(`id="${id}"`);
}
}
assert.ok(home.includes('<div class="ovnet" id="home-network"'));
assert.ok(home.includes('<div class="ovnet" id="home-network"'));
+ assert.ok(!home.includes('hero-egg'));
});
});
console.log(failures ? `\n${failures} check(s) failed` : '\nhome order: all checks passed');
console.log(failures ? `\n${failures} check(s) failed` : '\nhome order: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const { parseHTML } = require('linkedom');
+
+const source = fs.readFileSync(require('node:path').join(__dirname, '../public/js/app-home.js'), 'utf8');
+const start = source.indexOf('function _renderHomePulse(');
+const end = source.indexOf('/* ── "While you were away"', start);
+assert.ok(start >= 0 && end > start);
+const { document } = parseHTML('<div id="home-pulse-section"><div id="pulse-grid"></div></div>');
+const context = { document, window: {}, Intl, Number, String, encodeURIComponent };
+vm.runInNewContext(source.slice(start, end) + '\nthis.render = _renderHomePulse;', context);
+context.render({ goals: [{ title: 'Do not show', current_amount: 1, target_amount: 2 }],
+ liveNow: { username: 'ann', display_name: 'Ann', viewer_count: 12 },
+ topFan: { username: 'ben', display_name: 'Ben', points: 0 },
+ dailyFinished: 2 });
+const cards = [...document.querySelectorAll('.pulse-fact-card')];
+assert.equal(cards.length, 2);
+assert.match(cards[0].textContent, /LIVE NOW.*Ann.*12.*viewers watching/s);
+assert.match(cards[1].textContent, /TODAY'S QUEST.*2.*people finished today/s);
+assert.ok(!document.body.textContent.includes('Do not show'));
+assert.ok(!document.body.textContent.includes('Ben'));
+context.render({ goals: [{ title: 'Still hidden' }], dailyFinished: 0 });
+assert.equal(document.querySelectorAll('.pulse-fact-card').length, 0);
+assert.equal(document.getElementById('home-pulse-section').style.display, 'none');
+process.stdout.write('home pulse rail: all checks passed\n');
@@ -97,7 +97,7 @@ const intro = home.slice(introStart, introEnd);
@@ -97,7 +97,7 @@ const intro = home.slice(introStart, introEnd);
check('Around OpenVibe shows its three sections, in their order, with the egg where it was, and no tabs', () => {
check('Around OpenVibe shows its three sections, in their order, with the egg where it was, and no tabs', () => {
assert.ok(aroundStart > 0 && aroundEnd > aroundStart, 'the block opens and closes on the home page');
assert.ok(aroundStart > 0 && aroundEnd > aroundStart, 'the block opens and closes on the home page');
- const order = ['id="home-digest"', 'id="home-star-section"', 'id="hero-egg"', 'id="home-pulse-section"'].map((n) => around.indexOf(n));
+ const order = ['id="home-digest"', 'id="home-star-section"', 'id="home-daily-quest"', 'id="home-pulse-section"'].map((n) => around.indexOf(n));
assert.ok(order.every((i) => i > 0), 'every section is inside it');
assert.ok(order.every((i) => i > 0), 'every section is inside it');
assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'in the same order as before');
assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'in the same order as before');
assert.ok(home.indexOf('id="home-around"') < home.indexOf('id="home-catchup"'), 'the block sits where the digest was');
assert.ok(home.indexOf('id="home-around"') < home.indexOf('id="home-catchup"'), 'the block sits where the digest was');
@@ -29,7 +29,7 @@ console.warn = () => {};
@@ -29,7 +29,7 @@ console.warn = () => {};
assert.match(src, /const shown = keepHeroFull\(moments, prev\.moments, TARGET_N\);\s*await db\.setState\(SETTING, JSON\.stringify\(\{ moments: shown,/);
assert.match(src, /const shown = keepHeroFull\(moments, prev\.moments, TARGET_N\);\s*await db\.setState\(SETTING, JSON\.stringify\(\{ moments: shown,/);
// Every job's command line opens the database first, as a script: the serving pool, no migrations, no boot recovery.
// Every job's command line opens the database first, as a script: the serving pool, no migrations, no boot recovery.
- for (const f of ['ai-moments-job.js', 'auto-clip-job.js', 'easter-egg-job.js']) {
+ for (const f of ['ai-moments-job.js', 'auto-clip-job.js']) {
const s = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', f), 'utf8');
const s = fs.readFileSync(path.join(__dirname, '..', 'server', 'ai', f), 'utf8');
const cli = s.slice(s.indexOf('if (require.main === module)'));
const cli = s.slice(s.indexOf('if (require.main === module)'));
assert.match(cli, /initDb\(\{ script: true \}\)\.then\(/, `${f}: its command line opens the database`);
assert.match(cli, /initDb\(\{ script: true \}\)\.then\(/, `${f}: its command line opens the database`);
@@ -71,19 +71,20 @@ const db = require('../server/db/database');
@@ -71,19 +71,20 @@ const db = require('../server/db/database');
const fx = body.streamers.find((s) => s.username === 'finditfixit');
const fx = body.streamers.find((s) => s.username === 'finditfixit');
assert.deepStrictEqual(fx.managed_streams.map((m) => m.slug), [null, 'bench'], 'the newest one is listed, first');
assert.deepStrictEqual(fx.managed_streams.map((m) => m.slug), [null, 'bench'], 'the newest one is listed, first');
assert.strictEqual(body.total, 6);
assert.strictEqual(body.total, 6);
+ assert.ok(body.streamers.every(s => s.recent_facts && Array.isArray(s.recent_facts.streams)));
+ assert.ok(body.streamers.find(s => s.username === 'izor').recent_facts.streams.length <= 2);
console.log('recently-online live: all checks passed');
console.log('recently-online live: all checks passed');
} finally { server.close(); }
} finally { server.close(); }
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
-// The card (public/js/app-home.js renderRecentlyOnline): an offline row with a recording opens its replay on
-// openvibe.video, with a play mark; a live row, or one with no recording, is the channel (owner, 2026-10-27).
+// The card renders recent streams from actual history and sends a recording to its replay.
{
{
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
const body = src.slice(src.indexOf('function renderRecentlyOnline('), src.indexOf('async function loadHomeRecentVods('));
const body = src.slice(src.indexOf('function renderRecentlyOnline('), src.indexOf('async function loadHomeRecentVods('));
- assert.ok(body.includes("const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;"));
- assert.ok(body.includes('const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);'));
- assert.ok(body.includes('<span class="sg-play" aria-hidden="true">') && body.includes('<span class="sg-replay">Replay</span>'));
+ assert.ok(body.includes('const replay = !h.is_live && h.vod_id;'));
+ assert.ok(body.includes("row.href = replay ? videoHref('vod', h.vod_id) : channelHref;"));
+ assert.ok(body.includes("(h.is_live ? 'Live' : 'Replay')"));
}
}
@@ -76,7 +76,6 @@ try {
@@ -76,7 +76,6 @@ try {
// Tables the newer release fills.
// Tables the newer release fills.
await d.prepare("INSERT INTO search_doc_pushes (user_id, hash, revision) VALUES (?, 'h', 1)").run(u.id);
await d.prepare("INSERT INTO search_doc_pushes (user_id, hash, revision) VALUES (?, 'h', 1)").run(u.id);
await d.prepare("INSERT INTO search_media_pushes (kind, media_id, hash, revision) VALUES ('vod', 1, 'h', 1)").run();
await d.prepare("INSERT INTO search_media_pushes (kind, media_id, hash, revision) VALUES ('vod', 1, 'h', 1)").run();
- await db.recordEasterEggSolve('2026-01-01', 'rollback-probe', u.id);
const tables = await d.value("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()");
const tables = await d.value("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()");
await db.close();
await db.close();
process.stdout.write(JSON.stringify({ ok: true, user: u.id, tables: Number(tables) }) + '\\n');
process.stdout.write(JSON.stringify({ ok: true, user: u.id, tables: Number(tables) }) + '\\n');