openvibers/openvibe.quest Commits
Catalogue Live setup tasks with exact event matching (#277)
Changes
@@@ -2,6 +2,7 @@## Unreleased++- Catalogue Live's seventeen individually guided setup tasks, and count only the matching task event for each step.- Keep personalized AI wording off until the person records an explicit choice and language; choose only guided step kinds for that person.- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.- Let a channel owner save a quest made from guided catalogue steps through a default-off API.nnew file mode 100644@@@ -0,0 +1,3 @@++-- phase: expand++-- Match a Live setup event to its one task without storing any other payload data.++ALTER TABLE daily_events ADD COLUMN event_detail text;@@@ -6,6 +6,14 @@ const { randomBytes } = require('node:crypto');const catalog = require('./catalog');const { personOf } = require('./apply');const store = require('./store');++const SETUP_DAILY = new Set(['slot', 'method', 'profile', 'offline', 'emote', 'panels', 'share', 'moderation', 'paste']);++const SETUP_TYPES = catalog.QUESTS.flatMap((quest) => quest.steps++ .filter((step) => step.event_type === 'live.setup.task_completed' && step.match?.task)++ .map((step) => Object.freeze({ id: `setup-${step.match.task}`, event_type: step.event_type,++ site: 'openvibe.live', sentence: quest.title, href: 'https://openvibe.live/?guide=setup-hub',++ weight: 3, hook: `live.setup.${step.match.task}`, help: quest.why, match_task: step.match.task,++ daily_eligible: SETUP_DAILY.has(step.match.task), count: 1,++ verification: 'the matching setup task event for this person', status: 'approved', source: 'quest' })));const BASE_TYPES = Object.freeze([['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],@@@ -25,7 +33,7 @@ const BASE_TYPES = Object.freeze([].map(([id, event_type, site, sentence, href, weight, hook, help]) => Object.freeze({id, event_type, site, sentence, href, weight, hook, help, count: 1, verification: 'event subject, count within the person\'s local day',status: 'approved', source: 'quest',--})));++})).concat(SETUP_TYPES));const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);for (const t of BASE_TYPES) if (!KNOWN_TOPICS.has(t.event_type)) throw new Error(`Unknown daily event topic: ${t.event_type}`);const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;@@@ -65,6 +73,7 @@ function rotation(day, types, yesterday) {function wireType(t) {return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,...(t.hook ? { hook: t.hook, help: t.help } : {}),++ ...(t.match_task ? { match_task: t.match_task, daily_eligible: t.daily_eligible } : {}),weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',status: t.status, source: t.source || t.project_id };}@@@ -124,7 +133,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aif (!DAY_RE.test(day) || !Number.isFinite(Date.parse(day + 'T00:00:00Z'))) return null;const existing = await read(day);if (existing) return existing;-- const types = await allTypes();++ const types = (await allTypes()).filter((step) => !step.match_task);const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);const yesterday = await read(priorDay);let chosen = null, title = '', story = '', source = 'rotation';@@@ -156,12 +165,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aconst cached = await readPersonal(subject, day);if (cached) return cached;const preference = await aiPreference(subject);-- const types = (await allTypes()).filter((step) => step.hook && step.help);++ const types = (await allTypes()).filter((step) => step.hook && step.help && step.daily_eligible !== false);++ const completedSetup = await db.many(`SELECT quest_id FROM quest_progress++ WHERE subject=$1 AND quest_id LIKE 'live-setup-%' AND count > 0`, [subject]);++ const completedIds = new Set(completedSetup.map((row) => row.quest_id.replace(/^live-setup-/, 'setup-')));const since = new Date(s.now() - 30 * 86400000).toISOString();-- const history = await db.many('SELECT DISTINCT event_type FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);-- const done = new Set(history.map((x) => x.event_type));-- const fresh = types.filter((x) => !done.has(x.event_type));-- const candidates = fresh.length >= 3 ? fresh : types;++ const history = await db.many('SELECT DISTINCT event_type,event_detail FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);++ const done = new Set(history.map((x) => `${x.event_type}:${x.event_detail || ''}`));++ const available = types.filter((step) => !completedIds.has(step.id));++ const fresh = available.filter((step) => !done.has(`${step.event_type}:${step.match_task || ''}`));++ const candidates = fresh.length >= 3 ? fresh : available;let chosen = null, title = '', story = '', source = 'rotation', lines = null;if (preference.ai_text_enabled) try {const ai = await generateAi(day, candidates, null, preference.language);@@@ -174,7 +187,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a} catch (err) { log.warn('[Daily quest] Personal AI unavailable:', err.message); }if (!chosen) {try { chosen = rotation(day, candidates, null); }-- catch { chosen = rotation(day, types, null); }++ catch { chosen = rotation(day, available, null); }title = 'Make your way around OpenVibe';story = 'Three small things, across the network, to make today yours.';}@@@ -199,7 +212,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a}async function putChannelQuest(subject, input) {const title = typeof input?.title === 'string' ? input.title.trim() : '';-- const types = (await allTypes()).filter((step) => step.hook && step.help);++ const types = (await allTypes()).filter((step) => step.hook && step.help && !step.match_task);const steps = validateSelection(input?.steps, types, null);if (!title || title.length > 80 || !steps) return null;await db.exec(`INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)@@@ -240,8 +253,9 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aif (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;const occurred = new Date(event.timestamp);if (!Number.isFinite(occurred.getTime()) || Math.abs(s.now() - occurred.getTime()) > 3 * 86400000) return null;-- await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at) VALUES ($1,$2,$3,$4)-- ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);++ const detail = event.event_type === 'live.setup.task_completed' && typeof event.payload?.task === 'string' ? event.payload.task : null;++ await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at,event_detail) VALUES ($1,$2,$3,$4,$5)++ ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString(), detail]);return subject;}// Read-only: unlike progress(), a card must never ensure quests or settle rewards.@@@ -263,12 +277,14 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, aconst q = selectedQuest || await ensure(day);if (!q) return null;const start = new Date(day + 'T00:00:00Z').getTime();-- const events = await db.many(`SELECT event_type,occurred_at FROM daily_events++ const events = await db.many(`SELECT event_type,occurred_at,event_detail FROM daily_eventsWHERE subject=$1 AND occurred_at >= $2 AND occurred_at < $3`,[subject, new Date(start - 86400000).toISOString(), new Date(start + 2 * 86400000).toISOString()]);const local = events.filter((e) => localDay(e.occurred_at, zone) === day);-- const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, local.filter((e) => e.event_type === step.event_type).length),-- done: local.some((e) => e.event_type === step.event_type) }));++ const counts = (step) => local.filter((event) => event.event_type === step.event_type++ && (!step.match_task || event.event_detail === step.match_task)).length;++ const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, counts(step)),++ done: counts(step) >= step.count }));const done = steps.every((step) => step.done);if (done && savedZone) {const at = s.iso();@@@ -11,6 +11,17 @@ const { ids, serviceAuth } = require('openvibe-contracts');}) });const user = t.network.addUser('daily');try {++ await check('catalogue carries the Live setup controls and at least thirty kinds', async () => {++ const kinds = await t.ctx.daily.allTypes();++ assert.ok(kinds.length >= 30);++ const setup = kinds.filter((step) => step.id.startsWith('setup-'));++ assert.strictEqual(setup.length, 17);++ for (const step of setup) {++ assert.strictEqual(step.hook, `live.setup.${step.match_task}`);++ assert.strictEqual(new URL(step.href).hostname, 'openvibe.live');++ assert.ok(step.help);++ }++ });await check('today reader is private and permits network-site bearer requests', async () => {assert.strictEqual((await t.get('/api/v1/quests/today')).status, 401);const preflight = await t.get('/api/v1/quests/today', { method: 'OPTIONS', headers: { origin: 'https://openvibe.pics', 'access-control-request-method': 'GET' } });@@@ -24,6 +35,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);const today = await t.ctx.daily.ensure();assert.deepStrictEqual(today.steps.map((x) => x.id), ['go-live', 'chat', 'wiki']);++ assert.ok(today.steps.every((step) => !step.match_task), 'the public quest does not assign streamer-only setup tasks to everyone');assert.strictEqual(today.source, 'ai');const same = await t.ctx.daily.ensure();assert.deepStrictEqual(same, today);@@@ -161,6 +173,16 @@ const { ids, serviceAuth } = require('openvibe-contracts');assert.strictEqual(publicQuest.json().title, request.title);} finally { await g.close(); }});++ await check('one setup task event cannot finish a different task', async () => {++ const task = BASE_TYPES.find((step) => step.id === 'setup-slot');++ const chosen = { date: '2026-11-08', title: 'Set up', story: '', source: 'rotation', steps: [task] };++ const base = { event_type: 'live.setup.task_completed', timestamp: '2026-11-08T18:00:00Z',++ payload: { user: { type: 'user', id: user.subject } } };++ await t.ctx.daily.recordEvent(t.ctx.s.db, { ...base, event_id: 'evt_setup_method', payload: { ...base.payload, task: 'method' } });++ assert.strictEqual((await t.ctx.daily.progress(user.subject, chosen.date, chosen)).steps[0].done, false);++ await t.ctx.daily.recordEvent(t.ctx.s.db, { ...base, event_id: 'evt_setup_slot', payload: { ...base.payload, task: 'slot' } });++ assert.strictEqual((await t.ctx.daily.progress(user.subject, chosen.date, chosen)).steps[0].done, true);++ });} finally { await t.close(); }done();})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });@@@ -21,7 +21,8 @@ const BUDGETS = {// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.-- htmlRawKB: 89.5, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)++ // Shared 4.205.0 on main measures 89.9 KB raw; 89.5 KB came from Shared 4.202.0.++ htmlRawKB: 90, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)htmlBrotliKB: 17.5, // 17.5 openvibe-shared v4.202.0 (2026-11-11: measured 17.5, raised by pin-bump from 17); 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 394.5, // 394.5: measured 394.1 with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## Unreleased
+- Catalogue Live's seventeen individually guided setup tasks, and count only the matching task event for each step.
- Keep personalized AI wording off until the person records an explicit choice and language; choose only guided step kinds for that person.
- Keep personalized AI wording off until the person records an explicit choice and language; choose only guided step kinds for that person.
- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.
- Give guided picture, thread and calendar steps opaque per-person links and an authenticated step reader for the host page.
- Let a channel owner save a quest made from guided catalogue steps through a default-off API.
- Let a channel owner save a quest made from guided catalogue steps through a default-off API.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+-- phase: expand
+-- Match a Live setup event to its one task without storing any other payload data.
+ALTER TABLE daily_events ADD COLUMN event_detail text;
@@ -6,6 +6,14 @@ const { randomBytes } = require('node:crypto');
@@ -6,6 +6,14 @@ const { randomBytes } = require('node:crypto');
const catalog = require('./catalog');
const catalog = require('./catalog');
const { personOf } = require('./apply');
const { personOf } = require('./apply');
const store = require('./store');
const store = require('./store');
+const SETUP_DAILY = new Set(['slot', 'method', 'profile', 'offline', 'emote', 'panels', 'share', 'moderation', 'paste']);
+const SETUP_TYPES = catalog.QUESTS.flatMap((quest) => quest.steps
+ .filter((step) => step.event_type === 'live.setup.task_completed' && step.match?.task)
+ .map((step) => Object.freeze({ id: `setup-${step.match.task}`, event_type: step.event_type,
+ site: 'openvibe.live', sentence: quest.title, href: 'https://openvibe.live/?guide=setup-hub',
+ weight: 3, hook: `live.setup.${step.match.task}`, help: quest.why, match_task: step.match.task,
+ daily_eligible: SETUP_DAILY.has(step.match.task), count: 1,
+ verification: 'the matching setup task event for this person', status: 'approved', source: 'quest' })));
const BASE_TYPES = Object.freeze([
const BASE_TYPES = Object.freeze([
['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],
['go-live', 'live.stream.started', 'openvibe.live', 'Go live for a stream', 'https://openvibe.live/broadcast', 3],
@@ -25,7 +33,7 @@ const BASE_TYPES = Object.freeze([
@@ -25,7 +33,7 @@ const BASE_TYPES = Object.freeze([
].map(([id, event_type, site, sentence, href, weight, hook, help]) => Object.freeze({
].map(([id, event_type, site, sentence, href, weight, hook, help]) => Object.freeze({
id, event_type, site, sentence, href, weight, hook, help, count: 1, verification: 'event subject, count within the person\'s local day',
id, event_type, site, sentence, href, weight, hook, help, count: 1, verification: 'event subject, count within the person\'s local day',
status: 'approved', source: 'quest',
status: 'approved', source: 'quest',
-})));
+})).concat(SETUP_TYPES));
const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);
const KNOWN_TOPICS = new Set(catalog.EVENT_TYPES);
for (const t of BASE_TYPES) if (!KNOWN_TOPICS.has(t.event_type)) throw new Error(`Unknown daily event topic: ${t.event_type}`);
for (const t of BASE_TYPES) if (!KNOWN_TOPICS.has(t.event_type)) throw new Error(`Unknown daily event topic: ${t.event_type}`);
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -65,6 +73,7 @@ function rotation(day, types, yesterday) {
@@ -65,6 +73,7 @@ function rotation(day, types, yesterday) {
function wireType(t) {
function wireType(t) {
return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,
return { id: t.id, event_type: t.event_type, site: t.site, sentence: t.sentence, href: t.href,
...(t.hook ? { hook: t.hook, help: t.help } : {}),
...(t.hook ? { hook: t.hook, help: t.help } : {}),
+ ...(t.match_task ? { match_task: t.match_task, daily_eligible: t.daily_eligible } : {}),
weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',
weight: Number(t.weight), count: 1, verification: 'event subject, count within the person\'s local day',
status: t.status, source: t.source || t.project_id };
status: t.status, source: t.source || t.project_id };
}
}
@@ -124,7 +133,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -124,7 +133,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!DAY_RE.test(day) || !Number.isFinite(Date.parse(day + 'T00:00:00Z'))) return null;
if (!DAY_RE.test(day) || !Number.isFinite(Date.parse(day + 'T00:00:00Z'))) return null;
const existing = await read(day);
const existing = await read(day);
if (existing) return existing;
if (existing) return existing;
- const types = await allTypes();
+ const types = (await allTypes()).filter((step) => !step.match_task);
const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);
const priorDay = utcDay(Date.parse(day + 'T00:00:00Z') - 86400000);
const yesterday = await read(priorDay);
const yesterday = await read(priorDay);
let chosen = null, title = '', story = '', source = 'rotation';
let chosen = null, title = '', story = '', source = 'rotation';
@@ -156,12 +165,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -156,12 +165,16 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const cached = await readPersonal(subject, day);
const cached = await readPersonal(subject, day);
if (cached) return cached;
if (cached) return cached;
const preference = await aiPreference(subject);
const preference = await aiPreference(subject);
- const types = (await allTypes()).filter((step) => step.hook && step.help);
+ const types = (await allTypes()).filter((step) => step.hook && step.help && step.daily_eligible !== false);
+ const completedSetup = await db.many(`SELECT quest_id FROM quest_progress
+ WHERE subject=$1 AND quest_id LIKE 'live-setup-%' AND count > 0`, [subject]);
+ const completedIds = new Set(completedSetup.map((row) => row.quest_id.replace(/^live-setup-/, 'setup-')));
const since = new Date(s.now() - 30 * 86400000).toISOString();
const since = new Date(s.now() - 30 * 86400000).toISOString();
- const history = await db.many('SELECT DISTINCT event_type FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);
- const done = new Set(history.map((x) => x.event_type));
- const fresh = types.filter((x) => !done.has(x.event_type));
- const candidates = fresh.length >= 3 ? fresh : types;
+ const history = await db.many('SELECT DISTINCT event_type,event_detail FROM daily_events WHERE subject=$1 AND occurred_at >= $2', [subject, since]);
+ const done = new Set(history.map((x) => `${x.event_type}:${x.event_detail || ''}`));
+ const available = types.filter((step) => !completedIds.has(step.id));
+ const fresh = available.filter((step) => !done.has(`${step.event_type}:${step.match_task || ''}`));
+ const candidates = fresh.length >= 3 ? fresh : available;
let chosen = null, title = '', story = '', source = 'rotation', lines = null;
let chosen = null, title = '', story = '', source = 'rotation', lines = null;
if (preference.ai_text_enabled) try {
if (preference.ai_text_enabled) try {
const ai = await generateAi(day, candidates, null, preference.language);
const ai = await generateAi(day, candidates, null, preference.language);
@@ -174,7 +187,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -174,7 +187,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
} catch (err) { log.warn('[Daily quest] Personal AI unavailable:', err.message); }
} catch (err) { log.warn('[Daily quest] Personal AI unavailable:', err.message); }
if (!chosen) {
if (!chosen) {
try { chosen = rotation(day, candidates, null); }
try { chosen = rotation(day, candidates, null); }
- catch { chosen = rotation(day, types, null); }
+ catch { chosen = rotation(day, available, null); }
title = 'Make your way around OpenVibe';
title = 'Make your way around OpenVibe';
story = 'Three small things, across the network, to make today yours.';
story = 'Three small things, across the network, to make today yours.';
}
}
@@ -199,7 +212,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -199,7 +212,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
}
}
async function putChannelQuest(subject, input) {
async function putChannelQuest(subject, input) {
const title = typeof input?.title === 'string' ? input.title.trim() : '';
const title = typeof input?.title === 'string' ? input.title.trim() : '';
- const types = (await allTypes()).filter((step) => step.hook && step.help);
+ const types = (await allTypes()).filter((step) => step.hook && step.help && !step.match_task);
const steps = validateSelection(input?.steps, types, null);
const steps = validateSelection(input?.steps, types, null);
if (!title || title.length > 80 || !steps) return null;
if (!title || title.length > 80 || !steps) return null;
await db.exec(`INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)
await db.exec(`INSERT INTO channel_daily_quests(subject,title,steps_json,updated_at) VALUES($1,$2,$3,$4)
@@ -240,8 +253,9 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -240,8 +253,9 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;
if (!subject || !KNOWN_TOPICS.has(event.event_type)) return null;
const occurred = new Date(event.timestamp);
const occurred = new Date(event.timestamp);
if (!Number.isFinite(occurred.getTime()) || Math.abs(s.now() - occurred.getTime()) > 3 * 86400000) return null;
if (!Number.isFinite(occurred.getTime()) || Math.abs(s.now() - occurred.getTime()) > 3 * 86400000) return null;
- await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at) VALUES ($1,$2,$3,$4)
- ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
+ const detail = event.event_type === 'live.setup.task_completed' && typeof event.payload?.task === 'string' ? event.payload.task : null;
+ await tx.exec(`INSERT INTO daily_events (event_id,subject,event_type,occurred_at,event_detail) VALUES ($1,$2,$3,$4,$5)
+ ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString(), detail]);
return subject;
return subject;
}
}
// Read-only: unlike progress(), a card must never ensure quests or settle rewards.
// Read-only: unlike progress(), a card must never ensure quests or settle rewards.
@@ -263,12 +277,14 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
@@ -263,12 +277,14 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
const q = selectedQuest || await ensure(day);
const q = selectedQuest || await ensure(day);
if (!q) return null;
if (!q) return null;
const start = new Date(day + 'T00:00:00Z').getTime();
const start = new Date(day + 'T00:00:00Z').getTime();
- const events = await db.many(`SELECT event_type,occurred_at FROM daily_events
+ const events = await db.many(`SELECT event_type,occurred_at,event_detail FROM daily_events
WHERE subject=$1 AND occurred_at >= $2 AND occurred_at < $3`,
WHERE subject=$1 AND occurred_at >= $2 AND occurred_at < $3`,
[subject, new Date(start - 86400000).toISOString(), new Date(start + 2 * 86400000).toISOString()]);
[subject, new Date(start - 86400000).toISOString(), new Date(start + 2 * 86400000).toISOString()]);
const local = events.filter((e) => localDay(e.occurred_at, zone) === day);
const local = events.filter((e) => localDay(e.occurred_at, zone) === day);
- const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, local.filter((e) => e.event_type === step.event_type).length),
- done: local.some((e) => e.event_type === step.event_type) }));
+ const counts = (step) => local.filter((event) => event.event_type === step.event_type
+ && (!step.match_task || event.event_detail === step.match_task)).length;
+ const steps = q.steps.map((step) => ({ ...step, progress: Math.min(step.count, counts(step)),
+ done: counts(step) >= step.count }));
const done = steps.every((step) => step.done);
const done = steps.every((step) => step.done);
if (done && savedZone) {
if (done && savedZone) {
const at = s.iso();
const at = s.iso();
@@ -11,6 +11,17 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -11,6 +11,17 @@ const { ids, serviceAuth } = require('openvibe-contracts');
}) });
}) });
const user = t.network.addUser('daily');
const user = t.network.addUser('daily');
try {
try {
+ await check('catalogue carries the Live setup controls and at least thirty kinds', async () => {
+ const kinds = await t.ctx.daily.allTypes();
+ assert.ok(kinds.length >= 30);
+ const setup = kinds.filter((step) => step.id.startsWith('setup-'));
+ assert.strictEqual(setup.length, 17);
+ for (const step of setup) {
+ assert.strictEqual(step.hook, `live.setup.${step.match_task}`);
+ assert.strictEqual(new URL(step.href).hostname, 'openvibe.live');
+ assert.ok(step.help);
+ }
+ });
await check('today reader is private and permits network-site bearer requests', async () => {
await check('today reader is private and permits network-site bearer requests', async () => {
assert.strictEqual((await t.get('/api/v1/quests/today')).status, 401);
assert.strictEqual((await t.get('/api/v1/quests/today')).status, 401);
const preflight = await t.get('/api/v1/quests/today', { method: 'OPTIONS', headers: { origin: 'https://openvibe.pics', 'access-control-request-method': 'GET' } });
const preflight = await t.get('/api/v1/quests/today', { method: 'OPTIONS', headers: { origin: 'https://openvibe.pics', 'access-control-request-method': 'GET' } });
@@ -24,6 +35,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -24,6 +35,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
assert.strictEqual(validateSelection(['thread', 'reply', 'comment'], BASE_TYPES, null), null);
const today = await t.ctx.daily.ensure();
const today = await t.ctx.daily.ensure();
assert.deepStrictEqual(today.steps.map((x) => x.id), ['go-live', 'chat', 'wiki']);
assert.deepStrictEqual(today.steps.map((x) => x.id), ['go-live', 'chat', 'wiki']);
+ assert.ok(today.steps.every((step) => !step.match_task), 'the public quest does not assign streamer-only setup tasks to everyone');
assert.strictEqual(today.source, 'ai');
assert.strictEqual(today.source, 'ai');
const same = await t.ctx.daily.ensure();
const same = await t.ctx.daily.ensure();
assert.deepStrictEqual(same, today);
assert.deepStrictEqual(same, today);
@@ -161,6 +173,16 @@ const { ids, serviceAuth } = require('openvibe-contracts');
@@ -161,6 +173,16 @@ const { ids, serviceAuth } = require('openvibe-contracts');
assert.strictEqual(publicQuest.json().title, request.title);
assert.strictEqual(publicQuest.json().title, request.title);
} finally { await g.close(); }
} finally { await g.close(); }
});
});
+ await check('one setup task event cannot finish a different task', async () => {
+ const task = BASE_TYPES.find((step) => step.id === 'setup-slot');
+ const chosen = { date: '2026-11-08', title: 'Set up', story: '', source: 'rotation', steps: [task] };
+ const base = { event_type: 'live.setup.task_completed', timestamp: '2026-11-08T18:00:00Z',
+ payload: { user: { type: 'user', id: user.subject } } };
+ await t.ctx.daily.recordEvent(t.ctx.s.db, { ...base, event_id: 'evt_setup_method', payload: { ...base.payload, task: 'method' } });
+ assert.strictEqual((await t.ctx.daily.progress(user.subject, chosen.date, chosen)).steps[0].done, false);
+ await t.ctx.daily.recordEvent(t.ctx.s.db, { ...base, event_id: 'evt_setup_slot', payload: { ...base.payload, task: 'slot' } });
+ assert.strictEqual((await t.ctx.daily.progress(user.subject, chosen.date, chosen)).steps[0].done, true);
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });
})().catch((err) => { process.stderr.write(String(err.stack || err) + '\n'); process.exit(1); });
@@ -21,7 +21,8 @@ const BUDGETS = {
@@ -21,7 +21,8 @@ const BUDGETS = {
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
// the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
// Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
- htmlRawKB: 89.5, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
+ // Shared 4.205.0 on main measures 89.9 KB raw; 89.5 KB came from Shared 4.202.0.
+ htmlRawKB: 90, // 89.5 openvibe-shared v4.202.0 (2026-11-11: measured 89.5, raised by pin-bump from 87); 87: measured 86.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 83.2 with the daily quest as a card of three rows and its glyphs drawn inline (2026-11-10); before: 82.6 with the daily quest hero (2026-11-09); 81.3 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 80.6 with 2026-11-01: the hero orbit (15 sites, inline icons) (2026-11-01); 58.6 the home a taste and a door (2026-10-29): rails of kit cards and folds instead of every quest card and the sites list; 67.5 the hero's and the numbers' icons (inline ring icons, 2026-10-28); 61.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
htmlBrotliKB: 17.5, // 17.5 openvibe-shared v4.202.0 (2026-11-11: measured 17.5, raised by pin-bump from 17); 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
htmlBrotliKB: 17.5, // 17.5 openvibe-shared v4.202.0 (2026-11-11: measured 17.5, raised by pin-bump from 17); 17: measured 16.9 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 16.8: measured 16.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 394.5, // 394.5: measured 394.1 with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsRawKB: 394.5, // 394.5: measured 394.1 with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.7: measured 368.7 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09