diff --git a/audit.js b/audit.js
index 44c5908..597c6d8 100644
--- a/audit.js
+++ b/audit.js
@@ -29,7 +29,7 @@ async function run() {
const rows = await db.q('SELECT campaign_id, SUM(n) n FROM camp_hours GROUP BY campaign_id'); const h = {}; for (const r of rows) h[r.campaign_id] = Number(r.n);
const issues = [];
// only campaigns that started after the hourly log did
- for (const c of camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs)) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > 2) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' in the hourly log'); }
+ for (const c of camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs)) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > Math.max(5, Math.round(0.02 * Math.max(Number(c.imps), l)))) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' in the hourly log'); }
add('banner/text views vs hourly log', issues, camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs).length + ' campaigns compared (started since the hourly log began ' + (logStart || '') + ')');
}
// 3. login: days charged vs days shown (a day may only be charged after the ad was shown)
@@ -58,11 +58,14 @@ async function run() {
}
return out;
}
-let lastAlertDay = '';
+// the "once a day" memory lives on the volume: a redeploy restarts the process, and Marty got an alert after every deploy (2026-09-15)
+const STATE = () => path.join(R.dataDir, 'audit-state.json');
+function lastAlertDay() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')).lastAlertDay || ''; } catch (e) { return ''; } }
+function setAlertDay(d) { try { fs.writeFileSync(STATE(), JSON.stringify({ lastAlertDay: d })); } catch (e) {} }
async function dailyTick() {
try {
const r = await run(); const bad = r.checks.filter(c => !c.ok); const day = new Date().toISOString().slice(0, 10);
- if (bad.length && lastAlertDay !== day && R && R.notify) { lastAlertDay = day; R.notify('⚠️ InstantAdPay counter audit found ' + bad.length + ' issue' + (bad.length === 1 ? '' : 's') + ': ' + bad.map(c => c.name + ' (' + c.issues.length + ')').join('; ') + '. Admin > Reports > Counters audit.'); }
+ if (bad.length && lastAlertDay() !== day && R && R.notify) { setAlertDay(day); R.notify('⚠️ InstantAdPay counter audit found ' + bad.length + ' issue' + (bad.length === 1 ? '' : 's') + ': ' + bad.map(c => c.name + ' (' + c.issues.length + ')').join('; ') + '. Admin > Reports > Counters audit.'); }
return r;
} catch (e) { console.error('audit', e.message); return null; }
}
diff --git a/public/assets/my.js b/public/assets/my.js
index 6442fae..48d7c39 100644
--- a/public/assets/my.js
+++ b/public/assets/my.js
@@ -809,7 +809,7 @@
+ '
' + c.type + (c.type === 'banner' && c.width ? ' ' + c.width + '×' + c.height + '' : '') + (c.dailyCap ? ' cap ' + c.dailyCap + '/day' : '') + (c.geo ? ' tier ' + esc(c.geo.replace(/,/g, '+')) + '' : '') + schedChips(c) + ' | '
+ '' + c.imps.toLocaleString() + ' | '
+ '' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : 'n/a') + ' | '
- + '' + c.clicks + (r.clickSources && r.clickSources[c.id] ? ' ' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => esc(k) + ' ' + v).join(' · ') + (c.impsNas ? ' · network: see Network views' : '') + ' ' : '') + ' | '
+ + '' + c.clicks + (r.clickSources && r.clickSources[c.id] ? ' ' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => '' + esc(k) + ' ' + v + '').join('') + (c.impsNas ? ' · network: see Network views' : '') + ' ' : '') + ' | '
+ '' + c.spent + ' | ' + c.budget + ' | '
+ '' + (c.status === 'out' ? 'budget spent' : c.status === 'done' ? 'ended' : c.scheduled ? 'scheduled' : c.status) + ' | '
+ '' + (c.status === 'active' ? ''
diff --git a/public/assets/site.css b/public/assets/site.css
index 62a8d2a..a8b8d40 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -713,8 +713,10 @@ img{max-width:100%}
.tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)}
.tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)}
.tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px}
-/* campaigns list on phones: each row becomes a card, buttons at the bottom, no sideways scroll (Jim, 2026-09-15) */
-@media (max-width:720px){
+/* click sources stack one per line so the Clicks column stays narrow (Marty, 2026-09-15) */
+.clk-src span{display:block;white-space:nowrap} .camp-table td{white-space:normal} .camp-table td:first-child{max-width:260px}
+/* campaigns list on phones and tablets: each row becomes a card, buttons at the bottom, no sideways scroll (Jim, 2026-09-15) */
+@media (max-width:900px){
.tablewrap:has(.camp-table){overflow:visible}
.camp-table{display:block;width:100%;border-collapse:separate}
.camp-table thead{display:none}
diff --git a/public/my.html b/public/my.html
index ad81352..e3d9767 100644
--- a/public/my.html
+++ b/public/my.html
@@ -5,7 +5,7 @@
Member area | InstantAdPay
-
+
@@ -984,7 +984,7 @@
-
+
|