openvibers/openvibe.deals Commits
A deal card says what a deal is worth, and a shop says who it is (market outcomes) (#168)
Changes
nnew file mode 100644@@@ -0,0 +1,44 @@++/* The deal list's cards and a shop's head (market outcomes 1-6). Every page but the home loads this after++ deals.css (server/render/layout.js); the home draws its own cards, so its budget does not carry these. */++/* ── The card (market outcomes 1-5): price, was-price and saving; when it was seen and how much sits behind it; the++ stated expiry or "Expiry unknown"; on the right the price over time and what the votes say ── */++.dl-seen { margin: 0 0 4px; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; font-size: .82rem; color: var(--text-secondary, #96a7c2); }++.dl-seen time { color: var(--text-primary, #e8eef8); font-weight: 650; }++.dl-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--success, #22c55e); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success, #22c55e) 18%, transparent); }++.dl-dot--stale { background: var(--warning, #f59e0b); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning, #f59e0b) 18%, transparent); }++.dl-when { margin: 0 0 4px; font-size: .8rem; }++.dl-ends { color: var(--text-secondary, #96a7c2); }++.dl-ends--soon { color: var(--warning-text, var(--warning, #f59e0b)); font-weight: 700; }++.dl-ends--unknown, .dl-ends--past { color: var(--text-muted, #7386a3); }++.deal-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; min-width: 112px; }++.dl-spark { margin: 0; display: grid; justify-items: end; gap: 2px; }++.dl-spark svg { display: block; overflow: visible; }++.dl-spark polyline { fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }++.dl-spark circle.lo { fill: var(--success, #22c55e); }++.dl-spark circle.now { fill: var(--bg-card, #131c2e); stroke: var(--accent, #3b82f6); stroke-width: 1.8; }++.dl-spark circle.now.at-low { stroke: var(--success, #22c55e); }++.dl-spark figcaption { font-size: .7rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; white-space: nowrap; }++.dl-vote { margin: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px; }++.dl-useful { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); font-size: .78rem; font-weight: 700;++ color: var(--text-secondary, #96a7c2); text-decoration: none; white-space: nowrap; }++.dl-useful.has { color: var(--success-text, var(--success, #22c55e)); border-color: color-mix(in srgb, var(--success, #22c55e) 40%, var(--border, #1f2d47)); }++.dl-useful:hover { border-color: var(--accent, #3b82f6); }++.dl-band { padding: 2px 8px; border-radius: 999px; font-size: .7rem; font-weight: 800; letter-spacing: .02em; white-space: nowrap; }++.dl-band--pick { color: #f5b301; background: color-mix(in srgb, #f5b301 16%, transparent); }++.dl-band--popular { color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }++.dl-band--new { color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #e8eef8) 7%, transparent); }++@media (max-width: 600px) {++ .deal-item { grid-template-columns: minmax(0, 1fr); padding: 12px; }++ .deal-side { flex-direction: row; align-items: center; min-width: 0; padding-top: 8px; border-top: 1px solid var(--border, #1f2d47); }++ .dl-spark { grid-template-columns: auto auto; align-items: center; gap: 8px; justify-items: start; }++}++/* ── A shop's page (market outcome 6): who it is, how alive it is, and an alert ── */++.dl-shop { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin: 6px 0 14px; }++.dl-shop-mark { display: grid; place-items: center; width: 54px; height: 54px; border-radius: 16px; font-size: 1.5rem; font-weight: 900;++ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-card, #131c2e)); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++.dl-shop-t h1 { margin: 0; overflow-wrap: anywhere; }++.dl-shop-t .lede { margin: 2px 0 0; font-size: .9rem; }++.dl-alert { display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; font-weight: 700; font-size: .88rem; text-decoration: none;++ color: var(--on-accent, #fff); background: var(--accent, #3b82f6); white-space: nowrap; }++.dl-alert:hover { filter: brightness(1.08); }++@media (max-width: 600px) { .dl-shop { grid-template-columns: auto minmax(0, 1fr); } .dl-alert { grid-column: 1 / -1; justify-content: center; } }@@@ -48,7 +48,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }.tabs form.search input { width: 200px; }.tabs form.search button { margin: 0; }.deal-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }--.deal-item { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }++.deal-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }.deal-item:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }.deal-item .score { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 56px; border-radius: 12px; font-weight: 800; font-size: 1rem; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }.deal-item .score span { font-size: .7rem; line-height: 1; color: var(--text-muted, #7386a3); }@@@ -69,7 +69,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }.attribution { margin: 0; font-size: .82rem; color: var(--text-muted, #7386a3); }.deal-item .meta { margin: 2px 0 0; font-size: .82rem; }--@media (max-width: 600px) { .deal-item { grid-template-columns: 44px minmax(0, 1fr); padding: 12px; } .deal-item .score { height: 48px; } }++@media (max-width: 600px) { .deal-item { padding: 12px; } }@media (prefers-reduced-motion: reduce) { .deal-item { transition: none; } }.badge.fresh { border-color: var(--success, #22c55e); color: var(--success-text, var(--success, #22c55e)); }.badge.stale, .badge.expired { border-color: var(--warning, #f59e0b); color: var(--warning-text, var(--warning, #f59e0b)); }@@@ -30,6 +30,10 @@ function createListings({ store }) {countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),++ // A shop's own line (market outcome 6): how many deals are active, and how often its prices were seen today.++ storeStats: db.prepare(`SELECT (SELECT COUNT(*) FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status = 'active') AS active,++ (SELECT COUNT(*) FROM deal_price_observations p JOIN deal_offers o ON o.id = p.offer_id++ WHERE o.store_id = ? AND p.observed_at >= ?) AS seen24`),// Sellers: the shop a source names (migration 0006), as stated; a seller page lists by that exact name.sellers: db.prepare(`SELECT seller, COUNT(*) AS n, MAX(created_at) AS latest FROM deal_offers WHERE seller IS NOT NULL AND merged_into IS NULL AND status <> 'disabled' GROUP BY seller ORDER BY n DESC, seller LIMIT 2000`),bySeller: db.prepare(`SELECT * FROM deal_offers WHERE seller = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),@@@ -78,6 +82,10 @@ function createListings({ store }) {countActive: async () => (await q.countActive.get()).n,byStore: async (storeId, limit, offset) => await q.byStore.all(storeId, limit, offset),countStore: async (storeId) => (await q.countStore.get(storeId)).n,++ storeStats: async (storeId, now = Date.now()) => {++ const r = await q.storeStats.get(storeId, storeId, now - 24 * 3_600_000);++ return { active: Number(r.active) || 0, seen24: Number(r.seen24) || 0 };++ },sellers: async () => (await q.sellers.all()).map((r) => ({ seller: r.seller, slug: sellerSlug(r.seller), count: Number(r.n), latest: r.latest })),bySeller: async (seller, limit, offset) => await q.bySeller.all(seller, limit, offset),countSeller: async (seller) => (await q.countSeller.get(seller)).n,@@@ -428,7 +428,7 @@ function createPages(ctx) {agent: true,title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,-- body: views.storePage({ st, views: list, pager, urls }),++ body: views.storePage({ st, views: list, pager, urls, stats: await listings.storeStats(st.id, store.now()) }),};}@@@ -558,9 +558,14 @@ function createPages(ctx) {// ── watches ─────────────────────────────────────────────++ /** /watches?kind=keyword&query=zappos.com, from a shop's "Alert me": only a known kind and plain words. */++ const watchValues = (q = {}) => ({++ kind: ['keyword', 'price_below', 'product', 'search'].includes(String(q.kind)) ? String(q.kind) : 'keyword',++ query: typeof q.query === 'string' ? q.query.replace(/[\u0000-\u001f]/g, '').trim().slice(0, 200) : '',++ });async function watchesPage(req, res, status = 200, { error, notice } = {}) {const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));-- send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });++ send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query) }) });}router.get('/watches', wrap(async (req, res) => {@@@ -93,7 +93,7 @@ function renderPage(o) {return layout.renderDocument({// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick// block (there is one per pane, written for the page).-- head: [agentHead, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),++ head: [agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),site: 'deals',siteName: SITE_NAME,lang: o.lang,@@@ -83,17 +83,85 @@ ${o && o.availability ? html` · <span class="meta">${AVAILABILITY_LABEL[o.avail${o ? html` <span class="meta as-of">as of ${timeEl(o.observed_at)}</span>` : ''} ${freshnessBadge(v.freshness, v.now)}</p>`;}++/**++ * The price over time as a line the size of a word (plan: market outcome 4): the stated prices of the offer's++ * currency, oldest to newest, its lowest point marked. Nothing when there is only one price.++ */++function sparkline(t) {++ if (!t || !Array.isArray(t.points) || t.points.length < 2) return '';++ const pts = t.points.slice(-24);++ const W = 104, H = 30, P = 3;++ const lo = Math.min(...pts.map((p) => p.num)), hi = Math.max(...pts.map((p) => p.num));++ const x = (i) => P + (i * (W - 2 * P)) / (pts.length - 1);++ const y = (n) => (hi === lo ? H / 2 : P + ((hi - n) * (H - 2 * P)) / (hi - lo));++ const line = pts.map((p, i) => `${x(i).toFixed(1)},${y(p.num).toFixed(1)}`).join(' ');++ const li = pts.reduce((a, p, i) => (p.num < pts[a].num ? i : a), 0);++ const last = pts.length - 1;++ const label = `Price over ${t.count} observations: ${t.first.price} to ${t.latest.price}${t.currency ? ` ${t.currency}` : ''}, lowest ${t.lowest.price}`;++ return html`<figure class="dl-spark" data-points="${pts.length}"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}"><polyline points="${line}"/><circle class="lo" cx="${x(li).toFixed(1)}" cy="${y(pts[li].num).toFixed(1)}" r="2.4"/><circle class="now${t.atLowest ? ' at-low' : ''}" cx="${x(last).toFixed(1)}" cy="${y(pts[last].num).toFixed(1)}" r="2.6"/></svg>++<figcaption>${t.atLowest ? 'Lowest seen' : html`low ${t.lowest.price}`}</figcaption></figure>`;++}++++/** What the people who voted said, and the band it puts the deal in (market outcome 5). */++function bandOf(v) {++ const r = v.root;++ if (r.staff_pick) return ['pick', 'Editors\' pick'];++ if (v.tally && v.tally.up >= 5) return ['popular', 'Popular'];++ if (r.created_at && v.now - Number(r.created_at) < 24 * 3_600_000) return ['new', 'New today'];++ return null;++}++function voteLine(v, href) {++ const t = v.tally || { up: 0, down: 0 };++ const band = bandOf(v);++ const said = t.up ? `${t.up} found this useful` : 'Be the first to rate it';++ return html`<p class="dl-vote"><a href="${href}#vote" class="dl-useful${t.up ? ' has' : ''}" title="${t.up} up, ${t.down} down"><span aria-hidden="true">▲</span> ${said}</a>${band ? html` <span class="dl-band dl-band--${band[0]}">${band[1]}</span>` : ''}</p>`;++}++++/** The expiry a source stated, with how precise it was; never an assumed one (market outcome 3). */++function expiryLine(r, now) {++ if (r.status === 'expired') return '';++ if (r.expires_at == null) return html`<span class="dl-ends dl-ends--unknown">Expiry unknown</span>`;++ const at = Number(r.expires_at);++ const left = at - now;++ if (left <= 0) return html`<span class="dl-ends dl-ends--past">Stated to have ended ${timeEl(at)}</span>`;++ const soon = left < 48 * 3_600_000;++ const d = new Date(at);++ // A stated time at midnight UTC is a date, not a moment: say the day.++ const day = d.getUTCHours() === 0 && d.getUTCMinutes() === 0;++ const inWords = left < 90 * 60_000 ? `${Math.max(1, Math.round(left / 60_000))} minutes` : `${Math.round(left / 3_600_000)} hours`;++ return html`<span class="dl-ends${soon ? ' dl-ends--soon' : ''}">${soon ? `Ends in ${inWords}:` : 'Ends'} <time datetime="${d.toISOString()}">${day ? when(at).slice(0, 10) : when(at)}</time>${day ? ' (a date, no time stated)' : ''}</span>`;++}++++/** When the price was seen and how much sits behind it (market outcome 2), in the card's own words. */++function seenLine(v) {++ const o = v.latest;++ if (!o) return html`<p class="dl-seen"><span class="badge stale" data-freshness="unobserved">No price seen yet</span></p>`;++ const n = v.trend ? v.trend.count : 1;++ const f = v.freshness || {};++ return html`<p class="dl-seen"><span class="dl-dot dl-dot--${f.state === 'fresh' ? 'fresh' : 'stale'}" data-freshness="${f.state || 'unobserved'}" aria-hidden="true"></span>${f.state === 'stale' ? 'Last seen' : 'Price seen'} <time datetime="${new Date(Number(o.observed_at)).toISOString()}" title="${when(Number(o.observed_at))}">${ago(Number(o.observed_at), v.now)}</time>${f.state === 'stale' ? ' — it may be gone' : ''} · ${n} price${n === 1 ? '' : 's'} on record${v.root.deal_reason === 'price_drop' ? ' · lowest so far' : ''}</p>`;++}++++/** The price, the was-price struck through and the saving, shipping and stock (market outcome 1). */++function priceRow(v) {++ const o = v.latest;++ const stated = o && o.price != null;++ return html`<p class="price-line"><strong class="price${stated ? '' : ' price-none'}" data-price="${stated ? o.price : ''}">${priceText(o)}</strong>${listPriceLine(o)}++${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : ''}${o && o.availability ? html` <span class="meta">· ${AVAILABILITY_LABEL[o.availability]}</span>` : ''}</p>`;++}++function offerCard(v, urls) {const r = v.root;-- const score = v.tally.up - v.tally.down;++ const href = urls.offerPath(r);return html`<li class="deal-item">--<div class="score${score > 0 ? ' up' : score < 0 ? ' down' : ''}" title="${v.tally.up} up, ${v.tally.down} down"><span aria-hidden="true">▲</span>${score > 0 ? `+${score}` : score}</div><div class="deal-body">--<h2 class="deal-title"><a href="${urls.offerPath(r)}">${r.title}</a> ${statusBadge(r)}</h2>--${latestLine(v)}++<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>`}${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></li>`;++</div>++<div class="deal-side">${sparkline(v.trend)}${voteLine(v, href)}</div></li>`;}function offerList({ heading, intro, views, pager, urls, tabs, empty, lead }) {@@@ -301,7 +369,9 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}</form>`;}--function watchesPage({ csrf, watches, error, notice, urls }) {++function watchesPage({ csrf, watches, error, notice, urls, values = {} }) {++ // Arriving from a shop's "Alert me" (/watches?kind=keyword&query=zappos.com): the form says so, filled in.++ const kindOpt = (k, label) => html`<option value="${k}"${values.kind === k ? raw(' selected') : ''}>${label}</option>`;return html`<h1>Watches and saved searches</h1><p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}@@@ -311,9 +381,9 @@ ${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.produc${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}<form method="post" action="/watches" class="card">${csrfField(csrf)}--<h2>Add</h2>--<label for="w-kind">Kind</label><select id="w-kind" name="kind"><option value="keyword">Keywords (notify)</option><option value="price_below">Price below (notify)</option><option value="product">Product (notify)</option><option value="search">Saved search (no notifications)</option></select>--<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200">++<h2>${values.query ? html`Alert me about “${values.query}”` : 'Add'}</h2>++<label for="w-kind">Kind</label><select id="w-kind" name="kind">${kindOpt('keyword', 'Keywords (notify)')}${kindOpt('price_below', 'Price below (notify)')}${kindOpt('product', 'Product (notify)')}${kindOpt('search', 'Saved search (no notifications)')}</select>++<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200" value="${values.query || ''}"><label for="w-p">Product (slug, for product and price watches)</label><input id="w-p" name="product"><label for="w-max">Price below</label><input id="w-max" name="max_price" inputmode="decimal"><label for="w-cur">Currency</label><input id="w-cur" name="currency" maxlength="3" placeholder="USD">@@@ -344,10 +414,12 @@ ${duplicates.length ? html`<ul>${duplicates.map((d) => html`<li><a href="${urls.<form class="inline" method="post" action="/mod/offers/${d.b.slug}/merge">${csrfField(csrf)}<input type="hidden" name="into" value="${d.a.slug}"><button type="submit">Merge the newer into the older</button></form></li>`)}</ul>` : html`<p class="empty">None found.</p>`}`;}--function storePage({ st, views, pager, urls }) {++function storePage({ st, views, pager, urls, stats = null }) {++ const n = (x) => Number(x || 0).toLocaleString('en-US');return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: st.name || st.domain }]))}--<h1>${st.name || st.domain}</h1>--<p class="lede">Deals at <code>${st.domain}</code>.</p>++<header class="dl-shop"><span class="dl-shop-mark" aria-hidden="true">${String(st.name || st.domain).replace(/^www\./, '').slice(0, 1).toUpperCase()}</span>++<div class="dl-shop-t"><h1>${st.name || st.domain}</h1><p class="lede"><a href="https://${st.domain}" rel="nofollow noopener">${st.domain}</a>${stats ? html` · ${n(stats.active)} active deal${Number(stats.active) === 1 ? '' : 's'} · prices seen ${n(stats.seen24)} time${Number(stats.seen24) === 1 ? '' : 's'} in 24 hours` : ''}</p></div>++<a class="dl-alert" href="/watches?kind=keyword&query=${encodeURIComponent(st.domain)}">Alert me about deals here</a></header>${views.length ? html`<ol class="deal-list">${views.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No deals from this store.</p>`}${raw(pager ? paginationHtml(pager) : '')}`;}nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * The deal card and the shop page (market outcomes 1-6): the price with the was-price struck through and the saving;++ * when the price was seen and how many prices sit behind it; the stated expiry with its precision or "Expiry++ * unknown"; the price over time as a sparkline once there are two; what the votes count and the band; and a shop's++ * own line: active deals, prices seen in 24 hours, and an alert prefilled for it.++ */++const assert = require('assert');++const { boot, check, done, HOUR } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const [alice, bob, cara] = ['alice', 'bob', 'cara'].map((n) => t.network.addUser(n));++ const db = t.ctx.store.db;++ let a, b;++ const card = (html, title) => {++ const i = html.indexOf(`>${title}</a>`);++ assert.ok(i > 0, `a card for ${title}`);++ return html.slice(html.lastIndexOf('<li class="deal-item">', i), html.indexOf('</li>', i));++ };++++ await check('one price: the price, when it was seen, one price on record, expiry unknown, no sparkline', async () => {++ a = await t.submit(alice, { url: 'https://shop.example/headphones', title: 'Studio headphones', price: '49.99', currency: 'USD', availability: 'in_stock' });++ const c = card((await t.get('/s/shop.example')).text, 'Studio headphones');++ assert.match(c, /<strong class="price" data-price="49.99">49.99 USD<\/strong>/);++ assert.match(c, /Price seen <time datetime="[^"]+" title="[^"]+">just now<\/time> · 1 price on record/);++ assert.match(c, /data-freshness="fresh"/);++ assert.match(c, /<span class="dl-ends dl-ends--unknown">Expiry unknown<\/span>/, 'never an assumed expiry');++ assert.doesNotMatch(c, /dl-spark/, 'one price is not a line');++ assert.match(c, /Be the first to rate it/);++ assert.match(c, /dl-band--new">New today/);++ });++++ await check('more prices, a stated list price, a stated end, votes: the whole card', async () => {++ for (const [h, price] of [[2, '45.00'], [4, '39.99']]) {++ t.clock.advance(h * HOUR);++ const r = await t.get(`/d/${a.slug}/observe`, { as: bob, form: { price, currency: 'USD', csrf: t.csrf(bob) } });++ assert.ok([200, 303].includes(r.status), `observe ${r.status}: ${r.text.slice(0, 200)}`);++ }++ await db.prepare("UPDATE deal_price_observations SET list_price = '59.99', list_price_num = 59.99 WHERE offer_id = ? AND price = '39.99'").run(a.id);++ await db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(t.clock.now() + 30 * HOUR, a.id);++ for (const u of [bob, cara]) await t.get(`/d/${a.slug}/vote`, { as: u, form: { value: 'up', csrf: t.csrf(u) } });++ const c = card((await t.get('/s/shop.example')).text, 'Studio headphones');++ assert.match(c, /<s class="dl-list" data-list-price="59.99">59.99 USD<\/s> <span class="dl-saving">−33%<\/span>/, 'the was-price and the saving');++ assert.match(c, /· 3 prices on record/);++ assert.match(c, /<figure class="dl-spark" data-points="3"><svg [^>]*role="img" aria-label="Price over 3 observations: 49.99 to 39.99 USD, lowest 39.99">/);++ assert.match(c, /<figcaption>Lowest seen<\/figcaption>/, 'the latest is the lowest');++ assert.match(c, /<span class="dl-ends dl-ends--soon">Ends in 30 hours: <time datetime=/, 'a stated end, soon');++ assert.match(c, /2 found this useful/, 'the vote says what it counts');++ t.clock.advance(25 * HOUR);++ await db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(Date.UTC(2027, 0, 15), a.id);++ const later = card((await t.get('/s/shop.example')).text, 'Studio headphones');++ assert.match(later, /Ends <time datetime="2027-01-15T00:00:00.000Z">2027-01-15<\/time> \(a date, no time stated\)/, 'a date is said as a date');++ assert.doesNotMatch(later, /New today/);++ });++++ await check('the band of an editors\' pick', async () => {++ b = await t.submit(alice, { url: 'https://shop.example/lamp', title: 'Desk lamp', price: '20.00', currency: 'USD' });++ await db.prepare('UPDATE deal_offers SET staff_pick = true WHERE id = ?').run(b.id);++ assert.match(card((await t.get('/s/shop.example')).text, 'Desk lamp'), /dl-band--pick">Editors' pick|dl-band--pick">Editors' pick/);++ });++++ await check('the shop: its line, its active deals, prices seen in 24 hours, an alert prefilled for it', async () => {++ const r = await t.get('/s/shop.example');++ assert.match(r.text, /<header class="dl-shop">/);++ assert.match(r.text, /<a href="https:\/\/shop\.example" rel="nofollow noopener">shop\.example<\/a> · 2 active deals · prices seen \d+ times? in 24 hours/);++ assert.match(r.text, /<a class="dl-alert" href="\/watches\?kind=keyword&query=shop\.example">Alert me about deals here<\/a>/);++ const w = await t.get('/watches?kind=keyword&query=shop.example', { as: alice });++ assert.match(w.text, /<h2>Alert me about “shop\.example”<\/h2>/);++ assert.match(w.text, /<option value="keyword" selected>/);++ assert.match(w.text, /<input id="w-q" name="query" maxlength="200" value="shop\.example">/);++ const odd = await t.get('/watches?kind=evil&query=%3Cb%3Ex%3C%2Fb%3E', { as: alice });++ assert.match(odd.text, /<option value="keyword" selected>/, 'an unknown kind is a keyword watch');++ assert.ok(!odd.text.includes('<b>x</b>'), 'the words are text');++ });++++ await done(t);++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+/* The deal list's cards and a shop's head (market outcomes 1-6). Every page but the home loads this after
+ deals.css (server/render/layout.js); the home draws its own cards, so its budget does not carry these. */
+/* ── The card (market outcomes 1-5): price, was-price and saving; when it was seen and how much sits behind it; the
+ stated expiry or "Expiry unknown"; on the right the price over time and what the votes say ── */
+.dl-seen { margin: 0 0 4px; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; font-size: .82rem; color: var(--text-secondary, #96a7c2); }
+.dl-seen time { color: var(--text-primary, #e8eef8); font-weight: 650; }
+.dl-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--success, #22c55e); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success, #22c55e) 18%, transparent); }
+.dl-dot--stale { background: var(--warning, #f59e0b); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning, #f59e0b) 18%, transparent); }
+.dl-when { margin: 0 0 4px; font-size: .8rem; }
+.dl-ends { color: var(--text-secondary, #96a7c2); }
+.dl-ends--soon { color: var(--warning-text, var(--warning, #f59e0b)); font-weight: 700; }
+.dl-ends--unknown, .dl-ends--past { color: var(--text-muted, #7386a3); }
+.deal-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; min-width: 112px; }
+.dl-spark { margin: 0; display: grid; justify-items: end; gap: 2px; }
+.dl-spark svg { display: block; overflow: visible; }
+.dl-spark polyline { fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
+.dl-spark circle.lo { fill: var(--success, #22c55e); }
+.dl-spark circle.now { fill: var(--bg-card, #131c2e); stroke: var(--accent, #3b82f6); stroke-width: 1.8; }
+.dl-spark circle.now.at-low { stroke: var(--success, #22c55e); }
+.dl-spark figcaption { font-size: .7rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; white-space: nowrap; }
+.dl-vote { margin: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px; }
+.dl-useful { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); font-size: .78rem; font-weight: 700;
+ color: var(--text-secondary, #96a7c2); text-decoration: none; white-space: nowrap; }
+.dl-useful.has { color: var(--success-text, var(--success, #22c55e)); border-color: color-mix(in srgb, var(--success, #22c55e) 40%, var(--border, #1f2d47)); }
+.dl-useful:hover { border-color: var(--accent, #3b82f6); }
+.dl-band { padding: 2px 8px; border-radius: 999px; font-size: .7rem; font-weight: 800; letter-spacing: .02em; white-space: nowrap; }
+.dl-band--pick { color: #f5b301; background: color-mix(in srgb, #f5b301 16%, transparent); }
+.dl-band--popular { color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
+.dl-band--new { color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #e8eef8) 7%, transparent); }
+@media (max-width: 600px) {
+ .deal-item { grid-template-columns: minmax(0, 1fr); padding: 12px; }
+ .deal-side { flex-direction: row; align-items: center; min-width: 0; padding-top: 8px; border-top: 1px solid var(--border, #1f2d47); }
+ .dl-spark { grid-template-columns: auto auto; align-items: center; gap: 8px; justify-items: start; }
+}
+/* ── A shop's page (market outcome 6): who it is, how alive it is, and an alert ── */
+.dl-shop { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin: 6px 0 14px; }
+.dl-shop-mark { display: grid; place-items: center; width: 54px; height: 54px; border-radius: 16px; font-size: 1.5rem; font-weight: 900;
+ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-card, #131c2e)); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+.dl-shop-t h1 { margin: 0; overflow-wrap: anywhere; }
+.dl-shop-t .lede { margin: 2px 0 0; font-size: .9rem; }
+.dl-alert { display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; font-weight: 700; font-size: .88rem; text-decoration: none;
+ color: var(--on-accent, #fff); background: var(--accent, #3b82f6); white-space: nowrap; }
+.dl-alert:hover { filter: brightness(1.08); }
+@media (max-width: 600px) { .dl-shop { grid-template-columns: auto minmax(0, 1fr); } .dl-alert { grid-column: 1 / -1; justify-content: center; } }
@@ -48,7 +48,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
@@ -48,7 +48,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
.tabs form.search input { width: 200px; }
.tabs form.search input { width: 200px; }
.tabs form.search button { margin: 0; }
.tabs form.search button { margin: 0; }
.deal-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
.deal-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
-.deal-item { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
+.deal-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
.deal-item:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
.deal-item:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
.deal-item .score { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 56px; border-radius: 12px; font-weight: 800; font-size: 1rem; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }
.deal-item .score { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 56px; border-radius: 12px; font-weight: 800; font-size: 1rem; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }
.deal-item .score span { font-size: .7rem; line-height: 1; color: var(--text-muted, #7386a3); }
.deal-item .score span { font-size: .7rem; line-height: 1; color: var(--text-muted, #7386a3); }
@@ -69,7 +69,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
@@ -69,7 +69,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
.attribution { margin: 0; font-size: .82rem; color: var(--text-muted, #7386a3); }
.attribution { margin: 0; font-size: .82rem; color: var(--text-muted, #7386a3); }
.deal-item .meta { margin: 2px 0 0; font-size: .82rem; }
.deal-item .meta { margin: 2px 0 0; font-size: .82rem; }
-@media (max-width: 600px) { .deal-item { grid-template-columns: 44px minmax(0, 1fr); padding: 12px; } .deal-item .score { height: 48px; } }
+@media (max-width: 600px) { .deal-item { padding: 12px; } }
@media (prefers-reduced-motion: reduce) { .deal-item { transition: none; } }
@media (prefers-reduced-motion: reduce) { .deal-item { transition: none; } }
.badge.fresh { border-color: var(--success, #22c55e); color: var(--success-text, var(--success, #22c55e)); }
.badge.fresh { border-color: var(--success, #22c55e); color: var(--success-text, var(--success, #22c55e)); }
.badge.stale, .badge.expired { border-color: var(--warning, #f59e0b); color: var(--warning-text, var(--warning, #f59e0b)); }
.badge.stale, .badge.expired { border-color: var(--warning, #f59e0b); color: var(--warning-text, var(--warning, #f59e0b)); }
@@ -30,6 +30,10 @@ function createListings({ store }) {
@@ -30,6 +30,10 @@ function createListings({ store }) {
countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),
countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),
byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),
countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),
+ // A shop's own line (market outcome 6): how many deals are active, and how often its prices were seen today.
+ storeStats: db.prepare(`SELECT (SELECT COUNT(*) FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status = 'active') AS active,
+ (SELECT COUNT(*) FROM deal_price_observations p JOIN deal_offers o ON o.id = p.offer_id
+ WHERE o.store_id = ? AND p.observed_at >= ?) AS seen24`),
// Sellers: the shop a source names (migration 0006), as stated; a seller page lists by that exact name.
// Sellers: the shop a source names (migration 0006), as stated; a seller page lists by that exact name.
sellers: db.prepare(`SELECT seller, COUNT(*) AS n, MAX(created_at) AS latest FROM deal_offers WHERE seller IS NOT NULL AND merged_into IS NULL AND status <> 'disabled' GROUP BY seller ORDER BY n DESC, seller LIMIT 2000`),
sellers: db.prepare(`SELECT seller, COUNT(*) AS n, MAX(created_at) AS latest FROM deal_offers WHERE seller IS NOT NULL AND merged_into IS NULL AND status <> 'disabled' GROUP BY seller ORDER BY n DESC, seller LIMIT 2000`),
bySeller: db.prepare(`SELECT * FROM deal_offers WHERE seller = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
bySeller: db.prepare(`SELECT * FROM deal_offers WHERE seller = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
@@ -78,6 +82,10 @@ function createListings({ store }) {
@@ -78,6 +82,10 @@ function createListings({ store }) {
countActive: async () => (await q.countActive.get()).n,
countActive: async () => (await q.countActive.get()).n,
byStore: async (storeId, limit, offset) => await q.byStore.all(storeId, limit, offset),
byStore: async (storeId, limit, offset) => await q.byStore.all(storeId, limit, offset),
countStore: async (storeId) => (await q.countStore.get(storeId)).n,
countStore: async (storeId) => (await q.countStore.get(storeId)).n,
+ storeStats: async (storeId, now = Date.now()) => {
+ const r = await q.storeStats.get(storeId, storeId, now - 24 * 3_600_000);
+ return { active: Number(r.active) || 0, seen24: Number(r.seen24) || 0 };
+ },
sellers: async () => (await q.sellers.all()).map((r) => ({ seller: r.seller, slug: sellerSlug(r.seller), count: Number(r.n), latest: r.latest })),
sellers: async () => (await q.sellers.all()).map((r) => ({ seller: r.seller, slug: sellerSlug(r.seller), count: Number(r.n), latest: r.latest })),
bySeller: async (seller, limit, offset) => await q.bySeller.all(seller, limit, offset),
bySeller: async (seller, limit, offset) => await q.bySeller.all(seller, limit, offset),
countSeller: async (seller) => (await q.countSeller.get(seller)).n,
countSeller: async (seller) => (await q.countSeller.get(seller)).n,
@@ -428,7 +428,7 @@ function createPages(ctx) {
@@ -428,7 +428,7 @@ function createPages(ctx) {
agent: true,
agent: true,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
- body: views.storePage({ st, views: list, pager, urls }),
+ body: views.storePage({ st, views: list, pager, urls, stats: await listings.storeStats(st.id, store.now()) }),
};
};
}
}
@@ -558,9 +558,14 @@ function createPages(ctx) {
@@ -558,9 +558,14 @@ function createPages(ctx) {
// ── watches ─────────────────────────────────────────────
// ── watches ─────────────────────────────────────────────
+ /** /watches?kind=keyword&query=zappos.com, from a shop's "Alert me": only a known kind and plain words. */
+ const watchValues = (q = {}) => ({
+ kind: ['keyword', 'price_below', 'product', 'search'].includes(String(q.kind)) ? String(q.kind) : 'keyword',
+ query: typeof q.query === 'string' ? q.query.replace(/[\u0000-\u001f]/g, '').trim().slice(0, 200) : '',
+ });
async function watchesPage(req, res, status = 200, { error, notice } = {}) {
async function watchesPage(req, res, status = 200, { error, notice } = {}) {
const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));
const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));
- send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });
+ send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query) }) });
}
}
router.get('/watches', wrap(async (req, res) => {
router.get('/watches', wrap(async (req, res) => {
@@ -93,7 +93,7 @@ function renderPage(o) {
@@ -93,7 +93,7 @@ function renderPage(o) {
return layout.renderDocument({
return layout.renderDocument({
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// block (there is one per pane, written for the page).
// block (there is one per pane, written for the page).
- head: [agentHead, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
+ head: [agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
site: 'deals',
site: 'deals',
siteName: SITE_NAME,
siteName: SITE_NAME,
lang: o.lang,
lang: o.lang,
@@ -83,17 +83,85 @@ ${o && o.availability ? html` · <span class="meta">${AVAILABILITY_LABEL[o.avail
@@ -83,17 +83,85 @@ ${o && o.availability ? html` · <span class="meta">${AVAILABILITY_LABEL[o.avail
${o ? html` <span class="meta as-of">as of ${timeEl(o.observed_at)}</span>` : ''} ${freshnessBadge(v.freshness, v.now)}</p>`;
${o ? html` <span class="meta as-of">as of ${timeEl(o.observed_at)}</span>` : ''} ${freshnessBadge(v.freshness, v.now)}</p>`;
}
}
+/**
+ * The price over time as a line the size of a word (plan: market outcome 4): the stated prices of the offer's
+ * currency, oldest to newest, its lowest point marked. Nothing when there is only one price.
+ */
+function sparkline(t) {
+ if (!t || !Array.isArray(t.points) || t.points.length < 2) return '';
+ const pts = t.points.slice(-24);
+ const W = 104, H = 30, P = 3;
+ const lo = Math.min(...pts.map((p) => p.num)), hi = Math.max(...pts.map((p) => p.num));
+ const x = (i) => P + (i * (W - 2 * P)) / (pts.length - 1);
+ const y = (n) => (hi === lo ? H / 2 : P + ((hi - n) * (H - 2 * P)) / (hi - lo));
+ const line = pts.map((p, i) => `${x(i).toFixed(1)},${y(p.num).toFixed(1)}`).join(' ');
+ const li = pts.reduce((a, p, i) => (p.num < pts[a].num ? i : a), 0);
+ const last = pts.length - 1;
+ const label = `Price over ${t.count} observations: ${t.first.price} to ${t.latest.price}${t.currency ? ` ${t.currency}` : ''}, lowest ${t.lowest.price}`;
+ return html`<figure class="dl-spark" data-points="${pts.length}"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}"><polyline points="${line}"/><circle class="lo" cx="${x(li).toFixed(1)}" cy="${y(pts[li].num).toFixed(1)}" r="2.4"/><circle class="now${t.atLowest ? ' at-low' : ''}" cx="${x(last).toFixed(1)}" cy="${y(pts[last].num).toFixed(1)}" r="2.6"/></svg>
+<figcaption>${t.atLowest ? 'Lowest seen' : html`low ${t.lowest.price}`}</figcaption></figure>`;
+}
+
+/** What the people who voted said, and the band it puts the deal in (market outcome 5). */
+function bandOf(v) {
+ const r = v.root;
+ if (r.staff_pick) return ['pick', 'Editors\' pick'];
+ if (v.tally && v.tally.up >= 5) return ['popular', 'Popular'];
+ if (r.created_at && v.now - Number(r.created_at) < 24 * 3_600_000) return ['new', 'New today'];
+ return null;
+}
+function voteLine(v, href) {
+ const t = v.tally || { up: 0, down: 0 };
+ const band = bandOf(v);
+ const said = t.up ? `${t.up} found this useful` : 'Be the first to rate it';
+ return html`<p class="dl-vote"><a href="${href}#vote" class="dl-useful${t.up ? ' has' : ''}" title="${t.up} up, ${t.down} down"><span aria-hidden="true">▲</span> ${said}</a>${band ? html` <span class="dl-band dl-band--${band[0]}">${band[1]}</span>` : ''}</p>`;
+}
+
+/** The expiry a source stated, with how precise it was; never an assumed one (market outcome 3). */
+function expiryLine(r, now) {
+ if (r.status === 'expired') return '';
+ if (r.expires_at == null) return html`<span class="dl-ends dl-ends--unknown">Expiry unknown</span>`;
+ const at = Number(r.expires_at);
+ const left = at - now;
+ if (left <= 0) return html`<span class="dl-ends dl-ends--past">Stated to have ended ${timeEl(at)}</span>`;
+ const soon = left < 48 * 3_600_000;
+ const d = new Date(at);
+ // A stated time at midnight UTC is a date, not a moment: say the day.
+ const day = d.getUTCHours() === 0 && d.getUTCMinutes() === 0;
+ const inWords = left < 90 * 60_000 ? `${Math.max(1, Math.round(left / 60_000))} minutes` : `${Math.round(left / 3_600_000)} hours`;
+ return html`<span class="dl-ends${soon ? ' dl-ends--soon' : ''}">${soon ? `Ends in ${inWords}:` : 'Ends'} <time datetime="${d.toISOString()}">${day ? when(at).slice(0, 10) : when(at)}</time>${day ? ' (a date, no time stated)' : ''}</span>`;
+}
+
+/** When the price was seen and how much sits behind it (market outcome 2), in the card's own words. */
+function seenLine(v) {
+ const o = v.latest;
+ if (!o) return html`<p class="dl-seen"><span class="badge stale" data-freshness="unobserved">No price seen yet</span></p>`;
+ const n = v.trend ? v.trend.count : 1;
+ const f = v.freshness || {};
+ return html`<p class="dl-seen"><span class="dl-dot dl-dot--${f.state === 'fresh' ? 'fresh' : 'stale'}" data-freshness="${f.state || 'unobserved'}" aria-hidden="true"></span>${f.state === 'stale' ? 'Last seen' : 'Price seen'} <time datetime="${new Date(Number(o.observed_at)).toISOString()}" title="${when(Number(o.observed_at))}">${ago(Number(o.observed_at), v.now)}</time>${f.state === 'stale' ? ' — it may be gone' : ''} · ${n} price${n === 1 ? '' : 's'} on record${v.root.deal_reason === 'price_drop' ? ' · lowest so far' : ''}</p>`;
+}
+
+/** The price, the was-price struck through and the saving, shipping and stock (market outcome 1). */
+function priceRow(v) {
+ const o = v.latest;
+ const stated = o && o.price != null;
+ return html`<p class="price-line"><strong class="price${stated ? '' : ' price-none'}" data-price="${stated ? o.price : ''}">${priceText(o)}</strong>${listPriceLine(o)}
+${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : ''}${o && o.availability ? html` <span class="meta">· ${AVAILABILITY_LABEL[o.availability]}</span>` : ''}</p>`;
+}
+
function offerCard(v, urls) {
function offerCard(v, urls) {
const r = v.root;
const r = v.root;
- const score = v.tally.up - v.tally.down;
+ const href = urls.offerPath(r);
return html`<li class="deal-item">
return html`<li class="deal-item">
-<div class="score${score > 0 ? ' up' : score < 0 ? ' down' : ''}" title="${v.tally.up} up, ${v.tally.down} down"><span aria-hidden="true">▲</span>${score > 0 ? `+${score}` : score}</div>
<div class="deal-body">
<div class="deal-body">
-<h2 class="deal-title"><a href="${urls.offerPath(r)}">${r.title}</a> ${statusBadge(r)}</h2>
-${latestLine(v)}
+<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>`}
${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></li>`;
+</div>
+<div class="deal-side">${sparkline(v.trend)}${voteLine(v, href)}</div></li>`;
}
}
function offerList({ heading, intro, views, pager, urls, tabs, empty, lead }) {
function offerList({ heading, intro, views, pager, urls, tabs, empty, lead }) {
@@ -301,7 +369,9 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
@@ -301,7 +369,9 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
</form>`;
</form>`;
}
}
-function watchesPage({ csrf, watches, error, notice, urls }) {
+function watchesPage({ csrf, watches, error, notice, urls, values = {} }) {
+ // Arriving from a shop's "Alert me" (/watches?kind=keyword&query=zappos.com): the form says so, filled in.
+ const kindOpt = (k, label) => html`<option value="${k}"${values.kind === k ? raw(' selected') : ''}>${label}</option>`;
return html`<h1>Watches and saved searches</h1>
return html`<h1>Watches and saved searches</h1>
<p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>
<p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>
${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
@@ -311,9 +381,9 @@ ${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.produc
@@ -311,9 +381,9 @@ ${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.produc
${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}
${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}
<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}
<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}
<form method="post" action="/watches" class="card">${csrfField(csrf)}
<form method="post" action="/watches" class="card">${csrfField(csrf)}
-<h2>Add</h2>
-<label for="w-kind">Kind</label><select id="w-kind" name="kind"><option value="keyword">Keywords (notify)</option><option value="price_below">Price below (notify)</option><option value="product">Product (notify)</option><option value="search">Saved search (no notifications)</option></select>
-<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200">
+<h2>${values.query ? html`Alert me about “${values.query}”` : 'Add'}</h2>
+<label for="w-kind">Kind</label><select id="w-kind" name="kind">${kindOpt('keyword', 'Keywords (notify)')}${kindOpt('price_below', 'Price below (notify)')}${kindOpt('product', 'Product (notify)')}${kindOpt('search', 'Saved search (no notifications)')}</select>
+<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200" value="${values.query || ''}">
<label for="w-p">Product (slug, for product and price watches)</label><input id="w-p" name="product">
<label for="w-p">Product (slug, for product and price watches)</label><input id="w-p" name="product">
<label for="w-max">Price below</label><input id="w-max" name="max_price" inputmode="decimal">
<label for="w-max">Price below</label><input id="w-max" name="max_price" inputmode="decimal">
<label for="w-cur">Currency</label><input id="w-cur" name="currency" maxlength="3" placeholder="USD">
<label for="w-cur">Currency</label><input id="w-cur" name="currency" maxlength="3" placeholder="USD">
@@ -344,10 +414,12 @@ ${duplicates.length ? html`<ul>${duplicates.map((d) => html`<li><a href="${urls.
@@ -344,10 +414,12 @@ ${duplicates.length ? html`<ul>${duplicates.map((d) => html`<li><a href="${urls.
<form class="inline" method="post" action="/mod/offers/${d.b.slug}/merge">${csrfField(csrf)}<input type="hidden" name="into" value="${d.a.slug}"><button type="submit">Merge the newer into the older</button></form></li>`)}</ul>` : html`<p class="empty">None found.</p>`}`;
<form class="inline" method="post" action="/mod/offers/${d.b.slug}/merge">${csrfField(csrf)}<input type="hidden" name="into" value="${d.a.slug}"><button type="submit">Merge the newer into the older</button></form></li>`)}</ul>` : html`<p class="empty">None found.</p>`}`;
}
}
-function storePage({ st, views, pager, urls }) {
+function storePage({ st, views, pager, urls, stats = null }) {
+ const n = (x) => Number(x || 0).toLocaleString('en-US');
return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: st.name || st.domain }]))}
return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: st.name || st.domain }]))}
-<h1>${st.name || st.domain}</h1>
-<p class="lede">Deals at <code>${st.domain}</code>.</p>
+<header class="dl-shop"><span class="dl-shop-mark" aria-hidden="true">${String(st.name || st.domain).replace(/^www\./, '').slice(0, 1).toUpperCase()}</span>
+<div class="dl-shop-t"><h1>${st.name || st.domain}</h1><p class="lede"><a href="https://${st.domain}" rel="nofollow noopener">${st.domain}</a>${stats ? html` · ${n(stats.active)} active deal${Number(stats.active) === 1 ? '' : 's'} · prices seen ${n(stats.seen24)} time${Number(stats.seen24) === 1 ? '' : 's'} in 24 hours` : ''}</p></div>
+<a class="dl-alert" href="/watches?kind=keyword&query=${encodeURIComponent(st.domain)}">Alert me about deals here</a></header>
${views.length ? html`<ol class="deal-list">${views.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No deals from this store.</p>`}
${views.length ? html`<ol class="deal-list">${views.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No deals from this store.</p>`}
${raw(pager ? paginationHtml(pager) : '')}`;
${raw(pager ? paginationHtml(pager) : '')}`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * The deal card and the shop page (market outcomes 1-6): the price with the was-price struck through and the saving;
+ * when the price was seen and how many prices sit behind it; the stated expiry with its precision or "Expiry
+ * unknown"; the price over time as a sparkline once there are two; what the votes count and the band; and a shop's
+ * own line: active deals, prices seen in 24 hours, and an alert prefilled for it.
+ */
+const assert = require('assert');
+const { boot, check, done, HOUR } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const [alice, bob, cara] = ['alice', 'bob', 'cara'].map((n) => t.network.addUser(n));
+ const db = t.ctx.store.db;
+ let a, b;
+ const card = (html, title) => {
+ const i = html.indexOf(`>${title}</a>`);
+ assert.ok(i > 0, `a card for ${title}`);
+ return html.slice(html.lastIndexOf('<li class="deal-item">', i), html.indexOf('</li>', i));
+ };
+
+ await check('one price: the price, when it was seen, one price on record, expiry unknown, no sparkline', async () => {
+ a = await t.submit(alice, { url: 'https://shop.example/headphones', title: 'Studio headphones', price: '49.99', currency: 'USD', availability: 'in_stock' });
+ const c = card((await t.get('/s/shop.example')).text, 'Studio headphones');
+ assert.match(c, /<strong class="price" data-price="49.99">49.99 USD<\/strong>/);
+ assert.match(c, /Price seen <time datetime="[^"]+" title="[^"]+">just now<\/time> · 1 price on record/);
+ assert.match(c, /data-freshness="fresh"/);
+ assert.match(c, /<span class="dl-ends dl-ends--unknown">Expiry unknown<\/span>/, 'never an assumed expiry');
+ assert.doesNotMatch(c, /dl-spark/, 'one price is not a line');
+ assert.match(c, /Be the first to rate it/);
+ assert.match(c, /dl-band--new">New today/);
+ });
+
+ await check('more prices, a stated list price, a stated end, votes: the whole card', async () => {
+ for (const [h, price] of [[2, '45.00'], [4, '39.99']]) {
+ t.clock.advance(h * HOUR);
+ const r = await t.get(`/d/${a.slug}/observe`, { as: bob, form: { price, currency: 'USD', csrf: t.csrf(bob) } });
+ assert.ok([200, 303].includes(r.status), `observe ${r.status}: ${r.text.slice(0, 200)}`);
+ }
+ await db.prepare("UPDATE deal_price_observations SET list_price = '59.99', list_price_num = 59.99 WHERE offer_id = ? AND price = '39.99'").run(a.id);
+ await db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(t.clock.now() + 30 * HOUR, a.id);
+ for (const u of [bob, cara]) await t.get(`/d/${a.slug}/vote`, { as: u, form: { value: 'up', csrf: t.csrf(u) } });
+ const c = card((await t.get('/s/shop.example')).text, 'Studio headphones');
+ assert.match(c, /<s class="dl-list" data-list-price="59.99">59.99 USD<\/s> <span class="dl-saving">−33%<\/span>/, 'the was-price and the saving');
+ assert.match(c, /· 3 prices on record/);
+ assert.match(c, /<figure class="dl-spark" data-points="3"><svg [^>]*role="img" aria-label="Price over 3 observations: 49.99 to 39.99 USD, lowest 39.99">/);
+ assert.match(c, /<figcaption>Lowest seen<\/figcaption>/, 'the latest is the lowest');
+ assert.match(c, /<span class="dl-ends dl-ends--soon">Ends in 30 hours: <time datetime=/, 'a stated end, soon');
+ assert.match(c, /2 found this useful/, 'the vote says what it counts');
+ t.clock.advance(25 * HOUR);
+ await db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(Date.UTC(2027, 0, 15), a.id);
+ const later = card((await t.get('/s/shop.example')).text, 'Studio headphones');
+ assert.match(later, /Ends <time datetime="2027-01-15T00:00:00.000Z">2027-01-15<\/time> \(a date, no time stated\)/, 'a date is said as a date');
+ assert.doesNotMatch(later, /New today/);
+ });
+
+ await check('the band of an editors\' pick', async () => {
+ b = await t.submit(alice, { url: 'https://shop.example/lamp', title: 'Desk lamp', price: '20.00', currency: 'USD' });
+ await db.prepare('UPDATE deal_offers SET staff_pick = true WHERE id = ?').run(b.id);
+ assert.match(card((await t.get('/s/shop.example')).text, 'Desk lamp'), /dl-band--pick">Editors' pick|dl-band--pick">Editors' pick/);
+ });
+
+ await check('the shop: its line, its active deals, prices seen in 24 hours, an alert prefilled for it', async () => {
+ const r = await t.get('/s/shop.example');
+ assert.match(r.text, /<header class="dl-shop">/);
+ assert.match(r.text, /<a href="https:\/\/shop\.example" rel="nofollow noopener">shop\.example<\/a> · 2 active deals · prices seen \d+ times? in 24 hours/);
+ assert.match(r.text, /<a class="dl-alert" href="\/watches\?kind=keyword&query=shop\.example">Alert me about deals here<\/a>/);
+ const w = await t.get('/watches?kind=keyword&query=shop.example', { as: alice });
+ assert.match(w.text, /<h2>Alert me about “shop\.example”<\/h2>/);
+ assert.match(w.text, /<option value="keyword" selected>/);
+ assert.match(w.text, /<input id="w-q" name="query" maxlength="200" value="shop\.example">/);
+ const odd = await t.get('/watches?kind=evil&query=%3Cb%3Ex%3C%2Fb%3E', { as: alice });
+ assert.match(odd.text, /<option value="keyword" selected>/, 'an unknown kind is a keyword watch');
+ assert.ok(!odd.text.includes('<b>x</b>'), 'the words are text');
+ });
+
+ await done(t);
+})();