openvibers/openvibe.deals Commits
Deals: a "Remind me" link for a deal's end (plan L48 R3) (#169)
Changes
@@@ -158,6 +158,12 @@ code { overflow-wrap: anywhere; }.hm-card .badge { font-size: .72rem; }.hm-ends { margin: 0; font-size: .85rem; font-weight: 600; }.hm-ends .meta { font-weight: 400; font-size: .78rem; }++++/* "Remind me" (plan L48 R3): a quiet link with the shared kit's bell, in a card's own line and beside a page's++ own buttons. This site carries no kit button styles, so the link wears the site's own. */++.remind { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; text-decoration: none; white-space: nowrap; }++.remind-action { margin: 10px 0 0 10px; }++.remind .ovk-i { width: 1.05em; height: 1.05em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.hm-where { margin: 0; font-size: .82rem; overflow-wrap: anywhere; }.hm-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }.hm-chips li { min-width: 0; max-width: 100%; }@@@ -47,7 +47,7 @@ function createPages(ctx) {// Per-actor limits on the forms (http/actor-limits.js), before the form is read: each shares its budget// with the API route that does the same thing. Reading pages is left to the per-address limits.const B = (name) => actorLimits.budget(name);-- const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath };++ const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath, abs: publication.abs };const form = express.urlencoded({ extended: false, limit: '32kb' });const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);@@@ -12,6 +12,7 @@const { html, raw } = require('openvibe-publishing/ssr');const showcase = require('openvibe-shared/showcase');const { ago, when, priceText } = require('./views');++const { remindLink } = require('./remind');const { campaignCard } = require('../http/crowdfunding');const { pickBlock, pickRules, foldBlock } = require('./short');@@@ -55,10 +56,13 @@ function card(v, urls, { ends = false } = {}) {const where = r.seller ? html`at <a href="${urls.sellerPath(r.seller)}">${r.seller}</a>`: v.store && !v.attribution ? html`at <a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a>` : '';const via = v.attribution ? html`via <a href="${safeHref(v.attribution.url)}" rel="nofollow noopener">${v.attribution.text}</a>` : '';++ // "Remind me" for the moment the deal ends (plan L48 R3): a quiet link in the card's own line, only when the end++ // is stated and still ahead.++ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(urls.offerPath(r)) : '' });return html`<li class="hm-card"><h3><a href="${urls.offerPath(r)}">${r.title}</a></h3><p class="hm-price">${price} ${seenBadge(v)}</p>--${ends && r.expires_at ? html`<p class="hm-ends">Ends <time datetime="${new Date(r.expires_at).toISOString()}" title="${when(r.expires_at)}">${until(r.expires_at, v.now)}</time> <span class="meta">(as the source states it)</span></p>` : ''}++${ends && r.expires_at ? html`<p class="hm-ends">Ends <time datetime="${new Date(r.expires_at).toISOString()}" title="${when(r.expires_at)}">${until(r.expires_at, v.now)}</time> <span class="meta">(as the source states it)</span>${remind ? raw(remind) : ''}</p>` : ''}${where || via ? html`<p class="meta hm-where">${where}${where && via ? ' · ' : ''}${via}</p>` : ''}</li>`;}nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * "Remind me" on a deal's end (plan L48 R3): a link to openvibe.today/remind that sets a one-off reminder for the++ * moment the deal ends. The person signs in there, picks how long before, and it reaches them in the bell and as a++ * push on every OpenVibe site.++ *++ * The link is only there when the end is stated and still ahead: a deal with no end, or one that has ended, gets++ * none. The instant is the end itself, in UTC.++ */++const kit = require('openvibe-shared/kit');++++const HOST = 'openvibe.deals';++const REMIND = 'https://openvibe.today/remind';++++/** The moment a deal ends, in UTC, or null when no end is stated. */++function endsAt(offer) {++ const raw = offer && offer.expires_at;++ if (raw == null || raw === '') return null;++ const d = new Date(raw);++ return Number.isFinite(d.getTime()) ? d.toISOString() : null;++}++++/** The reminder's title: the deal's own name, the shop it is at, and that it ends. */++function remindTitle(offer, shop = '') {++ const name = String(offer && offer.title ? offer.title : '').trim();++ const where = String(shop || '').trim();++ return where ? `${name} at ${where} ends` : `${name} ends`;++}++++/** Today's "Remind me" link for this deal, or '' when there is nothing to remind about. */++function remindLink(offer, { now = Date.now(), shop = '', url = '', action = false } = {}) {++ const at = endsAt(offer);++ if (!at || Date.parse(at) <= now) return '';++ const query = new URLSearchParams({ title: remindTitle(offer, shop), at, url, from: HOST });++ const href = `${REMIND}?${query}`.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');++ const cls = action ? 'remind remind-action' : 'remind';++ return `<a class="${cls}" href="${href}">${kit.icon('bell', { size: 16 })}<span>Remind me</span></a>`;++}++++module.exports = { endsAt, remindTitle, remindLink, HOST, REMIND };@@@ -10,6 +10,7 @@const { html, raw, renderMarkdown, paginationHtml, breadcrumbsHtml } = require('openvibe-publishing/ssr');const { AVAILABILITY_LABEL, CONDITIONS, AVAILABILITY } = require('../domain/values');const { savingOf } = require('../domain/price-trend');++const { remindLink } = require('./remind');const pad = (n) => String(n).padStart(2, '0');function when(ms) {@@@ -152,12 +153,15 @@ ${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : 'function offerCard(v, urls) {const r = v.root;const href = urls.offerPath(r);++ // "Remind me" for the moment the deal ends (plan L48 R3): a quiet link beside the end the card states, only when++ // the end is stated and still ahead.++ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(href) : '' });return html`<li class="deal-item"><div class="deal-body"><h2 class="deal-title"><a href="${href}">${r.title}</a> ${statusBadge(r)}</h2>${priceRow(v)}${seenLine(v)}--${r.status === 'expired' ? '' : html`<p class="dl-when">${expiryLine(r, v.now)}</p>`}++${r.status === 'expired' ? '' : html`<p class="dl-when">${expiryLine(r, v.now)}${remind ? raw(remind) : ''}</p>`}${attributionLine(v.attribution)}<p class="meta">${r.seller ? html`<a class="seller" href="${urls.sellerPath ? urls.sellerPath(r.seller) : '#'}">at ${r.seller}</a> · ` : ''}${v.store ? html`<a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a> · ` : ''}${v.product ? html`<a href="${urls.productPath(v.product)}">${v.product.name}</a> · ` : ''}posted ${timeEl(r.created_at)}${r.origin === 'import' ? ' · imported from a source' : ''}</p></div>@@@ -258,6 +262,9 @@ function offerPage({ v, dto, viewer, csrf, myVote, comments: c, mergedComments,const signedIn = viewer.kind === 'user' && viewer.subject;const own = Boolean(signedIn && [r, ...v.members].some((o) => o.submitted_by === viewer.subject));const crumbs = [{ name: 'Deals', url: '/' }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: urls.storePath(v.store) }] : []), { name: r.title }];++ // "Remind me" for the moment the deal ends (plan L48 R3), beside the page's other actions; only when the end is++ // stated and still ahead.++ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(urls.offerPath(r)) : '', action: true });const voteForm = signedIn && r.status !== 'disabled' ? (own ? html`<p class="meta">You posted this deal.</p>` : html`<form class="inline vote" method="post" action="${urls.offerPath(r)}/vote">${csrfField(csrf)}<button name="value" value="up"${myVote === 1 ? raw(' aria-pressed="true"') : ''}>▲ Hot</button><button name="value" value="down"${myVote === -1 ? raw(' aria-pressed="true"') : ''}>▼ Cold</button>@@@ -271,7 +278,7 @@ ${r.status === 'expired' ? html`<p class="notice">This deal expired${r.expired_a<section class="latest" aria-label="Latest observation">${latestLine(v)}${v.freshness.state === 'stale' ? html`<p class="notice stale-notice">This price was last observed ${timeEl(v.latest.observed_at)}. Deals shows it as history, not as the price now. If you see the deal, report what the page says below.</p>` : ''}--<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a></p>++<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a>${remind ? raw(remind) : ''}</p>${r.expires_at && r.status === 'active' ? html`<p class="meta">Stated end: ${timeEl(r.expires_at)}</p>` : html`<p class="meta">End date: not stated</p>`}</section><section class="votes" aria-label="Votes">nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * "Remind me" on a deal's end (plan L48 R3): the link to openvibe.today/remind on the deal's own page and on the cards++ * that show the end, with the right title, time and page — and no link at all once the end has passed.++ */++const assert = require('assert');++++const { boot, check, done } = require('./helpers/boot');++++/** Every "Remind me" link on a page, as the URL it points at. */++function remindLinks(html) {++ return [...String(html).matchAll(/href="(https:\/\/openvibe\.today\/remind\?[^"]+)"/g)]++ .map((m) => m[1].replace(/&/g, '&'));++}++const query = (href) => Object.fromEntries(new URL(href.replace(/&/g, '&')).searchParams);++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++++ await check('a deal that ends later links to Today\'s "Remind me" on its page and on the cards', async () => {++ const offer = await t.submit(alice, {++ url: 'https://shop.example/steam-deck', title: 'Steam Deck OLED 512GB', price: '549.00', currency: 'USD',++ seller: 'Corner Shop', expires_at: '2026-09-25T18:00',++ });++ const slug = offer.slug;++++ const page = await t.get(`/d/${slug}`);++ const links = remindLinks(page.text);++ assert.ok(links.length, 'the deal\'s own page has a "Remind me" link');++ const q = query(links[0]);++ assert.strictEqual(q.at, '2026-09-25T18:00:00.000Z', 'the end itself, in UTC');++ assert.strictEqual(q.url, `https://openvibe.deals/d/${slug}`, 'the deal\'s canonical page');++ assert.strictEqual(q.from, 'openvibe.deals');++ assert.ok(q.title.includes('Steam Deck OLED 512GB'), q.title);++ assert.ok(q.title.endsWith(' ends'), q.title);++++ const listing = await t.get('/hot');++ const cardLink = remindLinks(listing.text).find((h) => query(h).url === `https://openvibe.deals/d/${slug}`);++ assert.ok(cardLink, 'the listing card has the link too');++++ const home = await t.get('/');++ const homeLink = remindLinks(home.text).find((h) => query(h).url === `https://openvibe.deals/d/${slug}`);++ assert.ok(homeLink, 'the front page\'s card has the link too');++ assert.strictEqual(query(homeLink).at, '2026-09-25T18:00:00.000Z');++ });++++ await check('a deal with no stated end has no "Remind me" link', async () => {++ const offer = await t.submit(alice, { url: 'https://shop.example/kettle', title: 'Kettle', price: '20.00', currency: 'EUR' });++ const page = await t.get(`/d/${offer.slug}`);++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link without an end');++ });++++ await check('a deal that has ended has no "Remind me" link', async () => {++ const offer = await t.submit(alice, {++ url: 'https://shop.example/lamp', title: 'Reading lamp', price: '30.00', currency: 'EUR',++ expires_at: '2026-09-25T18:00',++ });++ // The end moves into the past: nobody may be reminded for a deal that is over.++ await t.ctx.store.db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(t.clock.now() - 60_000, offer.id);++ const page = await t.get(`/d/${offer.slug}`);++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link once the end has passed');++ });++++ await t.close();++ done();++})();
@@ -158,6 +158,12 @@ code { overflow-wrap: anywhere; }
@@ -158,6 +158,12 @@ code { overflow-wrap: anywhere; }
.hm-card .badge { font-size: .72rem; }
.hm-card .badge { font-size: .72rem; }
.hm-ends { margin: 0; font-size: .85rem; font-weight: 600; }
.hm-ends { margin: 0; font-size: .85rem; font-weight: 600; }
.hm-ends .meta { font-weight: 400; font-size: .78rem; }
.hm-ends .meta { font-weight: 400; font-size: .78rem; }
+
+/* "Remind me" (plan L48 R3): a quiet link with the shared kit's bell, in a card's own line and beside a page's
+ own buttons. This site carries no kit button styles, so the link wears the site's own. */
+.remind { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; text-decoration: none; white-space: nowrap; }
+.remind-action { margin: 10px 0 0 10px; }
+.remind .ovk-i { width: 1.05em; height: 1.05em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.hm-where { margin: 0; font-size: .82rem; overflow-wrap: anywhere; }
.hm-where { margin: 0; font-size: .82rem; overflow-wrap: anywhere; }
.hm-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.hm-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.hm-chips li { min-width: 0; max-width: 100%; }
.hm-chips li { min-width: 0; max-width: 100%; }
@@ -47,7 +47,7 @@ function createPages(ctx) {
@@ -47,7 +47,7 @@ function createPages(ctx) {
// Per-actor limits on the forms (http/actor-limits.js), before the form is read: each shares its budget
// Per-actor limits on the forms (http/actor-limits.js), before the form is read: each shares its budget
// with the API route that does the same thing. Reading pages is left to the per-address limits.
// with the API route that does the same thing. Reading pages is left to the per-address limits.
const B = (name) => actorLimits.budget(name);
const B = (name) => actorLimits.budget(name);
- const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath };
+ const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath, abs: publication.abs };
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
const { html, raw } = require('openvibe-publishing/ssr');
const { html, raw } = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { ago, when, priceText } = require('./views');
const { ago, when, priceText } = require('./views');
+const { remindLink } = require('./remind');
const { campaignCard } = require('../http/crowdfunding');
const { campaignCard } = require('../http/crowdfunding');
const { pickBlock, pickRules, foldBlock } = require('./short');
const { pickBlock, pickRules, foldBlock } = require('./short');
@@ -55,10 +56,13 @@ function card(v, urls, { ends = false } = {}) {
@@ -55,10 +56,13 @@ function card(v, urls, { ends = false } = {}) {
const where = r.seller ? html`at <a href="${urls.sellerPath(r.seller)}">${r.seller}</a>`
const where = r.seller ? html`at <a href="${urls.sellerPath(r.seller)}">${r.seller}</a>`
: v.store && !v.attribution ? html`at <a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a>` : '';
: v.store && !v.attribution ? html`at <a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a>` : '';
const via = v.attribution ? html`via <a href="${safeHref(v.attribution.url)}" rel="nofollow noopener">${v.attribution.text}</a>` : '';
const via = v.attribution ? html`via <a href="${safeHref(v.attribution.url)}" rel="nofollow noopener">${v.attribution.text}</a>` : '';
+ // "Remind me" for the moment the deal ends (plan L48 R3): a quiet link in the card's own line, only when the end
+ // is stated and still ahead.
+ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(urls.offerPath(r)) : '' });
return html`<li class="hm-card">
return html`<li class="hm-card">
<h3><a href="${urls.offerPath(r)}">${r.title}</a></h3>
<h3><a href="${urls.offerPath(r)}">${r.title}</a></h3>
<p class="hm-price">${price} ${seenBadge(v)}</p>
<p class="hm-price">${price} ${seenBadge(v)}</p>
-${ends && r.expires_at ? html`<p class="hm-ends">Ends <time datetime="${new Date(r.expires_at).toISOString()}" title="${when(r.expires_at)}">${until(r.expires_at, v.now)}</time> <span class="meta">(as the source states it)</span></p>` : ''}
+${ends && r.expires_at ? html`<p class="hm-ends">Ends <time datetime="${new Date(r.expires_at).toISOString()}" title="${when(r.expires_at)}">${until(r.expires_at, v.now)}</time> <span class="meta">(as the source states it)</span>${remind ? raw(remind) : ''}</p>` : ''}
${where || via ? html`<p class="meta hm-where">${where}${where && via ? ' · ' : ''}${via}</p>` : ''}
${where || via ? html`<p class="meta hm-where">${where}${where && via ? ' · ' : ''}${via}</p>` : ''}
</li>`;
</li>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * "Remind me" on a deal's end (plan L48 R3): a link to openvibe.today/remind that sets a one-off reminder for the
+ * moment the deal ends. The person signs in there, picks how long before, and it reaches them in the bell and as a
+ * push on every OpenVibe site.
+ *
+ * The link is only there when the end is stated and still ahead: a deal with no end, or one that has ended, gets
+ * none. The instant is the end itself, in UTC.
+ */
+const kit = require('openvibe-shared/kit');
+
+const HOST = 'openvibe.deals';
+const REMIND = 'https://openvibe.today/remind';
+
+/** The moment a deal ends, in UTC, or null when no end is stated. */
+function endsAt(offer) {
+ const raw = offer && offer.expires_at;
+ if (raw == null || raw === '') return null;
+ const d = new Date(raw);
+ return Number.isFinite(d.getTime()) ? d.toISOString() : null;
+}
+
+/** The reminder's title: the deal's own name, the shop it is at, and that it ends. */
+function remindTitle(offer, shop = '') {
+ const name = String(offer && offer.title ? offer.title : '').trim();
+ const where = String(shop || '').trim();
+ return where ? `${name} at ${where} ends` : `${name} ends`;
+}
+
+/** Today's "Remind me" link for this deal, or '' when there is nothing to remind about. */
+function remindLink(offer, { now = Date.now(), shop = '', url = '', action = false } = {}) {
+ const at = endsAt(offer);
+ if (!at || Date.parse(at) <= now) return '';
+ const query = new URLSearchParams({ title: remindTitle(offer, shop), at, url, from: HOST });
+ const href = `${REMIND}?${query}`.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
+ const cls = action ? 'remind remind-action' : 'remind';
+ return `<a class="${cls}" href="${href}">${kit.icon('bell', { size: 16 })}<span>Remind me</span></a>`;
+}
+
+module.exports = { endsAt, remindTitle, remindLink, HOST, REMIND };
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
const { html, raw, renderMarkdown, paginationHtml, breadcrumbsHtml } = require('openvibe-publishing/ssr');
const { html, raw, renderMarkdown, paginationHtml, breadcrumbsHtml } = require('openvibe-publishing/ssr');
const { AVAILABILITY_LABEL, CONDITIONS, AVAILABILITY } = require('../domain/values');
const { AVAILABILITY_LABEL, CONDITIONS, AVAILABILITY } = require('../domain/values');
const { savingOf } = require('../domain/price-trend');
const { savingOf } = require('../domain/price-trend');
+const { remindLink } = require('./remind');
const pad = (n) => String(n).padStart(2, '0');
const pad = (n) => String(n).padStart(2, '0');
function when(ms) {
function when(ms) {
@@ -152,12 +153,15 @@ ${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : '
@@ -152,12 +153,15 @@ ${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : '
function offerCard(v, urls) {
function offerCard(v, urls) {
const r = v.root;
const r = v.root;
const href = urls.offerPath(r);
const href = urls.offerPath(r);
+ // "Remind me" for the moment the deal ends (plan L48 R3): a quiet link beside the end the card states, only when
+ // the end is stated and still ahead.
+ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(href) : '' });
return html`<li class="deal-item">
return html`<li class="deal-item">
<div class="deal-body">
<div class="deal-body">
<h2 class="deal-title"><a href="${href}">${r.title}</a> ${statusBadge(r)}</h2>
<h2 class="deal-title"><a href="${href}">${r.title}</a> ${statusBadge(r)}</h2>
${priceRow(v)}
${priceRow(v)}
${seenLine(v)}
${seenLine(v)}
-${r.status === 'expired' ? '' : html`<p class="dl-when">${expiryLine(r, v.now)}</p>`}
+${r.status === 'expired' ? '' : html`<p class="dl-when">${expiryLine(r, v.now)}${remind ? raw(remind) : ''}</p>`}
${attributionLine(v.attribution)}
${attributionLine(v.attribution)}
<p class="meta">${r.seller ? html`<a class="seller" href="${urls.sellerPath ? urls.sellerPath(r.seller) : '#'}">at ${r.seller}</a> · ` : ''}${v.store ? html`<a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a> · ` : ''}${v.product ? html`<a href="${urls.productPath(v.product)}">${v.product.name}</a> · ` : ''}posted ${timeEl(r.created_at)}${r.origin === 'import' ? ' · imported from a source' : ''}</p>
<p class="meta">${r.seller ? html`<a class="seller" href="${urls.sellerPath ? urls.sellerPath(r.seller) : '#'}">at ${r.seller}</a> · ` : ''}${v.store ? html`<a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a> · ` : ''}${v.product ? html`<a href="${urls.productPath(v.product)}">${v.product.name}</a> · ` : ''}posted ${timeEl(r.created_at)}${r.origin === 'import' ? ' · imported from a source' : ''}</p>
</div>
</div>
@@ -258,6 +262,9 @@ function offerPage({ v, dto, viewer, csrf, myVote, comments: c, mergedComments,
@@ -258,6 +262,9 @@ function offerPage({ v, dto, viewer, csrf, myVote, comments: c, mergedComments,
const signedIn = viewer.kind === 'user' && viewer.subject;
const signedIn = viewer.kind === 'user' && viewer.subject;
const own = Boolean(signedIn && [r, ...v.members].some((o) => o.submitted_by === viewer.subject));
const own = Boolean(signedIn && [r, ...v.members].some((o) => o.submitted_by === viewer.subject));
const crumbs = [{ name: 'Deals', url: '/' }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: urls.storePath(v.store) }] : []), { name: r.title }];
const crumbs = [{ name: 'Deals', url: '/' }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: urls.storePath(v.store) }] : []), { name: r.title }];
+ // "Remind me" for the moment the deal ends (plan L48 R3), beside the page's other actions; only when the end is
+ // stated and still ahead.
+ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(urls.offerPath(r)) : '', action: true });
const voteForm = signedIn && r.status !== 'disabled' ? (own ? html`<p class="meta">You posted this deal.</p>` : html`<form class="inline vote" method="post" action="${urls.offerPath(r)}/vote">${csrfField(csrf)}
const voteForm = signedIn && r.status !== 'disabled' ? (own ? html`<p class="meta">You posted this deal.</p>` : html`<form class="inline vote" method="post" action="${urls.offerPath(r)}/vote">${csrfField(csrf)}
<button name="value" value="up"${myVote === 1 ? raw(' aria-pressed="true"') : ''}>▲ Hot</button>
<button name="value" value="up"${myVote === 1 ? raw(' aria-pressed="true"') : ''}>▲ Hot</button>
<button name="value" value="down"${myVote === -1 ? raw(' aria-pressed="true"') : ''}>▼ Cold</button>
<button name="value" value="down"${myVote === -1 ? raw(' aria-pressed="true"') : ''}>▼ Cold</button>
@@ -271,7 +278,7 @@ ${r.status === 'expired' ? html`<p class="notice">This deal expired${r.expired_a
@@ -271,7 +278,7 @@ ${r.status === 'expired' ? html`<p class="notice">This deal expired${r.expired_a
<section class="latest" aria-label="Latest observation">
<section class="latest" aria-label="Latest observation">
${latestLine(v)}
${latestLine(v)}
${v.freshness.state === 'stale' ? html`<p class="notice stale-notice">This price was last observed ${timeEl(v.latest.observed_at)}. Deals shows it as history, not as the price now. If you see the deal, report what the page says below.</p>` : ''}
${v.freshness.state === 'stale' ? html`<p class="notice stale-notice">This price was last observed ${timeEl(v.latest.observed_at)}. Deals shows it as history, not as the price now. If you see the deal, report what the page says below.</p>` : ''}
-<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a></p>
+<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a>${remind ? raw(remind) : ''}</p>
${r.expires_at && r.status === 'active' ? html`<p class="meta">Stated end: ${timeEl(r.expires_at)}</p>` : html`<p class="meta">End date: not stated</p>`}
${r.expires_at && r.status === 'active' ? html`<p class="meta">Stated end: ${timeEl(r.expires_at)}</p>` : html`<p class="meta">End date: not stated</p>`}
</section>
</section>
<section class="votes" aria-label="Votes">
<section class="votes" aria-label="Votes">
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * "Remind me" on a deal's end (plan L48 R3): the link to openvibe.today/remind on the deal's own page and on the cards
+ * that show the end, with the right title, time and page — and no link at all once the end has passed.
+ */
+const assert = require('assert');
+
+const { boot, check, done } = require('./helpers/boot');
+
+/** Every "Remind me" link on a page, as the URL it points at. */
+function remindLinks(html) {
+ return [...String(html).matchAll(/href="(https:\/\/openvibe\.today\/remind\?[^"]+)"/g)]
+ .map((m) => m[1].replace(/&/g, '&'));
+}
+const query = (href) => Object.fromEntries(new URL(href.replace(/&/g, '&')).searchParams);
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+
+ await check('a deal that ends later links to Today\'s "Remind me" on its page and on the cards', async () => {
+ const offer = await t.submit(alice, {
+ url: 'https://shop.example/steam-deck', title: 'Steam Deck OLED 512GB', price: '549.00', currency: 'USD',
+ seller: 'Corner Shop', expires_at: '2026-09-25T18:00',
+ });
+ const slug = offer.slug;
+
+ const page = await t.get(`/d/${slug}`);
+ const links = remindLinks(page.text);
+ assert.ok(links.length, 'the deal\'s own page has a "Remind me" link');
+ const q = query(links[0]);
+ assert.strictEqual(q.at, '2026-09-25T18:00:00.000Z', 'the end itself, in UTC');
+ assert.strictEqual(q.url, `https://openvibe.deals/d/${slug}`, 'the deal\'s canonical page');
+ assert.strictEqual(q.from, 'openvibe.deals');
+ assert.ok(q.title.includes('Steam Deck OLED 512GB'), q.title);
+ assert.ok(q.title.endsWith(' ends'), q.title);
+
+ const listing = await t.get('/hot');
+ const cardLink = remindLinks(listing.text).find((h) => query(h).url === `https://openvibe.deals/d/${slug}`);
+ assert.ok(cardLink, 'the listing card has the link too');
+
+ const home = await t.get('/');
+ const homeLink = remindLinks(home.text).find((h) => query(h).url === `https://openvibe.deals/d/${slug}`);
+ assert.ok(homeLink, 'the front page\'s card has the link too');
+ assert.strictEqual(query(homeLink).at, '2026-09-25T18:00:00.000Z');
+ });
+
+ await check('a deal with no stated end has no "Remind me" link', async () => {
+ const offer = await t.submit(alice, { url: 'https://shop.example/kettle', title: 'Kettle', price: '20.00', currency: 'EUR' });
+ const page = await t.get(`/d/${offer.slug}`);
+ assert.strictEqual(remindLinks(page.text).length, 0, 'no link without an end');
+ });
+
+ await check('a deal that has ended has no "Remind me" link', async () => {
+ const offer = await t.submit(alice, {
+ url: 'https://shop.example/lamp', title: 'Reading lamp', price: '30.00', currency: 'EUR',
+ expires_at: '2026-09-25T18:00',
+ });
+ // The end moves into the past: nobody may be reminded for a deal that is over.
+ await t.ctx.store.db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(t.clock.now() - 60_000, offer.id);
+ const page = await t.get(`/d/${offer.slug}`);
+ assert.strictEqual(remindLinks(page.text).length, 0, 'no link once the end has passed');
+ });
+
+ await t.close();
+ done();
+})();