openvibers/openvibe.live Commits

Replace home secret with daily quest and live stats (#715)

OP OpenVibers committed 4 days ago · 6c14808de3 · 32 files, +444 −694

* Replace Live secret with daily quest and home stats

* Merge remote-tracking branch 'origin/main' into feat/home-daily-quest-1108

* Keep prior Live home clients compatible during daily quest rollout

* Home pulse: the old client's fields go now, not after a release

Goals, the latest tip, the newest follow, top supporters and top earners were kept for one release for the old client.
Few people, clean cuts (the owner): they go with the new rail, and the home pulse keeps its rule of featuring no
donation goals or tips (test/channel-tips.test.js).

* Today's quest: its name in full, a three-part bar for the steps, done steps marked

The name was cut to one line ("Make your way around …"); it takes two. A bar of three under it fills as steps are done
(green when all three are). A done step reads quieter, with a round check.

* Revert "Home pulse: the old client's fields go now, not after a release"

This reverts commit 78fa487f08aa907abdbfb8194e447e4531ce643a.

* Home pulse: the previous client's fields stay one release (N-1), and the tips test says so

The mixed-version gate (test/n-1.test.js, ADR-016) runs the previous release's home client against this server: it still
reads goals, latestTip and newestFollow. They stay for this release and go in the next; the tips test checks them again.

* Merge remote-tracking branch 'origin/main' into HEAD

* Merge remote-tracking branch 'origin/main' into HEAD

Changes

ddiff --git a/.env.example b/.env.example
iindex 910dde15..2316a0de 100644
---- a/.env.example
++++ b/.env.example
@@@ -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:4980
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.
ddiff --git a/docs/screenshots/daily-home/signed-in-1280-dark.jpg b/docs/screenshots/daily-home/signed-in-1280-dark.jpg
nnew file mode 100644
iindex 00000000..d5537002
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-dark.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-in-1280-light.jpg b/docs/screenshots/daily-home/signed-in-1280-light.jpg
nnew file mode 100644
iindex 00000000..d42b7146
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-1280-light.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-in-390-dark.jpg b/docs/screenshots/daily-home/signed-in-390-dark.jpg
nnew file mode 100644
iindex 00000000..d0e830d0
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-dark.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-in-390-light.jpg b/docs/screenshots/daily-home/signed-in-390-light.jpg
nnew file mode 100644
iindex 00000000..12053d97
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-in-390-light.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-out-1280-dark.jpg b/docs/screenshots/daily-home/signed-out-1280-dark.jpg
nnew file mode 100644
iindex 00000000..6349539e
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-dark.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-out-1280-light.jpg b/docs/screenshots/daily-home/signed-out-1280-light.jpg
nnew file mode 100644
iindex 00000000..9e56d985
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-1280-light.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-out-390-dark.jpg b/docs/screenshots/daily-home/signed-out-390-dark.jpg
nnew file mode 100644
iindex 00000000..6bbe7391
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-dark.jpg differ
ddiff --git a/docs/screenshots/daily-home/signed-out-390-light.jpg b/docs/screenshots/daily-home/signed-out-390-light.jpg
nnew file mode 100644
iindex 00000000..246633dc
BBinary files /dev/null and b/docs/screenshots/daily-home/signed-out-390-light.jpg differ
ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex 84df0ac0..c8cda375 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -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",
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 3a39157c..c420ef30 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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) {
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 9f956c00..a6fb2261 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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;
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 4b746177..efe1097b 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -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>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 0dc04c1d..23301596 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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 top
startHeroRotation(); // instant static rotation; loadHeroData upgrades it with 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
// 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) {
ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex 97a49464..0a02b586 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -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'];
ddiff --git a/server/ai/easter-egg-job.js b/server/ai/easter-egg-job.js
ddeleted file mode 100644
iindex 9ebf130e..00000000
---- a/server/ai/easter-egg-job.js
++++ /dev/null
@@@ -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); });
--}
ddiff --git a/server/ai/easter-egg-routes.js b/server/ai/easter-egg-routes.js
ddeleted file mode 100644
iindex e4b0229c..00000000
---- a/server/ai/easter-egg-routes.js
++++ /dev/null
@@@ -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;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 7e74cac5..494f8e99 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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_name
FROM donation_goals g JOIN users u ON u.id = g.user_id
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`), []),
-- 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_display
FROM channel_tips t
LEFT JOIN users fu ON fu.id = t.from_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,
su.username AS streamer_username, su.display_name AS streamer_display
FROM follows f
JOIN users fu ON fu.id = f.follower_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
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')
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
WHERE 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,
// Profiles
getUserProfile, updateUserAvatar,
getKickChannelCache, setKickChannelCache,
ddiff --git a/server/home/daily-quest.js b/server/home/daily-quest.js
nnew file mode 100644
iindex 00000000..011884c0
---- /dev/null
++++ b/server/home/daily-quest.js
@@@ -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 };
ddiff --git a/server/home/recent-facts.js b/server/home/recent-facts.js
nnew file mode 100644
iindex 00000000..f968acfa
---- /dev/null
++++ b/server/home/recent-facts.js
@@@ -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 };
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex a77874e6..d8204822 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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();
ddiff --git a/server/index.js b/server/index.js
iindex b012cfa9..1e6d16ff 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.Today
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'));
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); }
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex ccdae64d..6826fc20 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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);
ddiff --git a/test/ai-templates-jobs.test.js b/test/ai-templates-jobs.test.js
iindex 68a8b1c3..c0784be9 100644
---- a/test/ai-templates-jobs.test.js
++++ b/test/ai-templates-jobs.test.js
@@@ -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.js
const 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' },
ddiff --git a/test/channel-tips.test.js b/test/channel-tips.test.js
iindex 4c497401..fb1756a1 100644
---- a/test/channel-tips.test.js
++++ b/test/channel-tips.test.js
@@@ -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]]);
ddiff --git a/test/home-daily.test.js b/test/home-daily.test.js
nnew file mode 100644
iindex 00000000..daee0e50
---- /dev/null
++++ b/test/home-daily.test.js
@@@ -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); });
ddiff --git a/test/home-order.test.js b/test/home-order.test.js
iindex 09c4cc9e..264d1879 100644
---- a/test/home-order.test.js
++++ b/test/home-order.test.js
@@@ -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'));
});
--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');
ddiff --git a/test/home-pulse-rail.test.js b/test/home-pulse-rail.test.js
nnew file mode 100644
iindex 00000000..f0209955
---- /dev/null
++++ b/test/home-pulse-rail.test.js
@@@ -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');
ddiff --git a/test/home-tabs.test.js b/test/home-tabs.test.js
iindex db95d935..91e5ec7c 100644
---- a/test/home-tabs.test.js
++++ b/test/home-tabs.test.js
@@@ -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');
ddiff --git a/test/moments-keep-hero.test.js b/test/moments-keep-hero.test.js
iindex 7a4329f5..367e10d3 100644
---- a/test/moments-keep-hero.test.js
++++ b/test/moments-keep-hero.test.js
@@@ -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`);
ddiff --git a/test/recently-online-live.test.js b/test/recently-online-live.test.js
iindex 83de213d..73908aab 100644
---- a/test/recently-online-live.test.js
++++ b/test/recently-online-live.test.js
@@@ -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')"));
}
ddiff --git a/test/rollback-newer-writes.test.js b/test/rollback-newer-writes.test.js
iindex fd79da20..d801d05e 100644
---- a/test/rollback-newer-writes.test.js
++++ b/test/rollback-newer-writes.test.js
@@@ -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');