openvibers/openvibe.coupons Commits
A code's reports over time: twelve weekly columns of worked and didn't work, a plain sentence, report_weeks in its JSON (#120)
Changes
@@@ -132,3 +132,19 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--.cpn-best code { font-weight: 700; }.cpn-lede { padding: 0; margin: 0 0 8px; }.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }++++/* A code's reports over time (render/pages.js reportTrend): weekly columns, worked below, didn't work above. */++.cpn-trend { margin: 22px 0 8px; }++.cpn-trend h2 { margin-bottom: 4px; }++.cpn-weeks { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 6px; align-items: end; height: 150px; margin: 12px 0 6px; padding: 0 2px; border-bottom: 1px solid var(--border, #1f2d47); }++.cpn-week { display: flex; flex-direction: column; align-items: stretch; gap: 4px; height: 100%; min-width: 0; }++.cpn-bar { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; gap: 2px; }++.cpn-bar span { display: block; border-radius: 4px 4px 2px 2px; min-height: 3px; }++.cpn-ok { background: var(--success, #22c55e); }++.cpn-no { background: color-mix(in srgb, var(--danger, #ef4444) 80%, transparent); }++.cpn-week small { font-size: 10.5px; color: var(--text-muted, #7386a3); text-align: center; white-space: nowrap; overflow: hidden; text-overflow: clip; }++.cpn-legend { display: flex; gap: 16px; font-size: 13px; color: var(--text-secondary, #a8b3c4); margin: 6px 0 0; }++.cpn-lk::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }++.cpn-lk-ok::before { background: var(--success, #22c55e); }++.cpn-lk-no::before { background: color-mix(in srgb, var(--danger, #ef4444) 80%, transparent); }++@media (max-width: 480px) { .cpn-weeks { gap: 3px; height: 120px; } .cpn-week small { font-size: 10px; overflow: visible; visibility: hidden; } .cpn-week:nth-child(3n+1) small { visibility: visible; } }@@@ -213,6 +213,9 @@ function createCoupons({ store, merchants, publication, outbox = null }) {SELECT DISTINCT ON (reporter_key) reporter_key, outcome FROM coupon_validation_reportsWHERE coupon_id = ? AND updated_at > ? ORDER BY reporter_key, updated_at DESC) latestGROUP BY outcome`),++ // A code's reports by day (each person once a day by the table's own rule): its page's weekly columns.++ reportDays: db.prepare(`SELECT day, outcome, COUNT(*) AS n FROM coupon_validation_reports++ WHERE coupon_id = ? AND day >= ? GROUP BY day, outcome ORDER BY day`),byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),};@@@ -530,6 +533,24 @@ function createCoupons({ store, merchants, publication, outbox = null }) {return out;}++ /**++ * A code's reports over time (the owner, 2026-10-28: "historical data or trends… Coupons success over time"): the++ * last `weeks` weeks (Monday to Sunday, UTC), oldest first, each with its "worked" and "didn't work" reports. Counts++ * only: never who reported. Weeks with no report are zeros, so the columns line up with the calendar.++ */++ async function reportWeeks(id, weeks = 12) {++ const DAYMS = 86400000;++ const today = Math.floor(store.now() / DAYMS) * DAYMS;++ const monday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAYMS;++ const first = monday - (weeks - 1) * 7 * DAYMS;++ const out = Array.from({ length: weeks }, (_, i) => ({ week: new Date(first + i * 7 * DAYMS).toISOString().slice(0, 10), worked: 0, failed: 0 }));++ for (const r of await q.reportDays.all(id, new Date(first).toISOString().slice(0, 10))) {++ const i = Math.floor((Date.parse(`${r.day}T00:00:00Z`) - first) / (7 * DAYMS));++ if (i >= 0 && i < weeks && (r.outcome === 'worked' || r.outcome === 'failed')) out[i][r.outcome] += Number(r.n) || 0;++ }++ return out;++ }++/*** The public view of a code. Never includes who submitted or reported it.*/@@@ -579,6 +600,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,restrictionsOf, sourceRefs, reportCounts, activeCount, activeCounts, syncIndex, syncMerchant,history: async (id) => await q.historyFor.all(id),++ reportWeeks,active: async (merchantId) => await q.active.all({ merchant: merchantId, now: store.now() }),recentlyEnded: async (merchantId) => await q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),recentActive: async (limit = 30) => await q.recentActive.all({ now: store.now(), limit }),@@@ -297,7 +297,7 @@ function createPublicRoutes(ctx) {res.vary('Cookie'); res.vary('Authorization');res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);-- res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });++ res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), report_weeks: await coupons.reportWeeks(found.c.id), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });});router.get('/c/:id', async (req, res) => {@@@ -310,7 +310,7 @@ function createPublicRoutes(ctx) {title: `${v.code} — ${v.title} at ${m.name}`,description: couponDescription(v, m),decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),-- body: pages.couponPage({ v, m, history: await coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),++ body: pages.couponPage({ v, m, history: await coupons.history(c.id), weeks: await coupons.reportWeeks(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),}, { cacheable: !flashMsg });});@@@ -256,11 +256,38 @@ ${flash(flashMsg)}${body}`;}--function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {++/**++ * Reports over time: twelve weekly columns, each split into "worked" (below) and "didn't work" (above), the++ * tallest week at full height, with a legend and a sentence that says it plainly. Nothing when nobody reported.++ */++function reportTrend(weeks) {++ const list = Array.isArray(weeks) ? weeks : [];++ const worked = list.reduce((n, w) => n + w.worked, 0), failed = list.reduce((n, w) => n + w.failed, 0);++ if (!worked && !failed) return '';++ const top = Math.max(...list.map((w) => w.worked + w.failed), 1);++ const recent = list.slice(-4), before = list.slice(-8, -4);++ const rate = (ws) => { const ok = ws.reduce((n, w) => n + w.worked, 0), all = ws.reduce((n, w) => n + w.worked + w.failed, 0); return all ? ok / all : null; };++ const now = rate(recent), then = rate(before);++ const trend = now == null ? 'No reports in the last four weeks.'++ : then == null ? `${Math.round(now * 100)}% of the last four weeks' reports say it worked.`++ : `${Math.round(now * 100)}% of the last four weeks' reports say it worked, ${now > then + 0.05 ? 'up' : now < then - 0.05 ? 'down' : 'about the same as'} ${now > then + 0.05 || now < then - 0.05 ? `from ${Math.round(then * 100)}% the four weeks before` : 'the four weeks before'}.`;++ const label = (w) => new Date(`${w.week}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });++ const pct = (n) => Math.round((n / top) * 1000) / 10;++ return h`<section class="cpn-trend" aria-labelledby="trend-h"><h2 id="trend-h">Reports over time</h2>++<p class="meta">${worked} “worked” and ${failed} “didn't work” in the last ${list.length} weeks. ${trend}</p>++<div class="cpn-weeks" role="img" aria-label="${`Reports per week for ${list.length} weeks: ${list.map((w) => `${label(w)} ${w.worked} worked, ${w.failed} didn't`).join('; ')}`}">++${list.map((w) => h`<div class="cpn-week" title="${`Week of ${label(w)}: ${w.worked} worked, ${w.failed} didn't work`}"><div class="cpn-bar">${w.failed ? h`<span class="cpn-no" style="height:${pct(w.failed)}%"></span>` : ''}${w.worked ? h`<span class="cpn-ok" style="height:${pct(w.worked)}%"></span>` : ''}</div><small>${label(w)}</small></div>`)}++</div>++<p class="cpn-legend"><span class="cpn-lk cpn-lk-ok">✓ Worked</span><span class="cpn-lk cpn-lk-no">✕ Didn't work</span></p>++</section>`;++}++++function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}${flash(flashMsg)}${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}${couponCard(v, { viewer, csrf, back, page: true })}++${reportTrend(weeks)}<h2>Status history</h2>${history.length ? h`<ol class="history">${history.map((x) => h`<li>${utc(new Date(x.created_at).toISOString())}: ${x.from_status ? h`${STATUS_LABEL[x.from_status] || x.from_status} → ` : ''}${STATUS_LABEL[x.to_status] || x.to_status}${x.confidence_after != null ? h`, confidence ${Math.round(x.confidence_after * 100)}%` : ''} <span class="meta">(${x.reason.startsWith('staff') ? 'staff' : x.reason.startsWith('service:') ? 'service' : x.reason})</span></li>`)}</ol>` : h`<p class="empty">No changes yet.</p>`}<p class="meta"><a href="/c/${v.id}.json">This code as JSON</a></p>`;@@@ -18,7 +18,7 @@ const BUDGETS = {jsRawKB: 312, // 307.6 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js)jsBrotliKB: 78, // 76.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.5 Shared 4.20.1 (kit-web.js); 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)-- cssRawKB: 57, // 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css)++ cssRawKB: 59.5, // 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css)cssBrotliKB: 12, // 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css)externalFiles: 1, // 0};nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * A code's reports over time (the owner, 2026-10-28: "historical data or trends… Coupons success over time"): its page++ * shows twelve weekly columns of "worked" and "didn't work", with a sentence that says it plainly, and its JSON carries++ * the same weeks. Counts only, never who reported; nothing at all when nobody reported.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const DAY = 86400000;++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });++ const code = (await t.submit(alice, { host: 'bluekettle.shop', code: 'KETTLE10', title: 'Code KETTLE10' })).json().coupon;++ const quiet = (await t.submit(alice, { host: 'bluekettle.shop', code: 'QUIET5', title: 'Code QUIET5' })).json().coupon;++++ // Reports on days in the past weeks, each from its own reporter (the table allows one per person, code and day).++ const now = t.clock.now();++ const day = (ago) => new Date(now - ago * DAY).toISOString().slice(0, 10);++ let n = 0;++ const add = async (ago, outcome) => {++ n += 1;++ await t.ctx.store.db.prepare(`INSERT INTO coupon_validation_reports (coupon_id, reporter_key, channel, day, outcome, reason, created_at, updated_at)++ VALUES (?, ?, 'site', ?, ?, ?, ?, ?)`).run(code.id, `rk${n}`, day(ago), outcome, outcome === 'failed' ? 'expired' : null, now - ago * DAY, now - ago * DAY);++ };++ for (const ago of [1, 2, 3, 9, 10]) await add(ago, 'worked');++ for (const ago of [2, 40, 41, 42]) await add(ago, 'failed');++ await add(200, 'worked'); // older than twelve weeks: not in the columns++++ await check("the code's page shows its reports over time, plainly", async () => {++ const r = await t.get(`/c/${code.id}`);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<h2 id="trend-h">Reports over time<\/h2>/);++ assert.match(r.text, /5 “worked” and 4 “didn't work” in the last 12 weeks\./);++ assert.strictEqual((r.text.match(/class="cpn-week"/g) || []).length, 12, 'twelve weeks, empty ones too');++ assert.match(r.text, /role="img" aria-label="Reports per week for 12 weeks: /);++ assert.match(r.text, /✓ Worked<\/span><span class="cpn-lk cpn-lk-no">✕ Didn't work/);++ assert.ok(!/rk\d/.test(r.text), 'never who reported');++ });++++ await check('the JSON carries the same weeks, oldest first', async () => {++ const j = (await t.get(`/c/${code.id}.json`)).json();++ assert.strictEqual(j.report_weeks.length, 12);++ assert.ok(j.report_weeks.every((w) => /^\d{4}-\d\d-\d\d$/.test(w.week) && new Date(`${w.week}T00:00:00Z`).getUTCDay() === 1), 'weeks start on Monday');++ assert.strictEqual(j.report_weeks.reduce((s, w) => s + w.worked, 0), 5);++ assert.strictEqual(j.report_weeks.reduce((s, w) => s + w.failed, 0), 4);++ assert.ok(j.report_weeks[0].week < j.report_weeks[11].week, 'oldest first');++ });++++ await check('a code nobody reported on shows no chart', async () => {++ const r = await t.get(`/c/${quiet.id}`);++ assert.strictEqual(r.status, 200);++ assert.ok(!/Reports over time/.test(r.text));++ });++++ await t.close();++ done();++})();
@@ -132,3 +132,19 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
@@ -132,3 +132,19 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
.cpn-best code { font-weight: 700; }
.cpn-best code { font-weight: 700; }
.cpn-lede { padding: 0; margin: 0 0 8px; }
.cpn-lede { padding: 0; margin: 0 0 8px; }
.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }
.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }
+
+/* A code's reports over time (render/pages.js reportTrend): weekly columns, worked below, didn't work above. */
+.cpn-trend { margin: 22px 0 8px; }
+.cpn-trend h2 { margin-bottom: 4px; }
+.cpn-weeks { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 6px; align-items: end; height: 150px; margin: 12px 0 6px; padding: 0 2px; border-bottom: 1px solid var(--border, #1f2d47); }
+.cpn-week { display: flex; flex-direction: column; align-items: stretch; gap: 4px; height: 100%; min-width: 0; }
+.cpn-bar { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; gap: 2px; }
+.cpn-bar span { display: block; border-radius: 4px 4px 2px 2px; min-height: 3px; }
+.cpn-ok { background: var(--success, #22c55e); }
+.cpn-no { background: color-mix(in srgb, var(--danger, #ef4444) 80%, transparent); }
+.cpn-week small { font-size: 10.5px; color: var(--text-muted, #7386a3); text-align: center; white-space: nowrap; overflow: hidden; text-overflow: clip; }
+.cpn-legend { display: flex; gap: 16px; font-size: 13px; color: var(--text-secondary, #a8b3c4); margin: 6px 0 0; }
+.cpn-lk::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
+.cpn-lk-ok::before { background: var(--success, #22c55e); }
+.cpn-lk-no::before { background: color-mix(in srgb, var(--danger, #ef4444) 80%, transparent); }
+@media (max-width: 480px) { .cpn-weeks { gap: 3px; height: 120px; } .cpn-week small { font-size: 10px; overflow: visible; visibility: hidden; } .cpn-week:nth-child(3n+1) small { visibility: visible; } }
@@ -213,6 +213,9 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -213,6 +213,9 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
SELECT DISTINCT ON (reporter_key) reporter_key, outcome FROM coupon_validation_reports
SELECT DISTINCT ON (reporter_key) reporter_key, outcome FROM coupon_validation_reports
WHERE coupon_id = ? AND updated_at > ? ORDER BY reporter_key, updated_at DESC) latest
WHERE coupon_id = ? AND updated_at > ? ORDER BY reporter_key, updated_at DESC) latest
GROUP BY outcome`),
GROUP BY outcome`),
+ // A code's reports by day (each person once a day by the table's own rule): its page's weekly columns.
+ reportDays: db.prepare(`SELECT day, outcome, COUNT(*) AS n FROM coupon_validation_reports
+ WHERE coupon_id = ? AND day >= ? GROUP BY day, outcome ORDER BY day`),
byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),
byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),
ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),
ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),
};
};
@@ -530,6 +533,24 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -530,6 +533,24 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
return out;
return out;
}
}
+ /**
+ * A code's reports over time (the owner, 2026-10-28: "historical data or trends… Coupons success over time"): the
+ * last `weeks` weeks (Monday to Sunday, UTC), oldest first, each with its "worked" and "didn't work" reports. Counts
+ * only: never who reported. Weeks with no report are zeros, so the columns line up with the calendar.
+ */
+ async function reportWeeks(id, weeks = 12) {
+ const DAYMS = 86400000;
+ const today = Math.floor(store.now() / DAYMS) * DAYMS;
+ const monday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAYMS;
+ const first = monday - (weeks - 1) * 7 * DAYMS;
+ const out = Array.from({ length: weeks }, (_, i) => ({ week: new Date(first + i * 7 * DAYMS).toISOString().slice(0, 10), worked: 0, failed: 0 }));
+ for (const r of await q.reportDays.all(id, new Date(first).toISOString().slice(0, 10))) {
+ const i = Math.floor((Date.parse(`${r.day}T00:00:00Z`) - first) / (7 * DAYMS));
+ if (i >= 0 && i < weeks && (r.outcome === 'worked' || r.outcome === 'failed')) out[i][r.outcome] += Number(r.n) || 0;
+ }
+ return out;
+ }
+
/**
/**
* The public view of a code. Never includes who submitted or reported it.
* The public view of a code. Never includes who submitted or reported it.
*/
*/
@@ -579,6 +600,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -579,6 +600,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,
get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,
restrictionsOf, sourceRefs, reportCounts, activeCount, activeCounts, syncIndex, syncMerchant,
restrictionsOf, sourceRefs, reportCounts, activeCount, activeCounts, syncIndex, syncMerchant,
history: async (id) => await q.historyFor.all(id),
history: async (id) => await q.historyFor.all(id),
+ reportWeeks,
active: async (merchantId) => await q.active.all({ merchant: merchantId, now: store.now() }),
active: async (merchantId) => await q.active.all({ merchant: merchantId, now: store.now() }),
recentlyEnded: async (merchantId) => await q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),
recentlyEnded: async (merchantId) => await q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),
recentActive: async (limit = 30) => await q.recentActive.all({ now: store.now(), limit }),
recentActive: async (limit = 30) => await q.recentActive.all({ now: store.now(), limit }),
@@ -297,7 +297,7 @@ function createPublicRoutes(ctx) {
@@ -297,7 +297,7 @@ function createPublicRoutes(ctx) {
res.vary('Cookie'); res.vary('Authorization');
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
- res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });
+ res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), report_weeks: await coupons.reportWeeks(found.c.id), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });
});
});
router.get('/c/:id', async (req, res) => {
router.get('/c/:id', async (req, res) => {
@@ -310,7 +310,7 @@ function createPublicRoutes(ctx) {
@@ -310,7 +310,7 @@ function createPublicRoutes(ctx) {
title: `${v.code} — ${v.title} at ${m.name}`,
title: `${v.code} — ${v.title} at ${m.name}`,
description: couponDescription(v, m),
description: couponDescription(v, m),
decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
- body: pages.couponPage({ v, m, history: await coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),
+ body: pages.couponPage({ v, m, history: await coupons.history(c.id), weeks: await coupons.reportWeeks(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
});
});
@@ -256,11 +256,38 @@ ${flash(flashMsg)}
@@ -256,11 +256,38 @@ ${flash(flashMsg)}
${body}`;
${body}`;
}
}
-function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {
+/**
+ * Reports over time: twelve weekly columns, each split into "worked" (below) and "didn't work" (above), the
+ * tallest week at full height, with a legend and a sentence that says it plainly. Nothing when nobody reported.
+ */
+function reportTrend(weeks) {
+ const list = Array.isArray(weeks) ? weeks : [];
+ const worked = list.reduce((n, w) => n + w.worked, 0), failed = list.reduce((n, w) => n + w.failed, 0);
+ if (!worked && !failed) return '';
+ const top = Math.max(...list.map((w) => w.worked + w.failed), 1);
+ const recent = list.slice(-4), before = list.slice(-8, -4);
+ const rate = (ws) => { const ok = ws.reduce((n, w) => n + w.worked, 0), all = ws.reduce((n, w) => n + w.worked + w.failed, 0); return all ? ok / all : null; };
+ const now = rate(recent), then = rate(before);
+ const trend = now == null ? 'No reports in the last four weeks.'
+ : then == null ? `${Math.round(now * 100)}% of the last four weeks' reports say it worked.`
+ : `${Math.round(now * 100)}% of the last four weeks' reports say it worked, ${now > then + 0.05 ? 'up' : now < then - 0.05 ? 'down' : 'about the same as'} ${now > then + 0.05 || now < then - 0.05 ? `from ${Math.round(then * 100)}% the four weeks before` : 'the four weeks before'}.`;
+ const label = (w) => new Date(`${w.week}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
+ const pct = (n) => Math.round((n / top) * 1000) / 10;
+ return h`<section class="cpn-trend" aria-labelledby="trend-h"><h2 id="trend-h">Reports over time</h2>
+<p class="meta">${worked} “worked” and ${failed} “didn't work” in the last ${list.length} weeks. ${trend}</p>
+<div class="cpn-weeks" role="img" aria-label="${`Reports per week for ${list.length} weeks: ${list.map((w) => `${label(w)} ${w.worked} worked, ${w.failed} didn't`).join('; ')}`}">
+${list.map((w) => h`<div class="cpn-week" title="${`Week of ${label(w)}: ${w.worked} worked, ${w.failed} didn't work`}"><div class="cpn-bar">${w.failed ? h`<span class="cpn-no" style="height:${pct(w.failed)}%"></span>` : ''}${w.worked ? h`<span class="cpn-ok" style="height:${pct(w.worked)}%"></span>` : ''}</div><small>${label(w)}</small></div>`)}
+</div>
+<p class="cpn-legend"><span class="cpn-lk cpn-lk-ok">✓ Worked</span><span class="cpn-lk cpn-lk-no">✕ Didn't work</span></p>
+</section>`;
+}
+
+function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {
return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}
return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}
${flash(flashMsg)}
${flash(flashMsg)}
${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}
${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}
${couponCard(v, { viewer, csrf, back, page: true })}
${couponCard(v, { viewer, csrf, back, page: true })}
+${reportTrend(weeks)}
<h2>Status history</h2>
<h2>Status history</h2>
${history.length ? h`<ol class="history">${history.map((x) => h`<li>${utc(new Date(x.created_at).toISOString())}: ${x.from_status ? h`${STATUS_LABEL[x.from_status] || x.from_status} → ` : ''}${STATUS_LABEL[x.to_status] || x.to_status}${x.confidence_after != null ? h`, confidence ${Math.round(x.confidence_after * 100)}%` : ''} <span class="meta">(${x.reason.startsWith('staff') ? 'staff' : x.reason.startsWith('service:') ? 'service' : x.reason})</span></li>`)}</ol>` : h`<p class="empty">No changes yet.</p>`}
${history.length ? h`<ol class="history">${history.map((x) => h`<li>${utc(new Date(x.created_at).toISOString())}: ${x.from_status ? h`${STATUS_LABEL[x.from_status] || x.from_status} → ` : ''}${STATUS_LABEL[x.to_status] || x.to_status}${x.confidence_after != null ? h`, confidence ${Math.round(x.confidence_after * 100)}%` : ''} <span class="meta">(${x.reason.startsWith('staff') ? 'staff' : x.reason.startsWith('service:') ? 'service' : x.reason})</span></li>`)}</ol>` : h`<p class="empty">No changes yet.</p>`}
<p class="meta"><a href="/c/${v.id}.json">This code as JSON</a></p>`;
<p class="meta"><a href="/c/${v.id}.json">This code as JSON</a></p>`;
@@ -18,7 +18,7 @@ const BUDGETS = {
@@ -18,7 +18,7 @@ const BUDGETS = {
jsRawKB: 312, // 307.6 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js)
jsRawKB: 312, // 307.6 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js)
jsBrotliKB: 78, // 76.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.5 Shared 4.20.1 (kit-web.js); 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 78, // 76.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.5 Shared 4.20.1 (kit-web.js); 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)
cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)
- cssRawKB: 57, // 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css)
+ cssRawKB: 59.5, // 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css)
cssBrotliKB: 12, // 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css)
cssBrotliKB: 12, // 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * A code's reports over time (the owner, 2026-10-28: "historical data or trends… Coupons success over time"): its page
+ * shows twelve weekly columns of "worked" and "didn't work", with a sentence that says it plainly, and its JSON carries
+ * the same weeks. Counts only, never who reported; nothing at all when nobody reported.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const DAY = 86400000;
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });
+ const code = (await t.submit(alice, { host: 'bluekettle.shop', code: 'KETTLE10', title: 'Code KETTLE10' })).json().coupon;
+ const quiet = (await t.submit(alice, { host: 'bluekettle.shop', code: 'QUIET5', title: 'Code QUIET5' })).json().coupon;
+
+ // Reports on days in the past weeks, each from its own reporter (the table allows one per person, code and day).
+ const now = t.clock.now();
+ const day = (ago) => new Date(now - ago * DAY).toISOString().slice(0, 10);
+ let n = 0;
+ const add = async (ago, outcome) => {
+ n += 1;
+ await t.ctx.store.db.prepare(`INSERT INTO coupon_validation_reports (coupon_id, reporter_key, channel, day, outcome, reason, created_at, updated_at)
+ VALUES (?, ?, 'site', ?, ?, ?, ?, ?)`).run(code.id, `rk${n}`, day(ago), outcome, outcome === 'failed' ? 'expired' : null, now - ago * DAY, now - ago * DAY);
+ };
+ for (const ago of [1, 2, 3, 9, 10]) await add(ago, 'worked');
+ for (const ago of [2, 40, 41, 42]) await add(ago, 'failed');
+ await add(200, 'worked'); // older than twelve weeks: not in the columns
+
+ await check("the code's page shows its reports over time, plainly", async () => {
+ const r = await t.get(`/c/${code.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<h2 id="trend-h">Reports over time<\/h2>/);
+ assert.match(r.text, /5 “worked” and 4 “didn't work” in the last 12 weeks\./);
+ assert.strictEqual((r.text.match(/class="cpn-week"/g) || []).length, 12, 'twelve weeks, empty ones too');
+ assert.match(r.text, /role="img" aria-label="Reports per week for 12 weeks: /);
+ assert.match(r.text, /✓ Worked<\/span><span class="cpn-lk cpn-lk-no">✕ Didn't work/);
+ assert.ok(!/rk\d/.test(r.text), 'never who reported');
+ });
+
+ await check('the JSON carries the same weeks, oldest first', async () => {
+ const j = (await t.get(`/c/${code.id}.json`)).json();
+ assert.strictEqual(j.report_weeks.length, 12);
+ assert.ok(j.report_weeks.every((w) => /^\d{4}-\d\d-\d\d$/.test(w.week) && new Date(`${w.week}T00:00:00Z`).getUTCDay() === 1), 'weeks start on Monday');
+ assert.strictEqual(j.report_weeks.reduce((s, w) => s + w.worked, 0), 5);
+ assert.strictEqual(j.report_weeks.reduce((s, w) => s + w.failed, 0), 4);
+ assert.ok(j.report_weeks[0].week < j.report_weeks[11].week, 'oldest first');
+ });
+
+ await check('a code nobody reported on shows no chart', async () => {
+ const r = await t.get(`/c/${quiet.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.ok(!/Reports over time/.test(r.text));
+ });
+
+ await t.close();
+ done();
+})();