diff --git a/backend/__tests__/utils/analyticsDateMerge.test.js b/backend/__tests__/utils/analyticsDateMerge.test.js new file mode 100644 index 00000000..dcfda159 --- /dev/null +++ b/backend/__tests__/utils/analyticsDateMerge.test.js @@ -0,0 +1,58 @@ +/** + * Pins the date-merge fix in `adminDashboard.js` /analytics route + * (#661 Bug A). The merge previously failed on Postgres because pg's + * driver returns `DATE(timestamp)` as a JS Date object, while SQLite + * returns a string — the old `dateObj.date === row.date` comparison + * was false on Postgres so chartData stayed all-zero even with traffic. + * + * We test the normalisation helper here in isolation. The route-level + * integration is covered by the existing dashboard route test. + */ + +// The helper is internal to the route file; reimport via a small wrapper +// so we don't need to export everything publicly. +const path = require('path'); +const fs = require('fs'); + +const ROUTE_SRC = fs.readFileSync( + path.join(__dirname, '../../src/routes/adminDashboard.js'), + 'utf8', +); +// Tiny evaluator that grabs the normaliseDateKey function definition from +// the route source so the test pins the actual shipping implementation, +// not a copy. +function extractNormaliseDateKey() { + const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/); + if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js'); + // eslint-disable-next-line no-new-func + return new Function(`${match[0]}; return normaliseDateKey;`)(); +} + +const normaliseDateKey = extractNormaliseDateKey(); + +describe('analytics route — normaliseDateKey (#661 Bug A)', () => { + test('passes through a YYYY-MM-DD string unchanged', () => { + expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22'); + }); + + test('slices off a time component on a longer ISO string', () => { + expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22'); + }); + + test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => { + const d = new Date('2026-06-22T12:34:56Z'); + expect(normaliseDateKey(d)).toBe('2026-06-22'); + }); + + test('returns null for null / undefined / empty', () => { + expect(normaliseDateKey(null)).toBeNull(); + expect(normaliseDateKey(undefined)).toBeNull(); + expect(normaliseDateKey('')).toBeNull(); + }); + + test('coerces unexpected types via String() to avoid throwing', () => { + // We don't expect to receive a number from either driver, but the + // helper should not crash if it does — date merge will simply miss. + expect(normaliseDateKey(20260622)).toBe('20260622'); + }); +}); diff --git a/backend/__tests__/utils/umamiClient.test.js b/backend/__tests__/utils/umamiClient.test.js new file mode 100644 index 00000000..4f325b2c --- /dev/null +++ b/backend/__tests__/utils/umamiClient.test.js @@ -0,0 +1,142 @@ +/** + * Unit tests for the Umami v2 metrics-API client (#661 Bug C). + * + * The client is only consumed by `/admin/dashboard/analytics` today to fetch + * the device-breakdown chart, so these tests pin: + * - The exact URL shape sent to Umami (`/api/websites//metrics?type=device&startAt=…&endAt=…`) + * - The `x-umami-api-key` auth header + * - The `{ x, y }` → `{ desktop, mobile, tablet }` percentage normalisation + * - `laptop` mapping into `desktop` for our 3-bucket UI + * - Defensive returns: missing config / non-2xx / non-JSON / empty array + * all return `null` so the route layer can fall back to access_logs. + */ + +const { fetchUmamiDeviceBreakdown } = require('../../src/services/umamiClient'); + +const ORIGINAL_FETCH = global.fetch; + +afterEach(() => { + global.fetch = ORIGINAL_FETCH; +}); + +function mockJson(body, { status = 200 } = {}) { + global.fetch = jest.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })); +} + +describe('fetchUmamiDeviceBreakdown', () => { + test('returns null when config is incomplete (back-compat for installs without API key)', async () => { + expect(await fetchUmamiDeviceBreakdown({})).toBeNull(); + expect(await fetchUmamiDeviceBreakdown({ baseUrl: 'https://u.example' })).toBeNull(); + expect(await fetchUmamiDeviceBreakdown({ baseUrl: 'https://u.example', websiteId: 'w' })).toBeNull(); + // No fetch should be issued in any of those cases. + expect(global.fetch).toBe(ORIGINAL_FETCH); + }); + + test('builds the expected URL + sends the x-umami-api-key header', async () => { + mockJson([{ x: 'desktop', y: 10 }]); + await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u.example.com/', + websiteId: 'site-123', + apiKey: 'secret', + startMs: 1700000000000, + endMs: 1700003600000, + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + const [calledUrl, init] = global.fetch.mock.calls[0]; + expect(calledUrl).toBe( + 'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000', + ); + expect(init.headers['x-umami-api-key']).toBe('secret'); + expect(init.method).toBe('GET'); + }); + + test('encodes the websiteId so a path segment with reserved chars is safe', async () => { + mockJson([{ x: 'desktop', y: 1 }]); + await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u.example.com', + websiteId: 'a/b?c', + apiKey: 'k', + startMs: 0, endMs: 0, + }); + const [calledUrl] = global.fetch.mock.calls[0]; + expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics'); + }); + + test('normalises a typical { x, y } payload into integer percentages', async () => { + mockJson([ + { x: 'desktop', y: 60 }, + { x: 'mobile', y: 30 }, + { x: 'tablet', y: 10 }, + ]); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 }); + }); + + test('maps `laptop` into `desktop` for the 3-bucket UI', async () => { + mockJson([ + { x: 'desktop', y: 50 }, + { x: 'laptop', y: 20 }, + { x: 'mobile', y: 30 }, + ]); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + // desktop = (50 + 20) / 100 = 70% + expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 }); + }); + + test('drops unknown buckets entirely (avoids silent miscategorisation)', async () => { + mockJson([ + { x: 'desktop', y: 80 }, + { x: 'mobile', y: 20 }, + { x: 'unknown-future-bucket', y: 100 }, + ]); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + // 100 isn't counted into total, so 80/(80+20) = 80%, 20/(80+20) = 20%. + expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 }); + }); + + test('returns null on empty payload (caller falls back to access_logs)', async () => { + mockJson([]); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + expect(out).toBeNull(); + }); + + test('returns null on non-2xx upstream response', async () => { + mockJson({ error: 'unauthorized' }, { status: 401 }); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + expect(out).toBeNull(); + }); + + test('returns null on invalid JSON body', async () => { + global.fetch = jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => { throw new SyntaxError('not json'); }, + })); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + expect(out).toBeNull(); + }); + + test('returns null on network error (fetch throws)', async () => { + global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); }); + const out = await fetchUmamiDeviceBreakdown({ + baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0, + }); + expect(out).toBeNull(); + }); +}); diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index ec4ec4b3..45cc5cfb 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -4,8 +4,26 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); const { formatBoolean } = require('../utils/dbCompat'); +const { getAppSetting } = require('../utils/appSettings'); +const { fetchUmamiDeviceBreakdown } = require('../services/umamiClient'); +const logger = require('../utils/logger'); const router = express.Router(); +/** + * Normalise a value coming back from `DATE(timestamp)` into a YYYY-MM-DD + * string. SQLite returns this column as a string already; Postgres' pg + * driver auto-converts it to a JavaScript Date object, which broke the + * old `dateObj.date === row.date` merge below — every Postgres install saw + * an all-zero `chartData[]` even with real traffic (#661 Bug A). Always + * normalise before comparing. + */ +function normaliseDateKey(value) { + if (!value) return null; + if (value instanceof Date) return value.toISOString().slice(0, 10); + // Strings might arrive with time component, slice defensively. + return String(value).slice(0, 10); +} + // Get dashboard statistics router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => { try { @@ -265,20 +283,25 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async ( .where('timestamp', '>=', startDateStr) .groupByRaw('DATE(timestamp)'); - // Merge data into dates array + // Merge data into dates array. row.date is normalised because Postgres + // returns DATE() as a JS Date while SQLite returns a string (#661 Bug A). + // Counts come back as strings on Postgres too, so coerce via Number. viewsData.forEach(row => { - const dateObj = dates.find(d => d.date === row.date); - if (dateObj) dateObj.views = row.count; + const key = normaliseDateKey(row.date); + const dateObj = dates.find(d => d.date === key); + if (dateObj) dateObj.views = Number(row.count) || 0; }); downloadsData.forEach(row => { - const dateObj = dates.find(d => d.date === row.date); - if (dateObj) dateObj.downloads = row.count; + const key = normaliseDateKey(row.date); + const dateObj = dates.find(d => d.date === key); + if (dateObj) dateObj.downloads = Number(row.count) || 0; }); visitorsData.forEach(row => { - const dateObj = dates.find(d => d.date === row.date); - if (dateObj) dateObj.uniqueVisitors = row.count; + const key = normaliseDateKey(row.date); + const dateObj = dates.find(d => d.date === key); + if (dateObj) dateObj.uniqueVisitors = Number(row.count) || 0; }); // Get top galleries by views with additional metrics @@ -293,31 +316,66 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async ( .orderBy('views', 'desc') .limit(5); - // Get device breakdown (simplified - based on user agent) - const deviceData = await db('access_logs') - .select( - db.raw(` - CASE - WHEN user_agent LIKE '%Mobile%' THEN 'mobile' - WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet' - ELSE 'desktop' - END as device_type - `), - db.raw('COUNT(*) as count') - ) - .where('timestamp', '>=', startDateStr) - .groupBy('device_type'); + // Device breakdown — prefer Umami's metrics API when configured (#661 + // Bug C). The local access_logs heuristic below produces 0% on installs + // where guest user agents don't reliably contain "Mobile" / "Tablet" + // tokens; Umami tracks devices natively. Falls back to access_logs when + // Umami is unconfigured, unreachable, or rate-limited. + let devices = { desktop: 0, mobile: 0, tablet: 0 }; + let devicesSource = 'access_logs'; - const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0); - const devices = { - desktop: 0, - mobile: 0, - tablet: 0 + const umamiConfig = { + enabled: (await getAppSetting('analytics_umami_enabled', false)) === true, + baseUrl: await getAppSetting('analytics_umami_url', null), + websiteId: await getAppSetting('analytics_umami_website_id', null), + apiKey: await getAppSetting('analytics_umami_api_key', null), }; + if (umamiConfig.enabled && umamiConfig.baseUrl && umamiConfig.websiteId && umamiConfig.apiKey) { + try { + const umamiDevices = await fetchUmamiDeviceBreakdown({ + baseUrl: umamiConfig.baseUrl, + websiteId: umamiConfig.websiteId, + apiKey: umamiConfig.apiKey, + startMs: startDate.getTime(), + endMs: Date.now(), + }); + if (umamiDevices) { + devices = umamiDevices; + devicesSource = 'umami'; + } + } catch (err) { + logger.warn('Analytics: Umami device-breakdown fetch failed; falling back to access_logs', { + error: err.message, + }); + } + } - deviceData.forEach(d => { - devices[d.device_type] = Math.round((d.count / totalDevices) * 100); - }); + if (devicesSource === 'access_logs') { + // Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't + // cover every UA shape (some Android browsers, embedded webviews, etc.) + // — and counts come back as strings on Postgres, hence Number() below. + const deviceData = await db('access_logs') + .select( + db.raw(` + CASE + WHEN user_agent LIKE '%Mobile%' THEN 'mobile' + WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet' + ELSE 'desktop' + END as device_type + `), + db.raw('COUNT(*) as count') + ) + .where('timestamp', '>=', startDateStr) + .whereNotNull('user_agent') + .groupBy('device_type'); + + const totalDevices = deviceData.reduce((sum, d) => sum + (Number(d.count) || 0), 0); + if (totalDevices > 0) { + deviceData.forEach(d => { + devices[d.device_type] = Math.round(((Number(d.count) || 0) / totalDevices) * 100); + }); + } + } // Calculate totals for the period (matching /stats logic) const totalViews = await db('access_logs') @@ -341,6 +399,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async ( chartData: dates, topGalleries, devices, + devicesSource, totals: { views: totalViews?.count || 0, downloads: totalDownloadsCount?.count || 0, diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 3bb261a7..a04f0b98 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -145,6 +145,12 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) if (settingsObject.security_recaptcha_secret_key) { settingsObject.security_recaptcha_secret_key = '••••••••'; } + // Umami v2 API key (#661 Bug C) — read-write secret that authenticates + // outbound calls to the operator's Umami instance for the device + // breakdown. Masked on GET, same pattern as the recaptcha secret. + if (settingsObject.analytics_umami_api_key) { + settingsObject.analytics_umami_api_key = '••••••••'; + } res.json(settingsObject); } catch (error) { @@ -390,6 +396,12 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, if (settingsObject.security_recaptcha_secret_key) { settingsObject.security_recaptcha_secret_key = '••••••••'; } + // Umami v2 API key (#661 Bug C) — read-write secret that authenticates + // outbound calls to the operator's Umami instance for the device + // breakdown. Masked on GET, same pattern as the recaptcha secret. + if (settingsObject.analytics_umami_api_key) { + settingsObject.analytics_umami_api_key = '••••••••'; + } res.json(settingsObject); } catch (error) { diff --git a/backend/src/services/umamiClient.js b/backend/src/services/umamiClient.js new file mode 100644 index 00000000..7e474562 --- /dev/null +++ b/backend/src/services/umamiClient.js @@ -0,0 +1,104 @@ +/** + * Thin Umami v2 API client (#661 Bug C). + * + * We use this in exactly one place today — `GET /admin/dashboard/analytics` + * pulls device-breakdown stats from Umami when an admin has provided an + * API key, because our own user-agent heuristic on `access_logs` returns + * 0/0/0 on installs where guest UAs don't reliably contain "Mobile" / + * "Tablet" tokens. Umami tracks devices natively. + * + * Auth: Umami v2 supports per-account API keys generated in the Umami UI + * (Settings → Profile → API Keys). We send them via the `x-umami-api-key` + * header. Older session-cookie-based auth is intentionally NOT supported + * here — operators should generate an API key rather than embedding their + * Umami account password in PicPeak. + */ + +const logger = require('../utils/logger'); + +const REQUEST_TIMEOUT_MS = 5000; + +/** + * Fetch the device breakdown for a Umami website in [startMs, endMs]. Returns + * `{ desktop, mobile, tablet }` as integer percentages summing to ~100, or + * `null` when the upstream call fails / returns no data — callers should + * treat null as "fall back to the local heuristic". + * + * @param {object} opts + * @param {string} opts.baseUrl — Umami instance root, e.g. https://analytics.example.com + * @param {string} opts.websiteId — UUID from the Umami website settings + * @param {string} opts.apiKey — Umami API key (x-umami-api-key) + * @param {number} opts.startMs — epoch ms, start of window + * @param {number} opts.endMs — epoch ms, end of window + */ +async function fetchUmamiDeviceBreakdown({ baseUrl, websiteId, apiKey, startMs, endMs }) { + if (!baseUrl || !websiteId || !apiKey) return null; + + const trimmedBase = String(baseUrl).replace(/\/+$/, ''); + const url = `${trimmedBase}/api/websites/${encodeURIComponent(websiteId)}/metrics` + + `?type=device&startAt=${encodeURIComponent(startMs)}&endAt=${encodeURIComponent(endMs)}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let response; + try { + response = await fetch(url, { + method: 'GET', + headers: { + 'x-umami-api-key': apiKey, + Accept: 'application/json', + }, + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if (err.name === 'AbortError') { + logger.warn('Umami device fetch: timeout', { url }); + return null; + } + logger.warn('Umami device fetch: network error', { error: err.message }); + return null; + } + clearTimeout(timer); + + if (!response.ok) { + logger.warn('Umami device fetch: non-2xx', { status: response.status }); + return null; + } + + let data; + try { + data = await response.json(); + } catch (err) { + logger.warn('Umami device fetch: invalid JSON', { error: err.message }); + return null; + } + + if (!Array.isArray(data)) return null; + + // Umami buckets device types into these strings: `desktop`, `mobile`, + // `tablet`, `laptop`. Map `laptop` → `desktop` for our 3-bucket UI; drop + // anything we don't recognise (vs. silently miscategorising). + const counts = { desktop: 0, mobile: 0, tablet: 0 }; + let total = 0; + for (const entry of data) { + if (!entry || typeof entry.x !== 'string') continue; + const n = Number(entry.y); + if (!Number.isFinite(n) || n <= 0) continue; + const key = entry.x === 'laptop' ? 'desktop' : entry.x; + if (key in counts) { + counts[key] += n; + total += n; + } + } + + if (total === 0) return null; + + return { + desktop: Math.round((counts.desktop / total) * 100), + mobile: Math.round((counts.mobile / total) * 100), + tablet: Math.round((counts.tablet / total) * 100), + }; +} + +module.exports = { fetchUmamiDeviceBreakdown }; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 5e3b2579..dae24f14 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -50,6 +50,12 @@ export interface AnalyticsSettings { umami_url: string; umami_website_id: string; umami_share_url: string; + // API key for Umami's v2 metrics API. Required ONLY for the device + // breakdown (#661 Bug C); the rest of the integration (embedded iframe, + // tracker script) still works without it. Server masks as `••••••••` + // on GET when a value is stored — submit the masked sentinel unchanged + // to keep the stored value, or a real key to replace it. + umami_api_key: string; } export interface EventSettings { @@ -129,7 +135,8 @@ export function useSettingsState() { umami_enabled: false, umami_url: '', umami_website_id: '', - umami_share_url: '' + umami_share_url: '', + umami_api_key: '' }); // Event creation settings state @@ -219,7 +226,8 @@ export function useSettingsState() { umami_enabled: toBoolean(settings.analytics_umami_enabled, false), umami_url: settings.analytics_umami_url || '', umami_website_id: settings.analytics_umami_website_id || '', - umami_share_url: settings.analytics_umami_share_url || '' + umami_share_url: settings.analytics_umami_share_url || '', + umami_api_key: settings.analytics_umami_api_key || '' }); setEventSettings({ @@ -315,6 +323,10 @@ export function useSettingsState() { mutationFn: async () => { const settingsData: Record = {}; Object.entries(analyticsSettings).forEach(([key, value]) => { + // The Umami API key (#661 Bug C) is returned masked as `••••••••` + // on GET so it doesn't leak in the response body. Don't re-save + // that sentinel — silently preserve whatever's already stored. + if (key === 'umami_api_key' && value === '••••••••') return; settingsData[`analytics_${key}`] = value; }); return settingsService.updateSettings(settingsData); diff --git a/frontend/src/features/settings/tabs/AnalyticsTab.tsx b/frontend/src/features/settings/tabs/AnalyticsTab.tsx index 265212cc..903e95b9 100644 --- a/frontend/src/features/settings/tabs/AnalyticsTab.tsx +++ b/frontend/src/features/settings/tabs/AnalyticsTab.tsx @@ -85,6 +85,29 @@ export const AnalyticsTab: React.FC = ({ {t('settings.analytics.shareUrlHelp')}

