openvibers/openvibe.coupons Commits

Coupons: success over time for codes and shops, with private weekly buckets and API (#151)

OP OpenVibers committed 14 days ago · bf5cd5d8f1 · 9 files, +219 −79

Changes

ddiff --git a/README.md b/README.md
iindex 1ddb7a6..57b949b 100644
---- a/README.md
++++ b/README.md
@@@ -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 terms
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 `
| `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
ddiff --git a/public/css/coupons.css b/public/css/coupons.css
iindex 5ff6c56..191f426 100644
---- a/public/css/coupons.css
++++ b/public/css/coupons.css
@@@ -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) {
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex 353f9ed..c998f8f 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -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_reports
WHERE coupon_id = ? AND updated_at > ? ORDER BY reporter_key, updated_at DESC) latest
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'"),
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 }),
ddiff --git a/server/domain/weekly.js b/server/domain/weekly.js
nnew file mode 100644
iindex 0000000..abbd838
---- /dev/null
++++ b/server/domain/weekly.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 1f756ad..113d576 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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);
ddiff --git a/server/http/public.js b/server/http/public.js
iindex b192687..c95d408 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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 });
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 74d3c19..7291a98 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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 it
facts: o.facts,
updated: o.updated,
url: o.url,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 390a88f..eeb5292 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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>`}
ddiff --git a/test/report-trend.test.js b/test/report-trend.test.js
iindex 4f05b1a..814fe1b 100644
---- a/test/report-trend.test.js
++++ b/test/report-trend.test.js
@@@ -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); });