diff --git a/public/app.js b/public/app.js
index 82655a5..333c9d9 100644
--- a/public/app.js
+++ b/public/app.js
@@ -75,16 +75,17 @@ function go(tab) {
if(formSaving){notice('Your event is saving. Please wait.');return;}
const target = tab === 'pending' ? 'events' : tab;
if (tab === 'events' || tab === 'pending') { listFilter = tab === 'pending' ? 'pending' : 'all'; renderEvents(); }
- if (tab === 'admin' && !state.user?.isSuperAdmin) return;
+ if (['admin','admin-analytics'].includes(tab) && !state.user?.isSuperAdmin) return;
if(target!=='gallery')dismissPendingMedia?.();
dismissTicket();
pageNavigationGeneration++;currentView=target;updateAppClose();
- for (const name of ['home','events', 'create', 'settings', 'admin', 'gallery', 'explore']) $(name + '-view').hidden = name !== target;
+ for (const name of ['home','events', 'create', 'settings', 'admin', 'admin-analytics', 'gallery', 'explore']) $(name + '-view').hidden = name !== target;
if(tab==='home'){renderHome();loadHomeSuggestions();}
if (tab === 'explore') loadExplore();
if (tab === 'admin') loadAdmin();
+ if (tab === 'admin-analytics') loadAdminAnalytics();
for (const button of document.querySelectorAll('[data-tab]')) {
- if (button.dataset.tab === (tab==='admin'?'settings':tab==='pending'?'events':tab)) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
+ if (button.dataset.tab === (['admin','admin-analytics'].includes(tab)?'settings':tab==='pending'?'events':tab)) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
}
window.scrollTo(0, 0);
}
@@ -743,6 +744,58 @@ async function loadExplore() {
$('explore-refresh').onclick=loadExplore;
function filterExplore(){const text=$('explore-search').value.trim().toLowerCase();let count=0;for(const card of $('explore-list').children)if(card.dataset.searchText!==undefined){card.hidden=!card.dataset.searchText.includes(text);if(!card.hidden)count++;}$('explore-zone').textContent='Public events in '+selectedZone().replaceAll('_',' ')+(text ? ' · '+count+' matches':'');}
$('explore-search').oninput=filterExplore;
+let analyticsSequence = 0;
+function renderActivityChart(container, daily, metric, label) {
+ const svgNode = (tag, attributes = {}, text) => {
+ const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));
+ if (text !== undefined) node.textContent = text;
+ return node;
+ };
+ container.replaceChildren();
+ const maximum = Math.max(1, ...daily.map(day => day[metric]));
+ const chart = svgNode('svg', { viewBox: '0 0 360 190', role: 'img', 'aria-label': label + ' by day; exact values are in Daily counts.', class: 'analytics-chart' });
+ chart.append(svgNode('title', {}, label + ' by day'));
+ const left = 32, width = 316, baseline = 155, height = 130, step = width / daily.length;
+ for (const fraction of [0, 1]) {
+ const y = baseline - fraction * height;
+ chart.append(svgNode('line', { x1: left, y1: y, x2: left + width, y2: y, class: 'analytics-grid' }), svgNode('text', { x: left - 7, y: y + 4, 'text-anchor': 'end', class: 'analytics-axis' }, String(fraction * maximum)));
+ }
+ daily.forEach((day, index) => {
+ const barHeight = day[metric] / maximum * height;
+ const bar = svgNode('rect', { x: left + index * step + step * .12, y: baseline - barHeight, width: step * .76, height: barHeight, rx: Math.min(2, step * .15), class: 'analytics-bar analytics-' + metric });
+ bar.append(svgNode('title', {}, `${day.date}: ${day[metric]} ${label.toLowerCase()}`));chart.append(bar);
+ });
+ for (const [index, anchor] of [[0, 'start'], [daily.length - 1, 'end']]) chart.append(svgNode('text', { x: index ? left + width : left, y: 180, 'text-anchor': anchor, class: 'analytics-axis' }, daily[index].date.slice(5)));
+ container.append(chart);
+ if (!daily.some(day => day[metric])) container.append(element('p', 'No activity in this date range.', 'small muted'));
+}
+async function loadAdminAnalytics() {
+ if (!state.user?.isSuperAdmin) return;
+ const sequence = ++analyticsSequence, zone = selectedZone();
+ $('analytics-refresh').disabled = true;$('analytics-error').hidden = true;$('analytics-content').hidden = true;
+ $('analytics-status').textContent = 'Loading daily activity…';$('analytics-zone').textContent = 'Timezone: ' + zone.replaceAll('_', ' ');
+ try {
+ const data = await api('admin/analytics?days=' + encodeURIComponent($('analytics-range').value) + '&timezone=' + encodeURIComponent(zone));
+ if (sequence !== analyticsSequence) return;
+ $('analytics-total-users').textContent = new Intl.NumberFormat().format(data.totalUsers);
+ $('analytics-summary').textContent = `${data.totals.users} new users · ${data.totals.events} events created · ${data.days} days`;
+ renderActivityChart($('analytics-users-chart'), data.daily, 'users', 'New users');
+ renderActivityChart($('analytics-events-chart'), data.daily, 'events', 'Events created');
+ const table = $('analytics-daily');table.replaceChildren();
+ for (const day of [...data.daily].reverse()) {
+ const row = element('tr'), date = element('th', day.date);date.scope = 'row';
+ row.append(date, element('td', String(day.users)), element('td', String(day.events)));table.append(row);
+ }
+ $('analytics-note').textContent = 'Today is partial. Counts use recorded first-seen and creation dates. Older users without recorded dates and deleted events are not included.' + (data.undated.users || data.undated.events ? ` Undated records excluded: ${data.undated.users} users, ${data.undated.events} events.` : '');
+ $('analytics-content').hidden = false;$('analytics-status').textContent = '';
+ } catch (error) {
+ if (sequence !== analyticsSequence) return;
+ $('analytics-error').textContent = error.message;$('analytics-error').hidden = false;$('analytics-status').textContent = '';
+ } finally { if (sequence === analyticsSequence) $('analytics-refresh').disabled = false; }
+}
+$('analytics-refresh').onclick = loadAdminAnalytics;
+$('analytics-range').onchange = loadAdminAnalytics;
async function loadAdmin() {
$('admin-refresh').disabled = true; $('admin-error').hidden = true;
const results=await Promise.allSettled([api('admin/overview'),loadAdminWhitelist()]);
diff --git a/public/index.html b/public/index.html
index 4846644..fed136d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -80,7 +80,23 @@
IT’S A DATEYour event is ready.
+
+ SUPER ADMINDaily activity.
+
+
+
+
+
+
ALL TIMETotal users
All known users, including older accounts without a recorded join date.
+
+
New users
People first seen by XEvents each day.
+
Events created
Creation date, including cancelled events still saved.
+
Daily counts
Daily new users and events created| Date | Users | Events |
|---|
+
+
+
SUPER ADMINAll plans.
All people.
Browse every event and everyone who has used XEvents.
+
🔒 Dev access
diff --git a/public/style.css b/public/style.css
index bd4c233..e1b8fd2 100644
--- a/public/style.css
+++ b/public/style.css
@@ -85,3 +85,5 @@ body.dark .guest-roster-row[data-status=yes]{background:#203c2e}body.dark .guest
@media(max-width:480px){.brand .wordmark-note{display:none}.brand>a{font-size:18px;gap:8px}.brand .header-profile{max-width:112px}}@media(max-width:360px){.brand{gap:6px}.brand .header-profile{max-width:96px}.brand #header-name{font-size:12px}.guest-roster-row{padding:10px}.guest-roster-more{top:8px;right:8px}}
.edit-checkin-panel{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;margin:0 0 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.edit-checkin-panel button{margin:0;min-height:44px;font-size:13px}.ticket-code-label{text-align:center;margin:18px 0 8px}#ticket-code{font-size:clamp(32px,8vw,42px);letter-spacing:6px;font-weight:750;font-variant-numeric:tabular-nums;line-height:1.5;overflow-wrap:anywhere}#ticket-countdown{text-align:center;min-height:20px;margin:6px 0 16px;font-variant-numeric:tabular-nums}.ticket-code-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin-top:12px}.ticket-code-actions button{min-width:0;min-height:46px}.ticket-code-actions button:disabled{cursor:not-allowed}#ticket-message{font-size:13px;line-height:1.5;margin:14px 0 8px}#checkin-event{margin:0 0 8px;overflow-wrap:anywhere}#checkin-scan{display:block;width:100%;margin:0 0 14px;min-height:44px}.checkin-code-entry{position:relative}.checkin-code-cells{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px}.checkin-code-cells span{display:grid;place-items:center;min-height:54px;background:var(--bg);border:1px solid var(--line);border-radius:9px;font-size:28px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}.checkin-code-cells .current{border-color:var(--accent)}#checkin-code{position:absolute;inset:0;width:100%;height:100%;min-height:54px;opacity:0;cursor:text}.checkin-code-entry:focus-within{outline:2px solid var(--accent);outline-offset:4px;border-radius:9px}#checkin-help{font-size:12px;margin:10px 0 14px;line-height:1.5}.checkin-keypad{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.checkin-keypad button{min-width:0;min-height:48px;margin:0;padding:10px;font-size:20px;line-height:1.2;background:var(--bg);font-variant-numeric:tabular-nums}.checkin-keypad button:nth-child(10){font-size:14px}.checkin-keypad button:focus-visible,#checkin-check:focus-visible{outline:2px solid var(--accent);outline-offset:3px}#checkin-check{margin-top:12px;min-height:48px}#checkin-result:not(:empty){margin:14px 0 0;padding:12px;border-radius:10px;font-size:14px;line-height:1.5}#checkin-result.checkin-success{background:#e8f1e9;color:#186449}#checkin-result.checkin-warning{background:#fff4de;color:#75541c}body.dark #checkin-result.checkin-success{background:#264b3b;color:#c4ecd5}body.dark #checkin-result.checkin-warning{background:#3e3323;color:#f6deac}#checkin-result:empty{display:none}@media(max-width:360px){.checkin-code-cells{gap:4px}.checkin-code-cells span{min-height:48px;font-size:25px}.checkin-keypad{gap:6px}.checkin-keypad button{min-height:46px}.edit-checkin-panel{padding:10px;gap:8px}.edit-checkin-panel>span{font-size:11px}.edit-checkin-panel button{padding:10px;font-size:12px}}
+.analytics-panel{padding:18px;overflow:hidden}.analytics-panel h2{margin-bottom:4px}.analytics-panel>p{margin-bottom:8px}.analytics-chart{display:block;width:100%;height:auto;overflow:visible}.analytics-axis{fill:var(--muted);font:11px system-ui,sans-serif}.analytics-grid{stroke:var(--line);stroke-width:1}.analytics-users{fill:#126b5b}.analytics-events{fill:#7350a3}body.dark .analytics-users{fill:#70d1ac}body.dark .analytics-events{fill:#c4a0f3}.analytics-table-wrap{overflow-x:auto}.analytics-table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}.analytics-table caption{text-align:left;padding:14px 0;color:var(--muted);font-size:12px}.analytics-table th,.analytics-table td{padding:10px 6px;border-bottom:1px solid var(--line);text-align:right}.analytics-table th:first-child{text-align:left}.analytics-table tbody th{font-weight:500}#analytics-status:empty{display:none}#analytics-range{max-width:240px}#admin-analytics-view button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+.analytics-total strong{display:block;font-size:36px;font-variant-numeric:tabular-nums;line-height:1.3;margin:6px 0}.analytics-total>p{margin-bottom:0}
diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs
index e0f8f3e..7d445f6 100644
--- a/scripts/check-worker.mjs
+++ b/scripts/check-worker.mjs
@@ -65,12 +65,26 @@ async function checkTestAccess() {
assert.equal((await worker.dispatchFetch('https://access.test/api/bootstrap')).status,401);
assert.equal((await api('bootstrap',seedUser)).status,200);
assert.equal((await api('bootstrap',admin)).status,200,'The configured admin must remain able to manage testers.');
+ assert.equal((await api('admin/analytics',seedUser)).status,403,'Analytics must require server-side super admin access.');
+ assert.equal((await worker.dispatchFetch('https://access.test/api/admin/analytics')).status,401);
+ assert.equal((await api('admin/analytics?days=365',admin)).status,400);
+ assert.equal((await api('admin/analytics?timezone=Bad%2FZone',admin)).status,400);
+ const analyticsResponse=await api('admin/analytics?days=7&timezone=UTC',admin);
+ assert.equal(analyticsResponse.status,200);assert.equal(analyticsResponse.headers.get('Cache-Control'),'no-store');
+ const analytics=await analyticsResponse.json();
+ assert.equal(analytics.daily.length,7);assert.equal(analytics.totals.users,2);
+ assert.equal(analytics.totalUsers,2);
+ assert.doesNotMatch(JSON.stringify(analytics),/Access tester|firstName|username|phone|"id"/);
// Hold delivery so command retries can be checked against the exact durable outbox.
await db.prepare("INSERT INTO delivery_lease(id,owner,expires) VALUES (1,'access-fixture',unixepoch()+3600)").run();
const eventId='7100000000000001',inviteToken='71000000000000000000000000000001',cohostToken='71000000000000000000000000000002',mediaId='710000000001';
const event={id:eventId,owner:admin,title:'Private access fixture',when:'Future test event',startsAt:'2099-12-05T18:00:00Z',timezone:'UTC',location:'Fictional venue',description:'',invitationMode:'named',oneTimeInvite:true,guests:{[blockedUser]:{name:'Existing tester',status:'yes',invitationToken:inviteToken}},invitees:{[inviteToken]:{name:'Named tester',participants:1,participantMode:'preset'}},permissions:{guestList:true,uploadMedia:true,viewMedia:true},banner:'access-banner',media:[{id:mediaId,fileId:'access-image',type:'photo',name:'fictional.jpg',user:blockedUser}],cohostLinks:[{id:'7100000000000002',token:cohostToken,label:'Test co-host',status:'pending',cohost:null,createdAt:'2026-01-01T00:00:00Z'}]};
await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(eventId,JSON.stringify(event)).run();
await db.prepare("INSERT INTO records(kind,id,data) VALUES ('preferences',?,?)").bind(String(blockedUser),JSON.stringify({profilePhoto:'access-profile',timezone:'UTC'})).run();
+ const beforeAnalytics=await records();
+ const totalsByRange=await Promise.all([7,30,90].map(async days=>(await (await api('admin/analytics?days='+days,admin)).json()).totalUsers));
+ assert.deepEqual(totalsByRange,[3,3,3],'All-time total includes legacy owners/guests and is independent of date range.');
+ assert.deepEqual(await records(),beforeAnalytics,'Analytics must not mutate user/event records.');
const beforeDenied=await records(),beforeCalls=calls.length;
for(const [path,method,input] of [
['bootstrap','GET'],['explore?timezone=UTC','GET'],['profile/photo','GET'],['profile/photo','DELETE'],['profile/photo','POST',{}],['branding/icon','GET'],
diff --git a/src/admin.js b/src/admin.js
index 151cacb..ab21646 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -1,4 +1,6 @@
import { eventGroup } from './reminders.js';
+import { Temporal } from '@js-temporal/polyfill';
+import { timezone, InputError } from './time.js';
import { permissions, responseCounts, participantCount,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
export const isSuperAdmin = (user, env = {}) => {
@@ -15,6 +17,35 @@ export async function rememberUser(env, user) {
await env.DB.prepare("INSERT INTO records(kind,id,data) VALUES ('users',?,?) ON CONFLICT(kind,id) DO UPDATE SET data=json_set(excluded.data,'$.firstSeen',coalesce(json_extract(records.data,'$.firstSeen'),json_extract(excluded.data,'$.firstSeen')))").bind(String(user.id), JSON.stringify(profile)).run();
}
+// Match the admin directory, including users from before first-seen tracking.
+export function adminUserCount(rows) {
+ return new Set(rows.map(row => Number(row.id)).filter(id => Number.isSafeInteger(id) && id > 0)).size;
+}
+
+// Read-only counts: never infer a registration date from a guest's RSVP or last visit.
+export function adminAnalytics(rows, { days = '30', zone = 'UTC', now = Date.now() } = {}) {
+ days = Number(days);
+ if (![7, 30, 90].includes(days)) throw new InputError('Choose 7, 30 or 90 days.');
+ zone = timezone(zone);
+ const today = Temporal.Instant.fromEpochMilliseconds(now).toZonedDateTimeISO(zone).toPlainDate();
+ const start = today.subtract({ days: days - 1 });
+ const daily = Array.from({ length: days }, (_, i) => ({ date: start.add({ days: i }).toString(), users: 0, events: 0 }));
+ const buckets = new Map(daily.map(day => [day.date, day]));
+ const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' });
+ const undated = { users: 0, events: 0 };
+ for (const row of rows) {
+ const key = row.kind === 'users' ? 'users' : row.kind === 'events' ? 'events' : null;
+ if (!key) continue;
+ const stamp = typeof row.timestamp === 'string' && row.timestamp.trim() ? Date.parse(row.timestamp) : NaN;
+ if (!Number.isFinite(stamp)) { undated[key]++; continue; }
+ if (stamp > now) continue;
+ const parts = Object.fromEntries(formatter.formatToParts(new Date(stamp)).map(p => [p.type, p.value]));
+ const bucket = buckets.get(`${parts.year}-${parts.month}-${parts.day}`);
+ if (bucket) bucket[key]++;
+ }
+ return { timezone: zone, days, daily, totals: daily.reduce((sum, day) => ({ users: sum.users + day.users, events: sum.events + day.events }), { users: 0, events: 0 }), undated };
+}
+
export function adminOverview(rows) {
const users = new Map();
const ensure = uid => { const id = Number(uid); if (!Number.isSafeInteger(id) || id <= 0) return null; if (!users.has(id)) users.set(id, { id, firstName: '', lastName: '', username: '', timezone: '', firstSeen: null, lastSeen: null, names: [], organised: [], invited: [] }); return users.get(id); };
diff --git a/src/mini-api.js b/src/mini-api.js
index e374850..edb4951 100644
--- a/src/mini-api.js
+++ b/src/mini-api.js
@@ -11,7 +11,7 @@ import { mayUseTestApp, testAccessMessage, TestAccessError, whitelistStatus, app
import { parsePricing, currencyCodes, localCurrency } from './pricing.js';
import { mediaApi } from './media-api.js';
import { shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
-import { isSuperAdmin, rememberUser, adminOverview } from './admin.js';
+import { isSuperAdmin, rememberUser, adminOverview, adminAnalytics, adminUserCount } from './admin.js';
import { schedule, timezone, InputError } from './time.js';
import { mutateState, BusyError } from './worker-store.js';
import { randomBytes } from 'node:crypto';
@@ -161,6 +161,22 @@ async function miniApiForUser(request, env, user) {
if (mediaResponse) return mediaResponse;
if (path.startsWith('/api/admin')) {
if (!isSuperAdmin(user, env)) return respond({ error: 'Super admin access required.' }, 403);
+ if (path === '/api/admin/analytics' && request.method === 'GET') {
+ const query = new URL(request.url).searchParams;
+ // Validate before querying, and project timestamps only (no guest or media data).
+ try {
+ const options = { days: query.get('days') ?? '30', zone: query.get('timezone') ?? 'UTC' };
+ adminAnalytics([], options);
+ const [activity, users] = await env.DB.batch([
+ env.DB.prepare("SELECT kind, CASE WHEN kind='users' THEN json_extract(data,'$.firstSeen') ELSE json_extract(data,'$.createdAt') END AS timestamp FROM records WHERE kind IN ('users','events')"),
+ env.DB.prepare("SELECT id FROM records WHERE kind IN ('users','preferences','sessions') UNION SELECT json_extract(data,'$.owner') AS id FROM records WHERE kind='events' UNION SELECT guest.key AS id FROM records, json_each(records.data,'$.guests') AS guest WHERE records.kind='events'")
+ ]);
+ return respond({ ...adminAnalytics(activity.results, options), totalUsers: adminUserCount(users.results) });
+ } catch (error) {
+ if (!(error instanceof InputError)) throw error;
+ return respond({ error: error.message }, 400);
+ }
+ }
if (path === '/api/admin/whitelist') {
if (request.method === 'GET') return respond(await whitelistStatus(env));
if (request.method === 'POST') {
diff --git a/test/admin.test.js b/test/admin.test.js
index 3ee3eee..638d44c 100644
--- a/test/admin.test.js
+++ b/test/admin.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { adminOverview, isSuperAdmin } from '../src/admin.js';
+import { adminOverview, isSuperAdmin, adminAnalytics, adminUserCount } from '../src/admin.js';
test('super admin is identified by verified numeric Telegram ID only',()=>{
assert.equal(isSuperAdmin({id:999001},{SUPER_ADMIN_ID:'999001\n'}),true);
@@ -22,3 +22,41 @@ test('admin user directory includes legacy owners, guests, preferences and conve
assert.equal(data.events[0].guests[0].phone,'12345');
assert.doesNotMatch(JSON.stringify(data),/SECRET|token|draft/);
});
+
+test('daily analytics uses first-seen/creation dates and zero fills local calendar days across DST', () => {
+ const now = Date.parse('2026-10-08T02:00:00Z');
+ const rows = [
+ {kind:'users',timestamp:'2026-10-03T15:30:00Z'}, // Oct 4 in Sydney, before DST jump
+ {kind:'users',timestamp:'2026-10-03T16:30:00Z'}, // Same day, after jump
+ {kind:'events',timestamp:'2026-10-07T13:30:00Z'}, // Oct 8 locally
+ {kind:'events',timestamp:'2026-10-08T01:00:00Z'}, // Cancelled retained events count too
+ {kind:'users',timestamp:null}, {kind:'events',timestamp:'invalid'},
+ {kind:'users',timestamp:'2026-10-09T00:00:00Z'}, // Future records ignored
+ {kind:'users',timestamp:'2020-01-01T00:00:00Z'},
+ {kind:'sessions',timestamp:'2026-10-08T00:00:00Z'}
+ ];
+ const data = adminAnalytics(rows, {days:'7',zone:'Australia/Sydney',now});
+ assert.equal(data.daily.length,7);
+ assert.equal(data.daily[0].date,'2026-10-02');
+ assert.equal(data.daily.at(-1).date,'2026-10-08');
+ assert.equal(data.daily.find(d=>d.date==='2026-10-04').users,2);
+ assert.equal(data.daily.find(d=>d.date==='2026-10-05').users,0);
+ assert.equal(data.daily.at(-1).events,2);
+ assert.deepEqual(data.totals,{users:2,events:2});
+ assert.deepEqual(data.undated,{users:1,events:1});
+ assert.notDeepEqual(adminAnalytics(rows,{days:7,zone:'UTC',now}).daily,data.daily);
+});
+
+test('analytics validates bounded ranges and timezone and returns aggregate data only', () => {
+ for (const days of ['0','365','7junk',null,'']) assert.throws(()=>adminAnalytics([],{days}));
+ assert.throws(()=>adminAnalytics([],{zone:'Bad/Timezone'}));
+ const data=adminAnalytics([{kind:'users',timestamp:'2026-01-01T00:00:00Z',id:123,name:'Private name',phone:'private-phone'}],{days:90,now:Date.parse('2026-01-01T12:00:00Z')});
+ assert.equal(data.daily.length,90);assert.equal(data.totals.users,1);
+ assert.doesNotMatch(JSON.stringify(data),/Private name|private-phone|"id"/);
+ assert.equal(adminAnalytics([]).daily.length,30);
+});
+
+test('total users includes legacy users, deduplicates sources and ignores non-user settings', () => {
+ assert.equal(adminUserCount([{id:1},{id:'1'},{id:'2'},{id:'3'},{id:'_pricing'},{id:null},{id:0},{id:-1},{id:'bad'},{id:'9999999999999999999999'}]),3);
+ assert.equal(adminUserCount([]),0);
+});
diff --git a/test/explore-ux.test.js b/test/explore-ux.test.js
index ea83f01..285e21a 100644
--- a/test/explore-ux.test.js
+++ b/test/explore-ux.test.js
@@ -13,7 +13,7 @@ function exploreHarness(){
let zone='Australia/Sydney';
const api=path=>new Promise((resolve,reject)=>requests.push({path,resolve,reject}));
const source=fs.readFileSync(new URL('../public/app.js',import.meta.url),'utf8');
- const start=source.indexOf('let exploreSequence='),end=source.indexOf('async function loadAdmin()',start);
+ const start=source.indexOf('let exploreSequence='),end=source.indexOf('let analyticsSequence',start);
assert.ok(start>=0 && end>start,'Explore implementation must be present.');
const loadExplore=new Function('$','selectedZone','api','element','priceTag','format','priceEstimate','action','openTelegram',source.slice(start,end)+'\nreturn loadExplore;')($,()=>zone,api,(tag,text)=>new El(tag,text),()=>new El('span','Free'),()=> 'Local event time',()=>'',label=>new El('button',label),()=>{});
return {$,requests,loadExplore,setZone:value=>{zone=value;}};