+ + {/* Umami API key — only used by the dashboard's device + breakdown today (#661 Bug C). Optional; the rest of the + integration works without it. Masked as •••••••• on GET. */} +
+ + setAnalyticsSettings(prev => ({ ...prev, umami_api_key: e.target.value }))} + placeholder="api_xxx…" + leftIcon={} + autoComplete="off" + /> +

+ {t( + 'settings.analytics.umamiApiKeyHelp', + 'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.', + )} +

+
)} diff --git a/frontend/src/pages/admin/AnalyticsPage.tsx b/frontend/src/pages/admin/AnalyticsPage.tsx index 8634fb90..f1253a54 100644 --- a/frontend/src/pages/admin/AnalyticsPage.tsx +++ b/frontend/src/pages/admin/AnalyticsPage.tsx @@ -82,21 +82,19 @@ export const AnalyticsPage: React.FC = () => { useEffect(() => { const fetchUmamiConfig = async () => { try { - // Use admin API endpoint with auth token since we're in admin area + // `/admin/settings` returns a key/value object (see backend + // `adminSettings.js:108`), NOT an array (#661 Bug B). The old + // `.reduce()` path threw `data.reduce is not a function` and the + // catch block silently rendered the "Umami Not Configured" banner + // even on perfectly-configured installs. Read keys directly. const response = await api.get('/admin/settings'); - const settings = response.data; - - // Transform the settings array to object - const settingsMap = settings.reduce((acc: any, setting: any) => { - acc[setting.key] = setting.value; - return acc; - }, {}); - + const settings = response.data ?? {}; + // Check if Umami is enabled in admin settings - if (settingsMap.analytics_umami_enabled && settingsMap.analytics_umami_url && settingsMap.analytics_umami_website_id) { + if (settings.analytics_umami_enabled && settings.analytics_umami_url && settings.analytics_umami_website_id) { setUmamiConfig({ - url: settingsMap.analytics_umami_url, - shareUrl: settingsMap.analytics_umami_share_url, + url: settings.analytics_umami_url, + shareUrl: settings.analytics_umami_share_url, enabled: true }); } else { @@ -139,10 +137,14 @@ export const AnalyticsPage: React.FC = () => { const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => { if (!apiData) return undefined; - // Calculate totals from chart data - const totalViews = apiData.chartData.reduce((sum, day) => sum + day.views, 0); - const totalVisitors = apiData.chartData.reduce((sum, day) => sum + day.uniqueVisitors, 0); - const totalDownloads = apiData.chartData.reduce((sum, day) => sum + day.downloads, 0); + // Headline totals come from the dedicated `totals` object that the + // backend computes via separate COUNT queries (#661 Bug A). The old + // sum-the-chartData path returned 0 on Postgres installs because the + // backend's date-string merge into chartData failed there. Postgres' + // pg driver returns COUNT(...) as strings, so coerce via Number(). + const totalViews = Number(apiData.totals?.views ?? 0); + const totalVisitors = Number(apiData.totals?.uniqueVisitors ?? 0); + const totalDownloads = Number(apiData.totals?.downloads ?? 0); // Calculate trends (comparing last half to first half) const halfPoint = Math.floor(apiData.chartData.length / 2); diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index f8c40575..b3f9b5f7 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -327,6 +327,18 @@ export interface AnalyticsData { mobile: number; tablet: number; }; + // Period totals computed via dedicated COUNT queries on the backend + // (#661 Bug A). Postgres returns these as strings, so callers should + // coerce via Number() before display. Optional on the type because + // older backends (pre-#661) didn't always emit it. + totals?: { + views: number | string; + downloads: number | string; + uniqueVisitors: number | string; + }; + // Source of the device breakdown — `umami` when API-key auth succeeded, + // `access_logs` for the local user-agent heuristic fallback (#661 Bug C). + devicesSource?: 'umami' | 'access_logs'; } export const adminService = {