openvibers/openvibe.coupons Commits
Coupons: success over time for codes and shops, with private weekly buckets and API (#151)
Changes
@@@ -210,6 +210,7 @@ use a visitor's session against the API. Errors are RFC 9457 problems.| `GET /merchants/resolve?host=` | Anyone. Anonymous callers are rate-limited per IP, installs per install. | `coupons.merchant.resolve` / `coupons.lookup` || `GET /merchants/:id/coupons` | Anyone (as above). | `coupons.coupon.lookup` / `coupons.lookup` || `GET /merchants/:id`, `GET /coupons/:id` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |++| `GET /coupons/:id/success`, `GET /stores/:slug/success` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |A source's terms can keep its codes out of browser extensions (`server/domain/source-policy.js`): DealNews' feed termssay its content may not be used in a browser extension made available for public use, so a code from@@@ -222,6 +223,11 @@ or another `*-extension://` scheme, or an install's `cpx_` token): left out of `| `POST /coupons/:id/status` `{status: disabled\|expired\|active, note?}` | Staff (Network admin, or `COUPONS_STAFF_SUBJECTS`) or a service. | `coupons.status.update` || `POST /merchants`, `POST /merchants/:id/domains`, `POST /merchants/:id/status` | Staff or a service. | `coupons.merchant.manage` |++- **Success over time.** `GET /coupons/:id/success` and `GET /stores/:slug/success` return the last twelve weeks,++ Monday to Sunday UTC, oldest first: `{ from, to, min_people, weeks: [null | { week, worked, failed }] }`. A week++ counts the people whose most recent report that week said it worked or didn't (a person once a week, even across a++ shop's codes). `null` means fewer than 3 people — too few to show — so one person's report cannot be read off it.++ The answer never contains a reporter key, install id, channel or day-level row.- **CORS:**- Only the two lookup routes send CORS headers, and only to origins in`COUPONS_EXTENSION_ORIGINS`. Those are exact `chrome-extension://<id>` origins, and optionally@@@ -151,21 +151,12 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--.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. */++/* Success over time (render/pages.js reportTrend, merchantTrend): the shared kit draws the chart; these rules place++ it and the one-line people count. Theme tokens only. */.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; } }++.cpn-people { margin: 12px 0 0; font-weight: 600; }++.cpn-chart .ovk-chart { margin: 10px 0; }/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */@media (pointer: coarse) {@@@ -29,6 +29,7 @@ function publicReason(reason) {const { ids } = require('openvibe-contracts');const { ApiError } = require('../http/errors');const confidence = require('./confidence');++const weekly = require('./weekly');const { hosts } = require('openvibe-publishing/ingest');const { attributionOf, hidesFromExtension } = require('./source-policy');@@@ -213,9 +214,13 @@ 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`),++ // A code's reports by person and day, for the page's weekly buckets (weekly.js counts people, not rows).++ reportPeopleDays: db.prepare(`SELECT reporter_key, day, outcome, updated_at FROM coupon_validation_reports++ WHERE coupon_id = ? AND day >= ? ORDER BY reporter_key, day, updated_at`),++ // The same for a shop: every code it owns. A person who reports two of its codes counts once a week.++ merchantPeopleDays: db.prepare(`SELECT r.reporter_key, r.day, r.outcome, r.updated_at FROM coupon_validation_reports r++ JOIN coupons c ON c.id = r.coupon_id WHERE c.merchant_id = ? AND r.day >= ?++ ORDER BY r.reporter_key, r.day, r.updated_at`),byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),};@@@ -534,21 +539,19 @@ function createCoupons({ store, merchants, publication, outbox = null }) {}/**-- * 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.++ * A code's success over time: the last `weeks` weeks (Monday to Sunday, UTC), oldest first, each a bucket of++ * people whose most recent report that week said it worked or didn't. `null` means fewer than 3 people — too few++ * to show — so one person's report cannot be read off it (weekly.js). Never who reported.*/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;++ const from = weekly.weekStarts(store.now(), weeks)[0];++ return weekly.buckets(await q.reportPeopleDays.all(id, from), store.now(), weeks);++ }++++ /** A shop's success over time across all its codes: the same weekly buckets, a person once a week. */++ async function merchantWeeks(merchantId, weeks = 12) {++ const from = weekly.weekStarts(store.now(), weeks)[0];++ return weekly.buckets(await q.merchantPeopleDays.all(merchantId, from), store.now(), weeks);}/**@@@ -600,7 +603,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,++ reportWeeks, merchantWeeks,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 }),nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++/**++ * Weekly success buckets for a code or a shop, with the privacy floor in one place.++ *++ * A bucket counts PEOPLE, not rows: each reporter's most recent report in the week says whether it worked. A week++ * with fewer than MIN_PEOPLE distinct people is `null` — "too few reports" — so one person's report (or one++ * person's seven daily rows) can never be read off the chart. Nothing here returns a reporter key, a channel, an++ * install id or a day-level row; only a week's aggregate worked/failed counts.++ */++const DAY = 24 * 60 * 60 * 1000;++const MIN_PEOPLE = 3;++++/** The Monday (UTC) that starts each of the last `weeks` weeks, oldest first. */++function weekStarts(now, weeks = 12) {++ const today = Math.floor(now / DAY) * DAY;++ const monday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAY;++ const first = monday - (weeks - 1) * 7 * DAY;++ return Array.from({ length: weeks }, (_, i) => new Date(first + i * 7 * DAY).toISOString().slice(0, 10));++}++++/**++ * rows: [{ reporter_key, day: 'YYYY-MM-DD', outcome: 'worked'|'failed', updated_at }] for one code or shop,++ * oldest first is not required (the latest day in the week wins). → { from, to, min_people, weeks: [null | { week,++ * worked, failed }] }.++ */++function buckets(rows, now, weeks = 12) {++ const starts = weekStarts(now, weeks);++ const first = Date.parse(`${starts[0]}T00:00:00Z`);++ const latest = new Map();++ for (const r of rows || []) {++ if (r.outcome !== 'worked' && r.outcome !== 'failed') continue;++ const i = Math.floor((Date.parse(`${r.day}T00:00:00Z`) - first) / (7 * DAY));++ if (!(i >= 0 && i < weeks)) continue;++ const key = `${r.reporter_key}|${i}`;++ const seen = latest.get(key);++ // The latest day wins; on the same day the later update wins (a shop's codes can tie on a day).++ if (!seen || String(r.day) > seen.day || (String(r.day) === seen.day && Number(r.updated_at) >= Number(seen.updated_at))) {++ latest.set(key, { day: String(r.day), outcome: r.outcome, at: Number(r.updated_at) || 0 });++ }++ }++ const count = Array.from({ length: weeks }, () => ({ worked: 0, failed: 0 }));++ for (const [key, r] of latest) count[Number(key.split('|').pop())][r.outcome] += 1;++ return {++ from: starts[0], to: starts[weeks - 1], min_people: MIN_PEOPLE,++ weeks: starts.map((week, i) => {++ const total = count[i].worked + count[i].failed;++ return total >= MIN_PEOPLE ? { week, worked: count[i].worked, failed: count[i].failed } : null;++ }),++ };++}++++module.exports = { DAY, MIN_PEOPLE, weekStarts, buckets };@@@ -11,6 +11,8 @@* Also public (no CORS):* GET /merchants/:id coupons.coupon.lookup | scope coupons.lookup* GET /coupons/:id coupons.coupon.lookup | scope coupons.lookup++ * GET /coupons/:id/success coupons.coupon.lookup | scope coupons.lookup (weekly, aggregate)++ * GET /stores/:slug/success coupons.coupon.lookup | scope coupons.lookup (weekly, aggregate)* People (Bearer Network token), installs and services acting for a person:* POST /coupons/:id/report coupons.report.create | scope coupons.report* POST /coupons/submit coupons.coupon.submit (installs cannot submit)@@@ -181,6 +183,25 @@ function createApi(ctx) {return { coupon: v, merchant: await merchantView(m, req) };}));++ // ── Success over time (aggregate weekly buckets; never a reporter, install, channel or day-level row) ──++ router.get('/coupons/:id/success', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {++ const c = await coupons.get(req.params.id);++ const m = c && await merchants.byId(c.merchant_id);++ if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');++ const v = await coupons.view(c, { merchant: m });++ if (v.extension_excluded && fromExtension(req)) throw new ApiError(404, 'coupon.not_found', 'no such code');++ byOrigin(res);++ publicCache(res);++ return await coupons.reportWeeks(c.id);++ }));++++ router.get('/stores/:slug/success', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {++ const m = await activeMerchant(req.params.slug);++ byOrigin(res);++ publicCache(res);++ return await coupons.merchantWeeks(m.id);++ }));++// ── Reports ─────────────────────────────────────────────router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run(async (req, res) => {noStore(res);@@@ -266,13 +266,15 @@ function createPublicRoutes(ctx) {const ended = tab === 'codes' ? await Promise.all((await coupons.recentlyEnded(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) : [];const decision = publication.merchantDecision(m, active.length);const flashMsg = FLASH[req.query.done] || null;++ const success = tab === 'codes' ? await coupons.merchantWeeks(m.id) : null;send(req, res, 200, {title: `${m.name} coupon codes`,description: active.length ? `${active.length} active code${active.length === 1 ? '' : 's'} for ${m.name}, with restrictions, expiry and people's reports.` : `No active codes for ${m.name} right now.`,decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],++ chart: true,body: pages.merchant({-- m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,++ m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended, success,viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab,watching: await watches.isWatching(req.viewer.subject, m),}),@@@ -304,7 +306,8 @@ 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 }), 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 } });++ const weeks = await coupons.reportWeeks(found.c.id);++ res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), report_weeks: weeks.weeks, report_weeks_from: weeks.from, 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) => {@@@ -317,7 +320,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), weeks: await coupons.reportWeeks(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 }), chart: true,}, { cacheable: !flashMsg });});@@@ -103,8 +103,8 @@ function renderPage(o) {// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta// and a WebPage JSON-LD tag from these real fields; with none it emits neither.summary: o.summary,-- styles: [...(o.styles || []), 'kit.css'], // openvibe-shared stylesheet names (the home's showcase.css; the OpenVibe Kit)-- scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`], // the kit's behaviour: copy buttons, menus, confirms (the page works without it)++ styles: [...(o.styles || []), 'kit.css', ...(o.chart ? ['kit-data.css'] : [])], // shared style names; kit-data.css is the chart's look++ scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`, ...(o.chart ? [`<script src="${ovServe.url('kit-data-web.js')}" defer></script>`] : [])], // the kit's behaviour; the page works without itfacts: o.facts,updated: o.updated,url: o.url,@@@ -249,7 +249,7 @@ function shopTab(query, active) {* A shop (GET /m/:slug): the head (the shop, its codes, Watch, Submit and more as icons), the best code right now, and* one tab at a time. ended is only read for the Codes tab (the route passes [] otherwise).*/--function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg, tab = 'codes' }) {++function merchant({ m, domains, hints, active, ended, success = null, viewer, csrf, watching, back, flashMsg, tab = 'codes' }) {const codes = active.filter((v) => !isProductDeal(v));const deals = active.filter(isProductDeal);const shopUrl = m.homepage_url || null;@@@ -284,6 +284,7 @@ ${hints.length ? h`<ul class="hints ovk-note">${hints.map((x) => h`<li>How to ap${deals.length ? deals.map(card) : h`<p class="empty">No product deals for ${m.name} right now.</p>`}`;} else {body = h`${best ? h`<a class="cpn-best" href="#${best.id}"><span>Best code now</span><strong>${headline(best)}</strong><code>${best.code}</code><span class="meta">${`Confidence ${Math.round(best.confidence * 100)}%`}</span></a>` : ''}++${merchantTrend(success, m.name)}${codes.length ? codes.map(card) : active.length ? h`<p class="empty">No code for the whole shop right now; ${String(deals.length)} product deal${deals.length === 1 ? '' : 's'} <a href="${base}?tab=deals">in Product deals</a>.</p>` : h`<p class="empty">No active codes for ${m.name} right now.</p>`}<p>${k(kit.button({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, label: `Submit a code for ${m.name}`, icon: 'plus', kind: 'quiet', small: true }))}</p>${ended.length ? k(kit.fold({ title: 'Recently ended (not in active results)', meta: String(ended.length), body: String(h`<ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>`) })) : ''}`;@@@ -293,30 +294,62 @@ ${flash(flashMsg)}${body}`;}++/** A week's Monday, for the buckets the API leaves null when fewer than 3 people reported. */++const DAY = 86400000;++const weekAt = (trend, i) => new Date(Date.parse(`${trend.from}T00:00:00Z`) + i * 7 * DAY).toISOString().slice(0, 10);++const weekDates = (trend) => trend.weeks.map((w, i) => (w ? w.week : weekAt(trend, i)));++++/** The people a code's last 30 days of reports came from: "Worked for 4 of 7 people". */++function peopleLine(v) {++ const worked = Number(v.reports.worked) || 0, failed = Number(v.reports.failed) || 0, total = worked + failed;++ return total ? `Worked for ${worked} of ${total} people in the last ${v.reports.window_days} days.` : `No one reported in the last ${v.reports.window_days} days.`;++}++/**-- * 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.++ * A code's success over time: twelve weekly buckets of people whose most recent report that week said it worked or++ * didn't, drawn by the shared kit. A null bucket is fewer than 3 people and stays a gap; nothing names a person.*/--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 reportTrend(trend) {++ if (!trend || !Array.isArray(trend.weeks) || !trend.weeks.length) return '';++ const dates = weekDates(trend);++ const shown = trend.weeks.filter(Boolean);++ const worked = shown.reduce((n, w) => n + w.worked, 0), failed = shown.reduce((n, w) => n + w.failed, 0);++ if (!shown.length) return h`<section class="cpn-trend" aria-labelledby="trend-h"><h2 id="trend-h">Success over time</h2>++<p class="meta">Too few reports in the last ${trend.weeks.length} weeks to show a chart: a week needs at least ${trend.min_people} people.</p></section>`;++ const points = (field) => trend.weeks.map((w, i) => [dates[i], w ? w[field] : null]);++ const chart = kit.chart({++ type: 'line', title: `Worked and didn't work, last ${trend.weeks.length} weeks`, height: 180,++ label: 'Worked and did not work by week',++ note: `Each week counts people, not reports; a week with fewer than ${trend.min_people} people is left blank.`,++ x: { time: true, label: 'Week' }, y: { fmt: 'int', label: 'People' },++ series: [{ name: 'Worked', points: points('worked') }, { name: "Didn't work", points: points('failed') }],++ });++ return h`<section class="cpn-trend" aria-labelledby="trend-h"><h2 id="trend-h">Success over time</h2>++<p class="meta">${worked} people said it worked and ${failed} said it didn't, in the weeks with enough reports of the last ${trend.weeks.length}.</p>++<div class="cpn-chart">${k(chart)}</div>++<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p></section>`;++}++++/** A shop's success rate per week across all its codes (weekly buckets, aggregate only). */++function merchantTrend(trend, name) {++ if (!trend || !Array.isArray(trend.weeks) || !trend.weeks.length) return '';++ const dates = weekDates(trend);++ const shown = trend.weeks.filter(Boolean);++ const worked = shown.reduce((n, w) => n + w.worked, 0), failed = shown.reduce((n, w) => n + w.failed, 0);++ const section = (body) => h`<section class="cpn-trend" aria-labelledby="shop-trend-h"><h2 id="shop-trend-h">Success over time</h2>${body}</section>`;++ if (!shown.length) return section(h`<p class="meta">Too few reports at ${name} in the last ${trend.weeks.length} weeks to show a success rate: a week needs at least ${trend.min_people} people.</p>`);++ const rate = Math.round((worked / (worked + failed)) * 100);++ const points = trend.weeks.map((w, i) => [dates[i], w ? Math.round((w.worked / (w.worked + w.failed)) * 100) : null]);++ const chart = kit.chart({++ type: 'line', title: `${name}: success rate by week, last ${trend.weeks.length} weeks`, height: 180,++ label: `${name} success rate by week`,++ note: `The share of people whose most recent report that week said it worked; a week with fewer than ${trend.min_people} people is left blank.`,++ x: { time: true, label: 'Week' }, y: { fmt: 'percent', label: 'Worked' },++ series: [{ name: '% worked', points }],++ });++ return section(h`<p class="meta">${rate}% of people said a code worked in the weeks with enough reports (${worked} of ${worked + failed}).</p>++<div class="cpn-chart">${k(chart)}</div>++<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p>`);}function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {@@@ -324,6 +357,7 @@ function couponPage({ v, m, history, weeks, viewer, csrf, back, 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>`}${couponCard(v, { viewer, csrf, back, page: true })}++<p class="cpn-people">${peopleLine(v)}</p>${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>`}@@@ -1,18 +1,19 @@'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.++ * Success over time (weekly.js, render/pages.js reportTrend/merchantTrend, GET /coupons/:id/success and++ * GET /stores/:slug/success): twelve weekly buckets of people, drawn with the shared kit. A week with fewer than++ * 3 people is null, and no answer carries a reporter key, install id, channel or day-level row.*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const DAY = 86400000;++const forbidden = /reporter|install|channel|^day$|reporter_key|install_id/i;(async () => {const t = await boot();const alice = t.network.addUser('alice');-- await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });++ const shop = 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;@@@ -27,34 +28,63 @@ const DAY = 86400000;};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 add(200, 'worked'); // older than twelve weeks: not in the buckets-- await check("the code's page shows its reports over time, plainly", async () => {++ await check('the code page: worked-for people, the shared weekly chart, too-few weeks left blank', 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.match(r.text, /Worked for 5 of 6 people in the last 30 days\./);++ assert.match(r.text, /<h2 id="trend-h">Success over time<\/h2>/);++ assert.match(r.text, /class="ovk-chart ovk-chart--line"/, 'the shared kit draws it, not a hand-drawn SVG');++ assert.match(r.text, /2 people said it worked and 4 said it didn't/);++ assert.match(r.text, /A week with fewer than 3 people is too few to show/);++ assert.match(r.text, /\/shared\/kit-data-web\.js\?v=/, 'the chart readout is loaded');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('the code API: weekly buckets, a null under 3 people, no per-person field', async () => {++ const j = (await t.get(`/api/v1/coupons/${code.id}/success`)).json();++ assert.deepStrictEqual(Object.keys(j).sort(), ['from', 'min_people', 'to', 'weeks']);++ assert.strictEqual(j.min_people, 3);++ assert.strictEqual(j.weeks.length, 12);++ assert.ok(j.weeks.every((w) => w === null || (/^\d{4}-\d\d-\d\d$/.test(w.week) && new Date(`${w.week}T00:00:00Z`).getUTCDay() === 1)), 'weeks start on Monday');++ const visible = j.weeks.filter(Boolean);++ assert.strictEqual(visible.length, 2, 'only weeks with 3 people are shown');++ assert.deepStrictEqual(visible.map((w) => [w.worked, w.failed]), [[0, 3], [2, 1]]);++ assert.strictEqual(j.weeks.filter((w) => w === null).length, 10, 'a week with 1 or 2 people is null');++ assert.ok(!/reporter|install_id|channel|"day"/i.test(JSON.stringify(j)), 'aggregates only');++ });++++ await check('the store page and API: the shop\'s success per week across its codes', async () => {++ const page = await t.get(`/m/${shop.slug}`);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<h2 id="shop-trend-h">Success over time<\/h2>/);++ assert.match(page.text, /33% of people said a code worked in the weeks with enough reports \(2 of 6\)\./);++ assert.match(page.text, /class="ovk-chart ovk-chart--line"/);++ assert.match(page.text, /\/shared\/kit-data\.css\?v=/);++ assert.match(page.text, /\/shared\/kit-data-web\.js\?v=/);++ const j = (await t.get(`/api/v1/stores/${shop.slug}/success`)).json();++ assert.strictEqual(j.weeks.length, 12);++ assert.deepStrictEqual(j.weeks.filter(Boolean).map((w) => [w.worked, w.failed]), [[0, 3], [2, 1]]);++ assert.ok(!/reporter|install_id|channel|"day"/i.test(JSON.stringify(j)));++ assert.strictEqual((await t.get('/api/v1/stores/nope/success')).status, 404);});-- await check('a code nobody reported on shows no chart', async () => {++ await check('a code nobody reported on shows too few, never a chart', async () => {const r = await t.get(`/c/${quiet.id}`);assert.strictEqual(r.status, 200);-- assert.ok(!/Reports over time/.test(r.text));++ assert.match(r.text, /No one reported in the last 30 days\./);++ assert.match(r.text, /Too few reports in the last 12 weeks to show a chart/);++ assert.ok(!/class="ovk-chart/.test(r.text));++ });++++ await check('the JSON twin carries the buckets too, and no person', async () => {++ const j = (await t.get(`/c/${code.id}.json`)).json();++ assert.strictEqual(j.report_weeks.length, 12);++ assert.strictEqual(j.report_weeks.filter(Boolean).length, 2);++ assert.ok(!/reporter|install_id|channel|"day"/i.test(JSON.stringify(j.report_weeks)));});await t.close();done();--})();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -210,6 +210,7 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
@@ -210,6 +210,7 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
| `GET /merchants/resolve?host=` | Anyone. Anonymous callers are rate-limited per IP, installs per install. | `coupons.merchant.resolve` / `coupons.lookup` |
| `GET /merchants/resolve?host=` | Anyone. Anonymous callers are rate-limited per IP, installs per install. | `coupons.merchant.resolve` / `coupons.lookup` |
| `GET /merchants/:id/coupons` | Anyone (as above). | `coupons.coupon.lookup` / `coupons.lookup` |
| `GET /merchants/:id/coupons` | Anyone (as above). | `coupons.coupon.lookup` / `coupons.lookup` |
| `GET /merchants/:id`, `GET /coupons/:id` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |
| `GET /merchants/:id`, `GET /coupons/:id` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |
+| `GET /coupons/:id/success`, `GET /stores/:slug/success` | Anyone. | `coupons.coupon.lookup` / `coupons.lookup` |
A source's terms can keep its codes out of browser extensions (`server/domain/source-policy.js`): DealNews' feed terms
A source's terms can keep its codes out of browser extensions (`server/domain/source-policy.js`): DealNews' feed terms
say its content may not be used in a browser extension made available for public use, so a code from
say its content may not be used in a browser extension made available for public use, so a code from
@@ -222,6 +223,11 @@ or another `*-extension://` scheme, or an install's `cpx_` token): left out of `
@@ -222,6 +223,11 @@ or another `*-extension://` scheme, or an install's `cpx_` token): left out of `
| `POST /coupons/:id/status` `{status: disabled\|expired\|active, note?}` | Staff (Network admin, or `COUPONS_STAFF_SUBJECTS`) or a service. | `coupons.status.update` |
| `POST /coupons/:id/status` `{status: disabled\|expired\|active, note?}` | Staff (Network admin, or `COUPONS_STAFF_SUBJECTS`) or a service. | `coupons.status.update` |
| `POST /merchants`, `POST /merchants/:id/domains`, `POST /merchants/:id/status` | Staff or a service. | `coupons.merchant.manage` |
| `POST /merchants`, `POST /merchants/:id/domains`, `POST /merchants/:id/status` | Staff or a service. | `coupons.merchant.manage` |
+- **Success over time.** `GET /coupons/:id/success` and `GET /stores/:slug/success` return the last twelve weeks,
+ Monday to Sunday UTC, oldest first: `{ from, to, min_people, weeks: [null | { week, worked, failed }] }`. A week
+ counts the people whose most recent report that week said it worked or didn't (a person once a week, even across a
+ shop's codes). `null` means fewer than 3 people — too few to show — so one person's report cannot be read off it.
+ The answer never contains a reporter key, install id, channel or day-level row.
- **CORS:**
- **CORS:**
- Only the two lookup routes send CORS headers, and only to origins in
- Only the two lookup routes send CORS headers, and only to origins in
`COUPONS_EXTENSION_ORIGINS`. Those are exact `chrome-extension://<id>` origins, and optionally
`COUPONS_EXTENSION_ORIGINS`. Those are exact `chrome-extension://<id>` origins, and optionally
@@ -151,21 +151,12 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
@@ -151,21 +151,12 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
.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. */
+/* Success over time (render/pages.js reportTrend, merchantTrend): the shared kit draws the chart; these rules place
+ it and the one-line people count. Theme tokens only. */
.cpn-trend { margin: 22px 0 8px; }
.cpn-trend { margin: 22px 0 8px; }
.cpn-trend h2 { margin-bottom: 4px; }
.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; } }
+.cpn-people { margin: 12px 0 0; font-weight: 600; }
+.cpn-chart .ovk-chart { margin: 10px 0; }
/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */
/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */
@media (pointer: coarse) {
@media (pointer: coarse) {
@@ -29,6 +29,7 @@ function publicReason(reason) {
@@ -29,6 +29,7 @@ function publicReason(reason) {
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const confidence = require('./confidence');
const confidence = require('./confidence');
+const weekly = require('./weekly');
const { hosts } = require('openvibe-publishing/ingest');
const { hosts } = require('openvibe-publishing/ingest');
const { attributionOf, hidesFromExtension } = require('./source-policy');
const { attributionOf, hidesFromExtension } = require('./source-policy');
@@ -213,9 +214,13 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -213,9 +214,13 @@ 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`),
+ // A code's reports by person and day, for the page's weekly buckets (weekly.js counts people, not rows).
+ reportPeopleDays: db.prepare(`SELECT reporter_key, day, outcome, updated_at FROM coupon_validation_reports
+ WHERE coupon_id = ? AND day >= ? ORDER BY reporter_key, day, updated_at`),
+ // The same for a shop: every code it owns. A person who reports two of its codes counts once a week.
+ merchantPeopleDays: db.prepare(`SELECT r.reporter_key, r.day, r.outcome, r.updated_at FROM coupon_validation_reports r
+ JOIN coupons c ON c.id = r.coupon_id WHERE c.merchant_id = ? AND r.day >= ?
+ ORDER BY r.reporter_key, r.day, r.updated_at`),
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 = ?'),
};
};
@@ -534,21 +539,19 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -534,21 +539,19 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
}
}
/**
/**
- * 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.
+ * A code's success over time: the last `weeks` weeks (Monday to Sunday, UTC), oldest first, each a bucket of
+ * people whose most recent report that week said it worked or didn't. `null` means fewer than 3 people — too few
+ * to show — so one person's report cannot be read off it (weekly.js). Never who reported.
*/
*/
async function reportWeeks(id, weeks = 12) {
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;
+ const from = weekly.weekStarts(store.now(), weeks)[0];
+ return weekly.buckets(await q.reportPeopleDays.all(id, from), store.now(), weeks);
+ }
+
+ /** A shop's success over time across all its codes: the same weekly buckets, a person once a week. */
+ async function merchantWeeks(merchantId, weeks = 12) {
+ const from = weekly.weekStarts(store.now(), weeks)[0];
+ return weekly.buckets(await q.merchantPeopleDays.all(merchantId, from), store.now(), weeks);
}
}
/**
/**
@@ -600,7 +603,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -600,7 +603,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,
+ reportWeeks, merchantWeeks,
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 }),
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+/**
+ * Weekly success buckets for a code or a shop, with the privacy floor in one place.
+ *
+ * A bucket counts PEOPLE, not rows: each reporter's most recent report in the week says whether it worked. A week
+ * with fewer than MIN_PEOPLE distinct people is `null` — "too few reports" — so one person's report (or one
+ * person's seven daily rows) can never be read off the chart. Nothing here returns a reporter key, a channel, an
+ * install id or a day-level row; only a week's aggregate worked/failed counts.
+ */
+const DAY = 24 * 60 * 60 * 1000;
+const MIN_PEOPLE = 3;
+
+/** The Monday (UTC) that starts each of the last `weeks` weeks, oldest first. */
+function weekStarts(now, weeks = 12) {
+ const today = Math.floor(now / DAY) * DAY;
+ const monday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAY;
+ const first = monday - (weeks - 1) * 7 * DAY;
+ return Array.from({ length: weeks }, (_, i) => new Date(first + i * 7 * DAY).toISOString().slice(0, 10));
+}
+
+/**
+ * rows: [{ reporter_key, day: 'YYYY-MM-DD', outcome: 'worked'|'failed', updated_at }] for one code or shop,
+ * oldest first is not required (the latest day in the week wins). → { from, to, min_people, weeks: [null | { week,
+ * worked, failed }] }.
+ */
+function buckets(rows, now, weeks = 12) {
+ const starts = weekStarts(now, weeks);
+ const first = Date.parse(`${starts[0]}T00:00:00Z`);
+ const latest = new Map();
+ for (const r of rows || []) {
+ if (r.outcome !== 'worked' && r.outcome !== 'failed') continue;
+ const i = Math.floor((Date.parse(`${r.day}T00:00:00Z`) - first) / (7 * DAY));
+ if (!(i >= 0 && i < weeks)) continue;
+ const key = `${r.reporter_key}|${i}`;
+ const seen = latest.get(key);
+ // The latest day wins; on the same day the later update wins (a shop's codes can tie on a day).
+ if (!seen || String(r.day) > seen.day || (String(r.day) === seen.day && Number(r.updated_at) >= Number(seen.updated_at))) {
+ latest.set(key, { day: String(r.day), outcome: r.outcome, at: Number(r.updated_at) || 0 });
+ }
+ }
+ const count = Array.from({ length: weeks }, () => ({ worked: 0, failed: 0 }));
+ for (const [key, r] of latest) count[Number(key.split('|').pop())][r.outcome] += 1;
+ return {
+ from: starts[0], to: starts[weeks - 1], min_people: MIN_PEOPLE,
+ weeks: starts.map((week, i) => {
+ const total = count[i].worked + count[i].failed;
+ return total >= MIN_PEOPLE ? { week, worked: count[i].worked, failed: count[i].failed } : null;
+ }),
+ };
+}
+
+module.exports = { DAY, MIN_PEOPLE, weekStarts, buckets };
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
* Also public (no CORS):
* Also public (no CORS):
* GET /merchants/:id coupons.coupon.lookup | scope coupons.lookup
* GET /merchants/:id coupons.coupon.lookup | scope coupons.lookup
* GET /coupons/:id coupons.coupon.lookup | scope coupons.lookup
* GET /coupons/:id coupons.coupon.lookup | scope coupons.lookup
+ * GET /coupons/:id/success coupons.coupon.lookup | scope coupons.lookup (weekly, aggregate)
+ * GET /stores/:slug/success coupons.coupon.lookup | scope coupons.lookup (weekly, aggregate)
* People (Bearer Network token), installs and services acting for a person:
* People (Bearer Network token), installs and services acting for a person:
* POST /coupons/:id/report coupons.report.create | scope coupons.report
* POST /coupons/:id/report coupons.report.create | scope coupons.report
* POST /coupons/submit coupons.coupon.submit (installs cannot submit)
* POST /coupons/submit coupons.coupon.submit (installs cannot submit)
@@ -181,6 +183,25 @@ function createApi(ctx) {
@@ -181,6 +183,25 @@ function createApi(ctx) {
return { coupon: v, merchant: await merchantView(m, req) };
return { coupon: v, merchant: await merchantView(m, req) };
}));
}));
+ // ── Success over time (aggregate weekly buckets; never a reporter, install, channel or day-level row) ──
+ router.get('/coupons/:id/success', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
+ const c = await coupons.get(req.params.id);
+ const m = c && await merchants.byId(c.merchant_id);
+ if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');
+ const v = await coupons.view(c, { merchant: m });
+ if (v.extension_excluded && fromExtension(req)) throw new ApiError(404, 'coupon.not_found', 'no such code');
+ byOrigin(res);
+ publicCache(res);
+ return await coupons.reportWeeks(c.id);
+ }));
+
+ router.get('/stores/:slug/success', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
+ const m = await activeMerchant(req.params.slug);
+ byOrigin(res);
+ publicCache(res);
+ return await coupons.merchantWeeks(m.id);
+ }));
+
// ── Reports ─────────────────────────────────────────────
// ── Reports ─────────────────────────────────────────────
router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run(async (req, res) => {
router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run(async (req, res) => {
noStore(res);
noStore(res);
@@ -266,13 +266,15 @@ function createPublicRoutes(ctx) {
@@ -266,13 +266,15 @@ function createPublicRoutes(ctx) {
const ended = tab === 'codes' ? await Promise.all((await coupons.recentlyEnded(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) : [];
const ended = tab === 'codes' ? await Promise.all((await coupons.recentlyEnded(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) : [];
const decision = publication.merchantDecision(m, active.length);
const decision = publication.merchantDecision(m, active.length);
const flashMsg = FLASH[req.query.done] || null;
const flashMsg = FLASH[req.query.done] || null;
+ const success = tab === 'codes' ? await coupons.merchantWeeks(m.id) : null;
send(req, res, 200, {
send(req, res, 200, {
title: `${m.name} coupon codes`,
title: `${m.name} coupon codes`,
description: active.length ? `${active.length} active code${active.length === 1 ? '' : 's'} for ${m.name}, with restrictions, expiry and people's reports.` : `No active codes for ${m.name} right now.`,
description: active.length ? `${active.length} active code${active.length === 1 ? '' : 's'} for ${m.name}, with restrictions, expiry and people's reports.` : `No active codes for ${m.name} right now.`,
decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
+ chart: true,
body: pages.merchant({
body: pages.merchant({
- m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,
+ m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended, success,
viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab,
viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab,
watching: await watches.isWatching(req.viewer.subject, m),
watching: await watches.isWatching(req.viewer.subject, m),
}),
}),
@@ -304,7 +306,8 @@ function createPublicRoutes(ctx) {
@@ -304,7 +306,8 @@ 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 }), 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 } });
+ const weeks = await coupons.reportWeeks(found.c.id);
+ res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), report_weeks: weeks.weeks, report_weeks_from: weeks.from, 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) => {
@@ -317,7 +320,7 @@ function createPublicRoutes(ctx) {
@@ -317,7 +320,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), weeks: await coupons.reportWeeks(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 }), chart: true,
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
});
});
@@ -103,8 +103,8 @@ function renderPage(o) {
@@ -103,8 +103,8 @@ function renderPage(o) {
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
summary: o.summary,
summary: o.summary,
- styles: [...(o.styles || []), 'kit.css'], // openvibe-shared stylesheet names (the home's showcase.css; the OpenVibe Kit)
- scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`], // the kit's behaviour: copy buttons, menus, confirms (the page works without it)
+ styles: [...(o.styles || []), 'kit.css', ...(o.chart ? ['kit-data.css'] : [])], // shared style names; kit-data.css is the chart's look
+ scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`, ...(o.chart ? [`<script src="${ovServe.url('kit-data-web.js')}" defer></script>`] : [])], // the kit's behaviour; the page works without it
facts: o.facts,
facts: o.facts,
updated: o.updated,
updated: o.updated,
url: o.url,
url: o.url,
@@ -249,7 +249,7 @@ function shopTab(query, active) {
@@ -249,7 +249,7 @@ function shopTab(query, active) {
* A shop (GET /m/:slug): the head (the shop, its codes, Watch, Submit and more as icons), the best code right now, and
* A shop (GET /m/:slug): the head (the shop, its codes, Watch, Submit and more as icons), the best code right now, and
* one tab at a time. ended is only read for the Codes tab (the route passes [] otherwise).
* one tab at a time. ended is only read for the Codes tab (the route passes [] otherwise).
*/
*/
-function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg, tab = 'codes' }) {
+function merchant({ m, domains, hints, active, ended, success = null, viewer, csrf, watching, back, flashMsg, tab = 'codes' }) {
const codes = active.filter((v) => !isProductDeal(v));
const codes = active.filter((v) => !isProductDeal(v));
const deals = active.filter(isProductDeal);
const deals = active.filter(isProductDeal);
const shopUrl = m.homepage_url || null;
const shopUrl = m.homepage_url || null;
@@ -284,6 +284,7 @@ ${hints.length ? h`<ul class="hints ovk-note">${hints.map((x) => h`<li>How to ap
@@ -284,6 +284,7 @@ ${hints.length ? h`<ul class="hints ovk-note">${hints.map((x) => h`<li>How to ap
${deals.length ? deals.map(card) : h`<p class="empty">No product deals for ${m.name} right now.</p>`}`;
${deals.length ? deals.map(card) : h`<p class="empty">No product deals for ${m.name} right now.</p>`}`;
} else {
} else {
body = h`${best ? h`<a class="cpn-best" href="#${best.id}"><span>Best code now</span><strong>${headline(best)}</strong><code>${best.code}</code><span class="meta">${`Confidence ${Math.round(best.confidence * 100)}%`}</span></a>` : ''}
body = h`${best ? h`<a class="cpn-best" href="#${best.id}"><span>Best code now</span><strong>${headline(best)}</strong><code>${best.code}</code><span class="meta">${`Confidence ${Math.round(best.confidence * 100)}%`}</span></a>` : ''}
+${merchantTrend(success, m.name)}
${codes.length ? codes.map(card) : active.length ? h`<p class="empty">No code for the whole shop right now; ${String(deals.length)} product deal${deals.length === 1 ? '' : 's'} <a href="${base}?tab=deals">in Product deals</a>.</p>` : h`<p class="empty">No active codes for ${m.name} right now.</p>`}
${codes.length ? codes.map(card) : active.length ? h`<p class="empty">No code for the whole shop right now; ${String(deals.length)} product deal${deals.length === 1 ? '' : 's'} <a href="${base}?tab=deals">in Product deals</a>.</p>` : h`<p class="empty">No active codes for ${m.name} right now.</p>`}
<p>${k(kit.button({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, label: `Submit a code for ${m.name}`, icon: 'plus', kind: 'quiet', small: true }))}</p>
<p>${k(kit.button({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, label: `Submit a code for ${m.name}`, icon: 'plus', kind: 'quiet', small: true }))}</p>
${ended.length ? k(kit.fold({ title: 'Recently ended (not in active results)', meta: String(ended.length), body: String(h`<ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>`) })) : ''}`;
${ended.length ? k(kit.fold({ title: 'Recently ended (not in active results)', meta: String(ended.length), body: String(h`<ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>`) })) : ''}`;
@@ -293,30 +294,62 @@ ${flash(flashMsg)}
@@ -293,30 +294,62 @@ ${flash(flashMsg)}
${body}`;
${body}`;
}
}
+/** A week's Monday, for the buckets the API leaves null when fewer than 3 people reported. */
+const DAY = 86400000;
+const weekAt = (trend, i) => new Date(Date.parse(`${trend.from}T00:00:00Z`) + i * 7 * DAY).toISOString().slice(0, 10);
+const weekDates = (trend) => trend.weeks.map((w, i) => (w ? w.week : weekAt(trend, i)));
+
+/** The people a code's last 30 days of reports came from: "Worked for 4 of 7 people". */
+function peopleLine(v) {
+ const worked = Number(v.reports.worked) || 0, failed = Number(v.reports.failed) || 0, total = worked + failed;
+ return total ? `Worked for ${worked} of ${total} people in the last ${v.reports.window_days} days.` : `No one reported in the last ${v.reports.window_days} days.`;
+}
+
/**
/**
- * 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.
+ * A code's success over time: twelve weekly buckets of people whose most recent report that week said it worked or
+ * didn't, drawn by the shared kit. A null bucket is fewer than 3 people and stays a gap; nothing names a person.
*/
*/
-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 reportTrend(trend) {
+ if (!trend || !Array.isArray(trend.weeks) || !trend.weeks.length) return '';
+ const dates = weekDates(trend);
+ const shown = trend.weeks.filter(Boolean);
+ const worked = shown.reduce((n, w) => n + w.worked, 0), failed = shown.reduce((n, w) => n + w.failed, 0);
+ if (!shown.length) return h`<section class="cpn-trend" aria-labelledby="trend-h"><h2 id="trend-h">Success over time</h2>
+<p class="meta">Too few reports in the last ${trend.weeks.length} weeks to show a chart: a week needs at least ${trend.min_people} people.</p></section>`;
+ const points = (field) => trend.weeks.map((w, i) => [dates[i], w ? w[field] : null]);
+ const chart = kit.chart({
+ type: 'line', title: `Worked and didn't work, last ${trend.weeks.length} weeks`, height: 180,
+ label: 'Worked and did not work by week',
+ note: `Each week counts people, not reports; a week with fewer than ${trend.min_people} people is left blank.`,
+ x: { time: true, label: 'Week' }, y: { fmt: 'int', label: 'People' },
+ series: [{ name: 'Worked', points: points('worked') }, { name: "Didn't work", points: points('failed') }],
+ });
+ return h`<section class="cpn-trend" aria-labelledby="trend-h"><h2 id="trend-h">Success over time</h2>
+<p class="meta">${worked} people said it worked and ${failed} said it didn't, in the weeks with enough reports of the last ${trend.weeks.length}.</p>
+<div class="cpn-chart">${k(chart)}</div>
+<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p></section>`;
+}
+
+/** A shop's success rate per week across all its codes (weekly buckets, aggregate only). */
+function merchantTrend(trend, name) {
+ if (!trend || !Array.isArray(trend.weeks) || !trend.weeks.length) return '';
+ const dates = weekDates(trend);
+ const shown = trend.weeks.filter(Boolean);
+ const worked = shown.reduce((n, w) => n + w.worked, 0), failed = shown.reduce((n, w) => n + w.failed, 0);
+ const section = (body) => h`<section class="cpn-trend" aria-labelledby="shop-trend-h"><h2 id="shop-trend-h">Success over time</h2>${body}</section>`;
+ if (!shown.length) return section(h`<p class="meta">Too few reports at ${name} in the last ${trend.weeks.length} weeks to show a success rate: a week needs at least ${trend.min_people} people.</p>`);
+ const rate = Math.round((worked / (worked + failed)) * 100);
+ const points = trend.weeks.map((w, i) => [dates[i], w ? Math.round((w.worked / (w.worked + w.failed)) * 100) : null]);
+ const chart = kit.chart({
+ type: 'line', title: `${name}: success rate by week, last ${trend.weeks.length} weeks`, height: 180,
+ label: `${name} success rate by week`,
+ note: `The share of people whose most recent report that week said it worked; a week with fewer than ${trend.min_people} people is left blank.`,
+ x: { time: true, label: 'Week' }, y: { fmt: 'percent', label: 'Worked' },
+ series: [{ name: '% worked', points }],
+ });
+ return section(h`<p class="meta">${rate}% of people said a code worked in the weeks with enough reports (${worked} of ${worked + failed}).</p>
+<div class="cpn-chart">${k(chart)}</div>
+<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p>`);
}
}
function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {
function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {
@@ -324,6 +357,7 @@ function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {
@@ -324,6 +357,7 @@ function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {
${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 })}
+<p class="cpn-people">${peopleLine(v)}</p>
${reportTrend(weeks)}
${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>`}
@@ -1,18 +1,19 @@
@@ -1,18 +1,19 @@
'use strict';
'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.
+ * Success over time (weekly.js, render/pages.js reportTrend/merchantTrend, GET /coupons/:id/success and
+ * GET /stores/:slug/success): twelve weekly buckets of people, drawn with the shared kit. A week with fewer than
+ * 3 people is null, and no answer carries a reporter key, install id, channel or day-level row.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const DAY = 86400000;
const DAY = 86400000;
+const forbidden = /reporter|install|channel|^day$|reporter_key|install_id/i;
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
- await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });
+ const shop = 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 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;
const quiet = (await t.submit(alice, { host: 'bluekettle.shop', code: 'QUIET5', title: 'Code QUIET5' })).json().coupon;
@@ -27,34 +28,63 @@ const DAY = 86400000;
@@ -27,34 +28,63 @@ const DAY = 86400000;
};
};
for (const ago of [1, 2, 3, 9, 10]) await add(ago, 'worked');
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');
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 add(200, 'worked'); // older than twelve weeks: not in the buckets
- await check("the code's page shows its reports over time, plainly", async () => {
+ await check('the code page: worked-for people, the shared weekly chart, too-few weeks left blank', async () => {
const r = await t.get(`/c/${code.id}`);
const r = await t.get(`/c/${code.id}`);
assert.strictEqual(r.status, 200);
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.match(r.text, /Worked for 5 of 6 people in the last 30 days\./);
+ assert.match(r.text, /<h2 id="trend-h">Success over time<\/h2>/);
+ assert.match(r.text, /class="ovk-chart ovk-chart--line"/, 'the shared kit draws it, not a hand-drawn SVG');
+ assert.match(r.text, /2 people said it worked and 4 said it didn't/);
+ assert.match(r.text, /A week with fewer than 3 people is too few to show/);
+ assert.match(r.text, /\/shared\/kit-data-web\.js\?v=/, 'the chart readout is loaded');
assert.ok(!/rk\d/.test(r.text), 'never who reported');
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('the code API: weekly buckets, a null under 3 people, no per-person field', async () => {
+ const j = (await t.get(`/api/v1/coupons/${code.id}/success`)).json();
+ assert.deepStrictEqual(Object.keys(j).sort(), ['from', 'min_people', 'to', 'weeks']);
+ assert.strictEqual(j.min_people, 3);
+ assert.strictEqual(j.weeks.length, 12);
+ assert.ok(j.weeks.every((w) => w === null || (/^\d{4}-\d\d-\d\d$/.test(w.week) && new Date(`${w.week}T00:00:00Z`).getUTCDay() === 1)), 'weeks start on Monday');
+ const visible = j.weeks.filter(Boolean);
+ assert.strictEqual(visible.length, 2, 'only weeks with 3 people are shown');
+ assert.deepStrictEqual(visible.map((w) => [w.worked, w.failed]), [[0, 3], [2, 1]]);
+ assert.strictEqual(j.weeks.filter((w) => w === null).length, 10, 'a week with 1 or 2 people is null');
+ assert.ok(!/reporter|install_id|channel|"day"/i.test(JSON.stringify(j)), 'aggregates only');
+ });
+
+ await check('the store page and API: the shop\'s success per week across its codes', async () => {
+ const page = await t.get(`/m/${shop.slug}`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<h2 id="shop-trend-h">Success over time<\/h2>/);
+ assert.match(page.text, /33% of people said a code worked in the weeks with enough reports \(2 of 6\)\./);
+ assert.match(page.text, /class="ovk-chart ovk-chart--line"/);
+ assert.match(page.text, /\/shared\/kit-data\.css\?v=/);
+ assert.match(page.text, /\/shared\/kit-data-web\.js\?v=/);
+ const j = (await t.get(`/api/v1/stores/${shop.slug}/success`)).json();
+ assert.strictEqual(j.weeks.length, 12);
+ assert.deepStrictEqual(j.weeks.filter(Boolean).map((w) => [w.worked, w.failed]), [[0, 3], [2, 1]]);
+ assert.ok(!/reporter|install_id|channel|"day"/i.test(JSON.stringify(j)));
+ assert.strictEqual((await t.get('/api/v1/stores/nope/success')).status, 404);
});
});
- await check('a code nobody reported on shows no chart', async () => {
+ await check('a code nobody reported on shows too few, never a chart', async () => {
const r = await t.get(`/c/${quiet.id}`);
const r = await t.get(`/c/${quiet.id}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.ok(!/Reports over time/.test(r.text));
+ assert.match(r.text, /No one reported in the last 30 days\./);
+ assert.match(r.text, /Too few reports in the last 12 weeks to show a chart/);
+ assert.ok(!/class="ovk-chart/.test(r.text));
+ });
+
+ await check('the JSON twin carries the buckets too, and no person', async () => {
+ const j = (await t.get(`/c/${code.id}.json`)).json();
+ assert.strictEqual(j.report_weeks.length, 12);
+ assert.strictEqual(j.report_weeks.filter(Boolean).length, 2);
+ assert.ok(!/reporter|install_id|channel|"day"/i.test(JSON.stringify(j.report_weeks)));
});
});
await t.close();
await t.close();
done();
done();
-})();
+})().catch((e) => { console.error(e); process.exit(1); });