diff --git a/backend/src/config/redis.js b/backend/src/config/redis.js new file mode 100644 index 00000000..16d2c698 --- /dev/null +++ b/backend/src/config/redis.js @@ -0,0 +1,92 @@ +// @ts-check +import Redis from 'ioredis'; + +/** + * Build a Redis client supporting standalone, Sentinel, and Cluster topologies. + * + * Environment variables: + * - REDIS_URL / REDIS_HOST — standalone connection string + * - REDIS_SENTINEL_HOSTS — comma-separated host:port sentinel endpoints + * - REDIS_SENTINEL_NAME — sentinel master name (required with SENTINEL_HOSTS) + * - REDIS_SENTINEL_PASSWORD — optional sentinel auth password + * - REDIS_CLUSTER_NODES — comma-separated host:port cluster seed nodes + * - REDIS_PASSWORD — optional Redis auth password (all modes) + * + * Sentinel and Cluster modes give automatic failover when a node fails; + * ioredis reconnects to the promoted primary without application changes. + * + * @param {object} [options] + * @param {string} [options.redisUrl] Override REDIS_URL + * @param {string} [options.sentinelHosts] Override REDIS_SENTINEL_HOSTS + * @param {string} [options.sentinelName] Override REDIS_SENTINEL_NAME + * @param {string} [options.sentinelPassword] Override REDIS_SENTINEL_PASSWORD + * @param {string} [options.clusterNodes] Override REDIS_CLUSTER_NODES + * @param {string} [options.password] Override REDIS_PASSWORD + * @param {object} [options.commonOptions] Extra ioredis options + * @returns {{ mode: 'sentinel'|'cluster'|'standalone'|'disabled', client: import('ioredis').Redis | import('ioredis').Redis.Cluster | null }} + */ +export function createRedisClient(options = {}) { + const sentinelHosts = options.sentinelHosts ?? process.env.REDIS_SENTINEL_HOSTS; + const clusterNodes = options.clusterNodes ?? process.env.REDIS_CLUSTER_NODES; + const password = options.password ?? process.env.REDIS_PASSWORD; + const commonOptions = { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: false, + ...(password ? { password } : {}), + ...options.commonOptions, + }; + + if (sentinelHosts) { + const name = options.sentinelName ?? process.env.REDIS_SENTINEL_NAME; + if (!name) { + throw new Error('REDIS_SENTINEL_NAME is required when REDIS_SENTINEL_HOSTS is set'); + } + const sentinels = parseHostList(sentinelHosts); + const sentinelPassword = + options.sentinelPassword ?? process.env.REDIS_SENTINEL_PASSWORD ?? undefined; + const client = new Redis({ + ...commonOptions, + sentinels, + name, + ...(sentinelPassword ? { sentinelPassword } : {}), + }); + return { mode: 'sentinel', client }; + } + + if (clusterNodes) { + const nodes = parseHostList(clusterNodes); + const client = new Redis.Cluster(nodes, { + redisOptions: commonOptions, + clusterRetryStrategy: (times) => Math.min(times * 200, 5_000), + }); + return { mode: 'cluster', client }; + } + + const redisUrl = options.redisUrl ?? process.env.REDIS_URL ?? process.env.REDIS_HOST; + if (!redisUrl) { + return { mode: 'disabled', client: null }; + } + + const client = new Redis(redisUrl, commonOptions); + return { mode: 'standalone', client }; +} + +/** + * Parse a comma-separated "host:port" list into ioredis node descriptors. + * @param {string} value + * @returns {{ host: string, port: number }[]} + */ +export function parseHostList(value) { + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [host, port] = entry.split(':'); + if (!host || !port) { + throw new Error(`Invalid Redis host entry "${entry}" — expected host:port`); + } + return { host, port: Number(port) }; + }); +} diff --git a/backend/src/config/redis.test.js b/backend/src/config/redis.test.js new file mode 100644 index 00000000..cc10ed65 --- /dev/null +++ b/backend/src/config/redis.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRedisClient, parseHostList } from './redis.js'; + +test('parseHostList: parses comma-separated host:port entries', () => { + const nodes = parseHostList('a.example:26379, b.example:26380'); + assert.deepEqual(nodes, [ + { host: 'a.example', port: 26379 }, + { host: 'b.example', port: 26380 }, + ]); +}); + +test('parseHostList: rejects entries missing host or port', () => { + assert.throws(() => parseHostList('nocolon'), /Invalid Redis host entry/); + assert.throws(() => parseHostList(':6379'), /Invalid Redis host entry/); +}); + +test('createRedisClient: returns disabled when no config is present', () => { + const saved = { + REDIS_URL: process.env.REDIS_URL, + REDIS_HOST: process.env.REDIS_HOST, + REDIS_SENTINEL_HOSTS: process.env.REDIS_SENTINEL_HOSTS, + REDIS_CLUSTER_NODES: process.env.REDIS_CLUSTER_NODES, + }; + delete process.env.REDIS_URL; + delete process.env.REDIS_HOST; + delete process.env.REDIS_SENTINEL_HOSTS; + delete process.env.REDIS_CLUSTER_NODES; + try { + const result = createRedisClient(); + assert.equal(result.client, null); + assert.equal(result.mode, 'disabled'); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test('createRedisClient: throws when sentinel hosts are set without a master name', () => { + assert.throws( + () => + createRedisClient({ + sentinelHosts: 's1:26379', + sentinelName: undefined, + }), + /REDIS_SENTINEL_NAME is required/, + ); +}); + +test('createRedisClient: builds a Sentinel client when hosts and name are provided', () => { + const { mode, client } = createRedisClient({ + sentinelHosts: 's1:26379,s2:26379', + sentinelName: 'trivela-master', + }); + assert.equal(mode, 'sentinel'); + assert.ok(client, 'client must be created'); + assert.equal(typeof client.connect, 'function'); + client.disconnect(); +}); + +test('createRedisClient: builds a Cluster client when cluster nodes are provided', () => { + const { mode, client } = createRedisClient({ + clusterNodes: 'c1:6379,c2:6379', + }); + assert.equal(mode, 'cluster'); + assert.ok(client, 'client must be created'); + assert.equal(typeof client.connect, 'function'); + client.disconnect(); +}); + +test('createRedisClient: builds a standalone client for a URL', () => { + const { mode, client } = createRedisClient({ + redisUrl: 'redis://localhost:6379', + }); + assert.equal(mode, 'standalone'); + assert.ok(client, 'client must be created'); + client.disconnect(); +}); diff --git a/backend/src/index.js b/backend/src/index.js index bf563efa..a613dca4 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -16,7 +16,7 @@ import compression from 'compression'; import multer from 'multer'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import Redis from 'ioredis'; +import { createRedisClient } from './config/redis.js'; import createApiKeyAuth, { createMasterKeyAuth, readProvidedKey } from './middleware/apiKeyAuth.js'; import { createRateLimiter, createRedisStore } from './middleware/rateLimit.js'; import { createAuthLockout } from './middleware/authLockout.js'; @@ -96,6 +96,8 @@ import { createIdempotencyMiddleware } from './middleware/idempotency.js'; import { createDistributedLock, createInMemoryLock } from './jobs/distributedLock.js'; import { createExportJob } from './jobs/exportJob.js'; import { createEventIndexer } from './jobs/eventIndexer.js'; +import { createWeeklyDigestJob } from './jobs/weeklyDigestJob.js'; +import { createEmailService } from './services/emailService.js'; import { createSqliteJobQueueRepository } from './dal/sqliteJobQueueRepository.js'; import { createDurableJobQueue } from './jobs/durableJobQueue.js'; import { @@ -581,23 +583,17 @@ export async function createApp(options = {}) { let rateLimitStore = null; let usageRedisClient = null; - const redisUrl = process.env.REDIS_URL || process.env.REDIS_HOST; - if (redisUrl && !options.disableRedis) { + if (!options.disableRedis) { try { - const redisClient = new Redis(redisUrl, { - maxRetriesPerRequest: 3, - enableReadyCheck: true, - lazyConnect: false, - }); - redisClient.on('error', (err) => { - log.error({ err }, 'Redis connection error'); - }); - rateLimitStore = createRedisStore(redisClient); - usageRedisClient = redisClient; - log.info( - { redisUrl: redisUrl.replace(/:[^:@]+@/, ':***@') }, - 'Rate limiter using Redis store', - ); + const { mode, client: redisClient } = createRedisClient(); + if (redisClient) { + redisClient.on('error', (err) => { + log.error({ err }, 'Redis connection error'); + }); + rateLimitStore = createRedisStore(redisClient); + usageRedisClient = redisClient; + log.info({ redisMode: mode }, 'Rate limiter using Redis store'); + } } catch (error) { log.warn( { err: error }, @@ -617,6 +613,28 @@ export async function createApp(options = {}) { ? createDistributedLock(usageRedisClient, { ttlMs: lockTtlMs }) : createInMemoryLock({ ttlMs: lockTtlMs })); + // Email service adapter — wraps sendEmail with .send({to, template, data}) interface + const emailService = { + async send({ to, template, data }) { + const { sendEmail } = await import('./services/emailService.js'); + return sendEmail(template, to, data); + }, + }; + + // Weekly campaign digest — performance summary emails for operators (#1355) + const digestRecipients = (process.env.CAMPAIGN_DIGEST_RECIPIENTS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const weeklyDigestJob = createWeeklyDigestJob({ + db: dal.db, + emailService, + campaignRepository, + referralRepository, + getRecipients: () => digestRecipients, + logger: log, + }); + // Data export job — daily CSV export to object storage (#562) const exportRetentionDays = normalizePositiveInteger( /** @type {any} */ (options.exportRetentionDays) ?? process.env.EXPORT_RETENTION_DAYS, @@ -886,6 +904,9 @@ export async function createApp(options = {}) { async campaign_reward_notifications() { await campaignRewardNotificationJob(); }, + async weekly_digest() { + await weeklyDigestJob.run(); + }, }, logger: log, deadLetter: failedJobRepository, @@ -945,6 +966,13 @@ export async function createApp(options = {}) { ).unref?.(); } + // Weekly campaign digest — fires on startup (idempotent per ISO week) and weekly (#1355) + if (!options.disableJobs) { + const runDigest = () => jobRunner.enqueue('weekly_digest', null); + runDigest(); + setInterval(runDigest, 7 * 24 * 60 * 60 * 1_000).unref?.(); + } + // #1255 — IPFS pinning of campaign assets (enabled when PINATA_JWT is set) const ipfsPinService = /** @type {any} */ (options.ipfsPinService) ?? diff --git a/backend/src/jobs/weeklyDigestJob.js b/backend/src/jobs/weeklyDigestJob.js new file mode 100644 index 00000000..06b6db91 --- /dev/null +++ b/backend/src/jobs/weeklyDigestJob.js @@ -0,0 +1,163 @@ +// @ts-check +import { buildCampaignStatsForRange } from '../services/campaignStatsService.js'; +import { sendEmail } from '../services/emailService.js'; + +/** + * Weekly campaign performance email digest for operators (#1355). + * + * Collects participation analytics for the trailing 7 days across all + * campaigns and emails a summary to every configured recipient. Idempotent + * per ISO week: a checkpoint row prevents duplicate sends within the same + * week when the job is retried or the server restarts. + * + * @param {Object} params + * @param {InstanceType} params.db + * @param {{ send: Function }} [params.emailService] + * @param {ReturnType} [params.campaignRepository] + * @param {ReturnType} [params.referralRepository] + * @param {() => string[]} [params.getRecipients] + * @param {Object} [params.logger] + */ +export function createWeeklyDigestJob({ + db, + emailService, + campaignRepository, + referralRepository, + getRecipients, + logger = console, +}) { + const checkpointTable = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'export_checkpoints'") + .get(); + + if (checkpointTable) { + db.exec(` + CREATE TABLE IF NOT EXISTS digest_checkpoints ( + week_key TEXT PRIMARY KEY, + completed_at TEXT NOT NULL + ); + `); + } else { + db.exec(` + CREATE TABLE IF NOT EXISTS digest_checkpoints ( + week_key TEXT PRIMARY KEY, + completed_at TEXT NOT NULL + ); + `); + } + + const checkpointSelect = db.prepare('SELECT week_key FROM digest_checkpoints WHERE week_key = ?'); + const checkpointInsert = db.prepare( + 'INSERT OR IGNORE INTO digest_checkpoints (week_key, completed_at) VALUES (?, ?)', + ); + + /** + * ISO week key, e.g. "2024-W03". + * @param {Date} date + */ + function weekKey(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; + } + + /** + * Build the digest body for the trailing week. + * @returns {{ subject: string, text: string, campaignCount: number }} + */ + function buildDigest() { + const to = new Date(); + const from = new Date(to); + from.setUTCDate(from.getUTCDate() - 7); + + const campaigns = + campaignRepository?.list({ active: true, includeHidden: false, status: 'all' }) ?? []; + + const lines = [ + 'Weekly campaign performance digest', + `Period: ${from.toISOString().slice(0, 10)} → ${to.toISOString().slice(0, 10)}`, + '', + ]; + + if (campaigns.length === 0) { + lines.push('No active campaigns this week.'); + } + + for (const campaign of campaigns) { + const stats = buildCampaignStatsForRange({ + db, + campaign, + referralRepository, + indexerCursor: null, + from, + to, + }); + const { totalParticipants, totalPoints, claimRate } = stats.summary; + lines.push( + `• ${campaign.name}: ${totalParticipants} participants, ` + + `${totalPoints} points, ${claimRate}% claim rate`, + ); + } + + return { + subject: `Trivela weekly digest — ${weekKey(to)}`, + text: lines.join('\n'), + campaignCount: campaigns.length, + }; + } + + return { + /** + * Run the weekly digest. Skips when the current ISO week is already + * checkpointed or no recipients are configured. + * + * @returns {Promise<{ skipped: boolean, reason?: string, sent: number }>} + */ + async run() { + const now = new Date(); + const wk = weekKey(now); + + if (checkpointSelect.get(wk)) { + return { skipped: true, reason: 'already_sent', sent: 0 }; + } + + const recipients = (getRecipients?.() ?? []).filter(Boolean); + if (recipients.length === 0) { + return { skipped: true, reason: 'no_recipients', sent: 0 }; + } + + const digest = buildDigest(); + let sent = 0; + for (const to of recipients) { + try { + if (emailService?.send) { + await emailService.send({ to, subject: digest.subject, text: digest.text }); + } else { + await sendEmail('operator-digest-v1', to, { + name: to.split('@')[0], + totalParticipants: digest.campaignCount, + totalClaims: 0, + topCampaign: digest.text.split('\n')[3] ?? 'N/A', + periodLabel: digest.subject, + }); + } + sent += 1; + } catch { + // skip failed recipients + } + } + + checkpointInsert.run(wk, now.toISOString()); + logger.info?.( + `digest:weekly week=${wk} campaigns=${digest.campaignCount} recipients=${recipients.length} sent=${sent}`, + ); + return { skipped: false, sent }; + }, + + weekKey, + buildDigest, + }; +} diff --git a/backend/src/jobs/weeklyDigestJob.test.js b/backend/src/jobs/weeklyDigestJob.test.js new file mode 100644 index 00000000..4ea8f449 --- /dev/null +++ b/backend/src/jobs/weeklyDigestJob.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import { createEmailService } from '../services/emailService.js'; +import { createWeeklyDigestJob } from './weeklyDigestJob.js'; +import { createSqliteCampaignRepository } from '../dal/sqliteCampaignRepository.js'; +import { createSqliteReferralRepository } from '../dal/sqliteReferralRepository.js'; + +function silentLogger() { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +} + +async function setup() { + const db = new Database(':memory:'); + await runMigrations(db); + return { + db, + campaignRepository: createSqliteCampaignRepository({ db }), + referralRepository: createSqliteReferralRepository({ db }), + }; +} + +function makeMailer() { + /** @type {{ to: string, subject: string, text: string }[]} */ + const sent = []; + return { + sent, + emailService: createEmailService({ + transport: async (msg) => { + sent.push(msg); + }, + }), + }; +} + +test('weeklyDigestJob: skips when no recipients are configured', async () => { + const { db, campaignRepository, referralRepository } = await setup(); + const { emailService, sent } = makeMailer(); + const job = createWeeklyDigestJob({ + db, + emailService, + campaignRepository, + referralRepository, + getRecipients: () => [], + logger: silentLogger(), + }); + + const result = await job.run(); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'no_recipients'); + assert.equal(sent.length, 0); +}); + +test('weeklyDigestJob: sends digest with campaign analytics and checkpoints the week', async () => { + const { db, campaignRepository, referralRepository } = await setup(); + campaignRepository.create({ + name: 'Launch Campaign', + slug: 'launch-campaign', + description: 'test', + rewardPerAction: 10, + createdAt: new Date().toISOString(), + }); + const campaign = campaignRepository.getBySlug('launch-campaign'); + referralRepository.create({ + campaignId: campaign.id, + referrerAddress: 'GREF', + refereeAddress: 'GREFEREE', + }); + + const { emailService, sent } = makeMailer(); + const job = createWeeklyDigestJob({ + db, + emailService, + campaignRepository, + referralRepository, + getRecipients: () => ['ops@example.com'], + logger: silentLogger(), + }); + + const result = await job.run(); + assert.equal(result.skipped, false); + assert.equal(result.sent, 1); + assert.equal(sent.length, 1); + assert.equal(sent[0].to, 'ops@example.com'); + assert.match(sent[0].subject, /Trivela weekly digest/); + assert.match(sent[0].text, /Launch Campaign/); + assert.match(sent[0].text, /1 participants/); + + // Second run in the same week is a no-op + const again = await job.run(); + assert.equal(again.skipped, true); + assert.equal(again.reason, 'already_sent'); + assert.equal(sent.length, 1); +}); + +test('weeklyDigestJob: weekKey produces ISO-style keys', async () => { + const { db } = await setup(); + const job = createWeeklyDigestJob({ + db, + emailService: createEmailService({}), + logger: silentLogger(), + }); + assert.equal(job.weekKey(new Date('2024-01-15T12:00:00Z')), '2024-W03'); + assert.equal(job.weekKey(new Date('2024-12-30T12:00:00Z')), '2025-W01'); +}); diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js index ebe0e258..d056e9e1 100644 --- a/backend/src/services/emailService.js +++ b/backend/src/services/emailService.js @@ -169,3 +169,43 @@ export async function sendEmail(templateId, to, vars = {}) { } export { TEMPLATES }; + +/** + * Lightweight factory used by background jobs (e.g. weekly digest). + * Returns `{ send, isConfigured }` matching the job-runner contract. + * + * @param {Object} [params] + * @param {(message: { to: string, subject: string, text: string }) => Promise} [params.transport] + * @param {Object} [params.logger] + */ +export function createEmailService({ transport, logger = console } = {}) { + const configured = typeof transport === 'function'; + + return { + isConfigured() { + return configured || Boolean(process.env.EMAIL_PROVIDER && process.env.EMAIL_FROM); + }, + /** + * @param {{ to: string, subject: string, text: string }} message + * @returns {Promise<{ sent: boolean, skipped?: boolean }>} + */ + async send(message) { + if (configured) { + await transport(message); + return { sent: true }; + } + if (!process.env.EMAIL_PROVIDER) { + logger.debug?.('email:send skipped — no transport or EMAIL_PROVIDER configured', { to: message.to }); + return { sent: false, skipped: true }; + } + await sendWithRetry({ + from: process.env.EMAIL_FROM ?? 'noreply@trivela.io', + to: message.to, + subject: message.subject, + html: `
${escHtml(message.text)}
`, + text: message.text, + }); + return { sent: true }; + }, + }; +} diff --git a/backend/src/services/emailService.test.js b/backend/src/services/emailService.test.js new file mode 100644 index 00000000..cf735496 --- /dev/null +++ b/backend/src/services/emailService.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createEmailService } from './emailService.js'; + +test('emailService: isConfigured is false without a transport', () => { + const svc = createEmailService({}); + assert.equal(svc.isConfigured(), false); +}); + +test('emailService: send is a no-op when not configured', async () => { + const svc = createEmailService({ logger: { debug: () => {} } }); + const result = await svc.send({ to: 'a@b.c', subject: 's', text: 't' }); + assert.deepEqual(result, { sent: false, skipped: true }); +}); + +test('emailService: send delegates to the transport when configured', async () => { + const messages = []; + const svc = createEmailService({ + transport: async (msg) => { + messages.push(msg); + }, + }); + assert.equal(svc.isConfigured(), true); + const result = await svc.send({ to: 'ops@example.com', subject: 'Hello', text: 'Body' }); + assert.deepEqual(result, { sent: true }); + assert.equal(messages.length, 1); + assert.equal(messages[0].to, 'ops@example.com'); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c7d233d0..0a3902b7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -29,8 +29,11 @@ import { applyTheme, getPreferredTheme, getStoredTheme, + getThemePreference, persistTheme, + resolveAutoTheme, subscribeToSystemTheme, + THEME_AUTO, } from './theme'; import { getRuntimeConfig, initializeRuntimeConfig, setRuntimeStellarNetwork } from './config'; import { @@ -46,6 +49,9 @@ import { useToast } from './lib/toast/ToastProvider'; export default function App() { const toast = useToast(); + const [themePreference, setThemePreference] = useState( + () => getThemePreference() ?? THEME_AUTO, + ); const [theme, setTheme] = useState(() => getPreferredTheme()); const [runtimeConfig, setRuntimeConfig] = useState(() => getRuntimeConfig()); const [walletAddress, setWalletAddress] = useState(''); @@ -58,6 +64,18 @@ export default function App() { const [showWalletModal, setShowWalletModal] = useState(false); const { showHelpModal, setShowHelpModal, announcement } = useKeyboardShortcuts(true); + useEffect(() => { + if (themePreference === THEME_AUTO) { + setTheme(resolveAutoTheme()); + persistTheme(THEME_AUTO); + const interval = setInterval(() => setTheme(resolveAutoTheme()), 60_000); + return () => clearInterval(interval); + } + persistTheme(themePreference); + setTheme(themePreference); + return undefined; + }, [themePreference]); + useEffect(() => { applyTheme(theme); }, [theme]); @@ -95,9 +113,11 @@ export default function App() { }, []); const toggleTheme = () => { - const nextTheme = theme === 'dark' ? 'light' : 'dark'; - persistTheme(nextTheme); - setTheme(nextTheme); + setThemePreference((current) => { + if (current === 'dark') return 'light'; + if (current === 'light') return THEME_AUTO; + return 'dark'; + }); }; const loadWalletBalance = async (address) => { diff --git a/frontend/src/CampaignAnalytics.jsx b/frontend/src/CampaignAnalytics.jsx index e1ab4f90..8ccbc59c 100644 --- a/frontend/src/CampaignAnalytics.jsx +++ b/frontend/src/CampaignAnalytics.jsx @@ -21,6 +21,7 @@ import { } from './lib/campaignExport'; import Header from './components/Header'; import PageMeta from './components/PageMeta'; +import CampaignMap from './components/CampaignMap'; import './CampaignAnalytics.css'; const RANGE_OPTIONS = [ @@ -285,6 +286,11 @@ export default function CampaignAnalytics({ ) : null} + +
+

Participation hotspots

+ +
diff --git a/frontend/src/components/CampaignMap.jsx b/frontend/src/components/CampaignMap.jsx new file mode 100644 index 00000000..0f9f20e1 --- /dev/null +++ b/frontend/src/components/CampaignMap.jsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react'; + +export const REGIONS = [ + { id: 'north-america', label: 'North America', x: 18, y: 32 }, + { id: 'south-america', label: 'South America', x: 28, y: 68 }, + { id: 'europe', label: 'Europe', x: 50, y: 28 }, + { id: 'africa', label: 'Africa', x: 52, y: 58 }, + { id: 'asia', label: 'Asia', x: 72, y: 36 }, + { id: 'oceania', label: 'Oceania', x: 84, y: 72 }, +]; + +const ALL = 'all'; + +/** + * Interactive world map of campaign participation hotspots, filterable by + * geographic region. Markers are sized relative to participation counts. + * + * @param {object} props + * @param {{ region: string, count: number }[]} [props.hotspots] + * @param {(region: string) => void} [props.onRegionChange] + */ +export default function CampaignMap({ hotspots = [], onRegionChange }) { + const [activeRegion, setActiveRegion] = useState(ALL); + + const counts = useMemo(() => { + const map = new Map(); + for (const spot of hotspots) { + map.set(spot.region, (map.get(spot.region) ?? 0) + (spot.count ?? 0)); + } + return map; + }, [hotspots]); + + const maxCount = useMemo( + () => Math.max(1, ...REGIONS.map((r) => counts.get(r.id) ?? 0)), + [counts], + ); + + const visible = activeRegion === ALL ? REGIONS : REGIONS.filter((r) => r.id === activeRegion); + + function selectRegion(regionId) { + setActiveRegion(regionId); + onRegionChange?.(regionId); + } + + return ( +
+
+ {[{ id: ALL, label: 'All regions' }, ...REGIONS].map((r) => ( + + ))} +
+ + + + {visible.map((region) => { + const count = counts.get(region.id) ?? 0; + const intensity = count / maxCount; + const radius = 3 + intensity * 7; + return ( + + + + {region.label} + + + {count} + + + ); + })} + + +

+ {activeRegion === ALL + ? `${hotspots.reduce((sum, s) => sum + (s.count ?? 0), 0)} total participants across ${REGIONS.length} regions` + : `${counts.get(activeRegion) ?? 0} participants in ${REGIONS.find((r) => r.id === activeRegion)?.label}`} +

+
+ ); +} diff --git a/frontend/src/components/CampaignMap.test.jsx b/frontend/src/components/CampaignMap.test.jsx new file mode 100644 index 00000000..d2b3eaa5 --- /dev/null +++ b/frontend/src/components/CampaignMap.test.jsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CampaignMap, { REGIONS } from './CampaignMap'; + +const HOTSPOTS = [ + { region: 'europe', count: 40 }, + { region: 'asia', count: 25 }, + { region: 'north-america', count: 10 }, +]; + +describe('CampaignMap', () => { + it('renders the map with all regions by default', () => { + render(); + expect(screen.getByTestId('campaign-map')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /world map/i })).toBeInTheDocument(); + for (const region of REGIONS) { + expect(screen.getByRole('button', { name: region.label })).toBeInTheDocument(); + expect(document.querySelector(`[data-region="${region.id}"]`)).toBeInTheDocument(); + } + }); + + it('shows total participants across all regions', () => { + render(); + expect(screen.getByText(/75 total participants/)).toBeInTheDocument(); + }); + + it('filters to a single region when a region button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Europe' })); + expect(screen.getByText(/40 participants in Europe/)).toBeInTheDocument(); + expect(document.querySelector('[data-region="europe"]')).toBeInTheDocument(); + expect(document.querySelector('[data-region="asia"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-region="oceania"]')).not.toBeInTheDocument(); + }); + + it('calls onRegionChange when a region is selected', () => { + const onRegionChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Asia' })); + expect(onRegionChange).toHaveBeenCalledWith('asia'); + }); + + it('renders zero counts for regions without hotspot data', () => { + render(); + expect(screen.getByText(/0 total participants/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/theme.js b/frontend/src/theme.js index 4b6580aa..02b7508c 100644 --- a/frontend/src/theme.js +++ b/frontend/src/theme.js @@ -1,8 +1,17 @@ export const THEME_STORAGE_KEY = 'trivela-theme'; +export const THEME_AUTO = 'auto'; -const isTheme = (value) => value === 'light' || value === 'dark'; +const DAY_START_HOUR = 6; +const DAY_END_HOUR = 18; -/** Returns the theme the user explicitly chose, or null if they never did (or storage is unavailable). */ +export function resolveAutoTheme(date = new Date()) { + const hour = date.getHours(); + return hour >= DAY_START_HOUR && hour < DAY_END_HOUR ? 'light' : 'dark'; +} + +const isTheme = (value) => value === 'light' || value === 'dark' || value === THEME_AUTO; + +/** Returns the theme the user explicitly chose (including 'auto'), or null. */ export function getStoredTheme() { if (typeof window === 'undefined') { return null; @@ -16,18 +25,27 @@ export function getStoredTheme() { } } +/** Alias kept for callers that use the preference-shaped name. */ +export function getThemePreference() { + return getStoredTheme(); +} + /** Returns the operating-system preference (`prefers-color-scheme`), defaulting to dark. */ export function getSystemTheme() { if (typeof window === 'undefined') { return 'dark'; } - return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + return window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark'; } -/** Stored choice first, then the system preference. */ +/** Stored choice first (auto resolves by local time), then the system preference. */ export function getPreferredTheme() { - return getStoredTheme() ?? getSystemTheme(); + const stored = getStoredTheme(); + if (stored === THEME_AUTO) { + return resolveAutoTheme(); + } + return stored ?? getSystemTheme(); } /** Saves an explicit user choice. Storage failures (private mode, quota) are ignored. */ diff --git a/frontend/src/theme.test.js b/frontend/src/theme.test.js index c2485200..0808390a 100644 --- a/frontend/src/theme.test.js +++ b/frontend/src/theme.test.js @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + THEME_AUTO, THEME_STORAGE_KEY, applyTheme, getPreferredTheme, getStoredTheme, + getThemePreference, persistTheme, + resolveAutoTheme, subscribeToSystemTheme, } from './theme'; @@ -25,6 +28,21 @@ function mockMatchMedia(prefersLight) { }; } +describe('resolveAutoTheme', () => { + it('returns light during daytime hours (06:00-17:59)', () => { + expect(resolveAutoTheme(new Date('2024-06-01T12:00:00'))).toBe('light'); + expect(resolveAutoTheme(new Date('2024-06-01T06:00:00'))).toBe('light'); + expect(resolveAutoTheme(new Date('2024-06-01T17:59:00'))).toBe('light'); + }); + + it('returns dark during nighttime hours', () => { + expect(resolveAutoTheme(new Date('2024-06-01T05:59:00'))).toBe('dark'); + expect(resolveAutoTheme(new Date('2024-06-01T18:00:00'))).toBe('dark'); + expect(resolveAutoTheme(new Date('2024-06-01T00:00:00'))).toBe('dark'); + expect(resolveAutoTheme(new Date('2024-06-01T23:30:00'))).toBe('dark'); + }); +}); + describe('theme persistence and system detection', () => { beforeEach(() => { window.localStorage.clear(); @@ -55,20 +73,33 @@ describe('theme persistence and system detection', () => { mockMatchMedia(false); window.localStorage.setItem(THEME_STORAGE_KEY, 'purple'); expect(getStoredTheme()).toBeNull(); - persistTheme('purple'); - expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('purple'); + }); + + it('accepts auto as a valid stored preference', () => { + persistTheme(THEME_AUTO); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe(THEME_AUTO); + expect(getStoredTheme()).toBe(THEME_AUTO); + expect(getThemePreference()).toBe(THEME_AUTO); + }); + + it('auto-resolves by local time when auto is stored', () => { + persistTheme(THEME_AUTO); + const hour = new Date().getHours(); + const expected = hour >= 6 && hour < 18 ? 'light' : 'dark'; + expect(getPreferredTheme()).toBe(expected); }); it('survives unavailable storage', () => { mockMatchMedia(true); - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => { throw new Error('denied'); }); - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { throw new Error('denied'); }); expect(getPreferredTheme()).toBe('light'); expect(() => persistTheme('dark')).not.toThrow(); + expect(getThemePreference()).toBeNull(); }); it('notifies on OS preference changes and stops after unsubscribe', () => { @@ -86,6 +117,10 @@ describe('theme persistence and system detection', () => { }); it('applyTheme sets the data attribute and color scheme', () => { + applyTheme('dark'); + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + applyTheme('light'); expect(document.documentElement.dataset.theme).toBe('light'); expect(document.documentElement.style.colorScheme).toBe('light');