diff --git a/backend/constants/orgBetaFeatures.js b/backend/constants/orgBetaFeatures.js new file mode 100644 index 00000000..f12b6339 --- /dev/null +++ b/backend/constants/orgBetaFeatures.js @@ -0,0 +1,55 @@ +/** + * Per-organization beta feature registry (server source of truth). + * Keep keys in sync with Meridian/frontend/src/constants/orgBetaFeatures.js + */ +const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks'; + +const ORG_BETA_FEATURE_CATALOG = { + [ORG_BETA_FEATURE_ORG_TASKS]: { + label: 'Organization task hub', + description: 'Cross-event operational tasks and org-level task board in Club Dashboard.', + clubDashMenuKey: 'tasks' + } +}; + +const ORG_BETA_FEATURE_KEYS = Object.freeze(Object.keys(ORG_BETA_FEATURE_CATALOG)); + +function orgHasBetaFeature(org, featureKey) { + const keys = org && org.betaFeatureKeys; + if (!Array.isArray(keys)) return false; + return keys.includes(featureKey); +} + +/** + * @param {unknown} keys + * @returns {{ ok: true, keys: string[] } | { ok: false, error: string }} + */ +function validateBetaFeatureKeysArray(keys) { + if (!Array.isArray(keys)) { + return { ok: false, error: 'enabledKeys must be an array' }; + } + const invalid = keys.filter((k) => typeof k !== 'string' || !ORG_BETA_FEATURE_KEYS.includes(k)); + if (invalid.length) { + return { ok: false, error: `Unknown beta feature keys: ${invalid.join(', ')}` }; + } + const unique = [...new Set(keys)]; + return { ok: true, keys: unique }; +} + +function getBetaFeatureCatalogForApi() { + return ORG_BETA_FEATURE_KEYS.map((key) => ({ + key, + label: ORG_BETA_FEATURE_CATALOG[key].label, + description: ORG_BETA_FEATURE_CATALOG[key].description, + clubDashMenuKey: ORG_BETA_FEATURE_CATALOG[key].clubDashMenuKey || null + })); +} + +module.exports = { + ORG_BETA_FEATURE_ORG_TASKS, + ORG_BETA_FEATURE_KEYS, + ORG_BETA_FEATURE_CATALOG, + orgHasBetaFeature, + validateBetaFeatureKeysArray, + getBetaFeatureCatalogForApi +}; diff --git a/backend/routes/orgManagementRoutes.js b/backend/routes/orgManagementRoutes.js index 3a345dfe..a23ef02f 100644 --- a/backend/routes/orgManagementRoutes.js +++ b/backend/routes/orgManagementRoutes.js @@ -18,6 +18,10 @@ const adminTenantSummaryService = require('../services/adminTenantSummaryService const adminTenantEventsService = require('../services/adminTenantEventsService'); const adminTenantEventOperatorService = require('../services/adminTenantEventOperatorService'); const rootOperatorUsersService = require('../services/rootOperatorUsersService'); +const { + validateBetaFeatureKeysArray, + getBetaFeatureCatalogForApi +} = require('../constants/orgBetaFeatures'); const router = express.Router(); @@ -363,6 +367,22 @@ router.get('/config', verifyToken, async (req, res) => { } }); +// Catalog of org-level beta features (Atlas / admin tooling) +router.get('/beta-feature-catalog', verifyToken, requireAdmin, (req, res) => { + try { + res.status(200).json({ + success: true, + data: { features: getBetaFeatureCatalogForApi() } + }); + } catch (error) { + console.error('GET /org-management/beta-feature-catalog failed:', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to load beta feature catalog' + }); + } +}); + router.get('/onboarding-config', verifyToken, async (req, res) => { const { OrgManagementConfig } = getModels(req, 'OrgManagementConfig'); try { @@ -2341,6 +2361,48 @@ router.put('/organizations/:orgId', verifyToken, requireAdmin, async (req, res) } }); +// Replace enabled per-org beta feature keys (validated against platform registry) +router.patch('/organizations/:orgId/beta-features', verifyToken, requireAdmin, async (req, res) => { + const { Org } = getModels(req, 'Org'); + const { orgId } = req.params; + const enabledKeys = req.body && req.body.enabledKeys; + + const parsed = validateBetaFeatureKeysArray(enabledKeys); + if (!parsed.ok) { + return res.status(400).json({ + success: false, + message: parsed.error + }); + } + + try { + const org = await Org.findById(orgId); + if (!org) { + return res.status(404).json({ + success: false, + message: 'Organization not found' + }); + } + + org.betaFeatureKeys = parsed.keys; + await org.save(); + + console.log(`PATCH: /org-management/organizations/${orgId}/beta-features`); + res.status(200).json({ + success: true, + message: 'Beta features updated', + data: org + }); + } catch (error) { + console.error('Error updating org beta features:', error); + res.status(500).json({ + success: false, + message: 'Error updating beta features', + error: error.message + }); + } +}); + // PATCH lifecycle (platform admin) router.patch('/organizations/:orgId/lifecycle', verifyToken, requireAdmin, async (req, res) => { const { Org, OrgManagementConfig } = getModels(req, 'Org', 'OrgManagementConfig'); diff --git a/backend/routes/taskManagementRoutes.js b/backend/routes/taskManagementRoutes.js index 7e8de8ef..618a9bf2 100644 --- a/backend/routes/taskManagementRoutes.js +++ b/backend/routes/taskManagementRoutes.js @@ -22,6 +22,10 @@ const { getAllowedStatusKeys, DEFAULT_TASK_BOARD_STATUSES } = require('../services/taskBoardStatusUtils'); +const { + ORG_BETA_FEATURE_ORG_TASKS, + orgHasBetaFeature +} = require('../constants/orgBetaFeatures'); function toBoolean(value, defaultValue = false) { if (value === undefined || value === null || value === '') return defaultValue; @@ -182,6 +186,26 @@ async function loadOrgTaskBoardConfig(models, orgId) { return getResolvedTaskBoardStatuses(org); } +/** Returns false if response was already sent (404/403). */ +async function assertOrgTasksHubBeta(req, res, orgId) { + const models = getModels(req, 'Org'); + const org = await models.Org.findById(asObjectId(orgId)).select('betaFeatureKeys').lean(); + if (!org) { + res.status(404).json({ success: false, message: 'Organization not found' }); + return false; + } + if (!orgHasBetaFeature(org, ORG_BETA_FEATURE_ORG_TASKS)) { + res.status(403).json({ + success: false, + code: 'BETA_FEATURE_DISABLED', + featureKey: ORG_BETA_FEATURE_ORG_TASKS, + message: 'Organization task hub is not enabled for this organization' + }); + return false; + } + return true; +} + async function ensureOrgEventAccess(models, orgId, eventId) { if (!eventId) return null; const event = await models.Event.findOne({ @@ -448,6 +472,7 @@ router.get('/:orgId/tasks/hub', verifyToken, requireEventManagement('orgId'), as const models = getModels(req, 'Task', 'Event', 'Org'); try { + if (!(await assertOrgTasksHubBeta(req, res, orgId))) return; const tasks = await listTasks(models, orgId, { eventId: eventId === 'all' ? undefined : eventId, status, @@ -485,6 +510,7 @@ router.get('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement('org const models = getModels(req, 'Task', 'Org'); try { + if (!(await assertOrgTasksHubBeta(req, res, orgId))) return; const task = await findOneTaskDto(models, orgId, taskId, null); if (!task) { return res.status(404).json({ success: false, message: 'Task not found' }); @@ -503,6 +529,7 @@ router.post('/:orgId/tasks/hub', verifyToken, requireEventManagement('orgId'), a const models = getModels(req, 'Task', 'Org'); try { + if (!(await assertOrgTasksHubBeta(req, res, orgId))) return; if (!payload.title || !String(payload.title).trim()) { return res.status(400).json({ success: false, message: 'Task title is required' }); } @@ -549,6 +576,7 @@ router.put('/:orgId/tasks/hub/column-order', verifyToken, requireEventManagement const models = getModels(req, 'Task'); try { + if (!(await assertOrgTasksHubBeta(req, res, orgId))) return; if (!Array.isArray(taskIds)) { return res.status(400).json({ success: false, message: 'taskIds must be an array' }); } @@ -567,6 +595,7 @@ router.put('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement('org const models = getModels(req, 'Task', 'Event', 'Org'); try { + if (!(await assertOrgTasksHubBeta(req, res, orgId))) return; const task = await models.Task.findOne({ _id: asObjectId(taskId), orgId: asObjectId(orgId) @@ -600,6 +629,7 @@ router.delete('/:orgId/tasks/hub/:taskId', verifyToken, requireEventManagement(' const { orgId, taskId } = req.params; const models = getModels(req, 'Task'); try { + if (!(await assertOrgTasksHubBeta(req, res, orgId))) return; const deleted = await models.Task.findOneAndDelete({ _id: asObjectId(taskId), orgId: asObjectId(orgId) diff --git a/backend/schemas/org.js b/backend/schemas/org.js index 3937176b..7d3f21c8 100644 --- a/backend/schemas/org.js +++ b/backend/schemas/org.js @@ -339,6 +339,11 @@ const OrgSchema= new Schema({ ref: 'User', default: null }, + /** Enabled per-org beta feature keys (validated on write against platform registry). */ + betaFeatureKeys: { + type: [String], + default: [] + }, /** Custom task hub / event task Kanban columns (max 10). Empty/absent = platform defaults. */ taskBoardStatuses: { type: [{ diff --git a/backend/services/analyticsDashboardService.js b/backend/services/analyticsDashboardService.js index 95a84ddd..b24842b7 100644 --- a/backend/services/analyticsDashboardService.js +++ b/backend/services/analyticsDashboardService.js @@ -189,6 +189,13 @@ async function getOverviewMetrics(AnalyticsEvent, timeRange = '30d', platform) { $group: { _id: null, avgDuration: { $avg: '$duration' }, + durationPercentiles: { + $percentile: { + input: '$duration', + p: [0.5], + method: 'approximate' + } + }, sessionCount: { $sum: 1 } } }, @@ -197,12 +204,24 @@ async function getOverviewMetrics(AnalyticsEvent, timeRange = '30d', platform) { avgDurationMs: '$avgDuration', avgDurationSeconds: { $divide: ['$avgDuration', 1000] + }, + medianDurationSeconds: { + $divide: [ + { + $ifNull: [ + { $arrayElemAt: [{ $ifNull: ['$durationPercentiles', []] }, 0] }, + 0 + ] + }, + 1000 + ] } } } ]); const avgSessionDurationSeconds = sessionDurationResult[0]?.avgDurationSeconds || 0; + const medianSessionDurationSeconds = sessionDurationResult[0]?.medianDurationSeconds || 0; // Web vs Mobile breakdown // Unique users by platform type @@ -304,7 +323,8 @@ async function getOverviewMetrics(AnalyticsEvent, timeRange = '30d', platform) { sessions, pageViews, bounceRate: Math.round(bounceRate * 100) / 100, // Round to 2 decimals - avgSessionDuration: Math.round(avgSessionDurationSeconds) + avgSessionDuration: Math.round(avgSessionDurationSeconds), + medianSessionDuration: Math.round(medianSessionDurationSeconds) }; // Only include web/mobile breakdown when not filtering by platform if (!platform) { diff --git a/backend/tests/route-outcomes/analyticsDashboardRoutes.outcomes.test.js b/backend/tests/route-outcomes/analyticsDashboardRoutes.outcomes.test.js index 6fb0b6a5..6d593538 100644 --- a/backend/tests/route-outcomes/analyticsDashboardRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/analyticsDashboardRoutes.outcomes.test.js @@ -15,9 +15,11 @@ jest.mock('../../events/backendRoot', () => { const backendPath = path.resolve(__dirname, '../..'); const schema = require(path.join(backendPath, 'events/schemas/analyticsEvent')); const { getOrCreateModel } = require(path.join(backendPath, 'tests/helpers/mongoMemory')); + const userSchema = new (require('mongoose').Schema)({ createdAt: Date }, { collection: 'users' }); const getModels = (req, ...names) => { const models = { AnalyticsEvent: getOrCreateModel(req.db, 'AnalyticsEvent', schema, 'analytics_events'), + User: getOrCreateModel(req.db, 'User', userSchema, 'users'), }; return names.reduce((acc, name) => { if (models[name]) acc[name] = models[name]; @@ -90,4 +92,27 @@ describe('analytics dashboard route outcome tests (multi-tenant)', () => { expect(response.body.data.screens).toEqual(expect.any(Array)); expect(response.body.data.events).toEqual(expect.any(Array)); }); + + test('GET /dashboard/general-snapshot returns kpi, timeseries, mobile', async () => { + const response = await request(app).get('/dashboard/general-snapshot?timeRange=7d&platform=web'); + + expect(response.statusCode).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.kpiSummary).toBeDefined(); + expect(response.body.data.kpiSummary.current.uniqueDevices).toBeDefined(); + expect(response.body.data.kpiSummary.previous.uniqueDevices).toBeDefined(); + expect(response.body.data.kpiSummary.deltas.uniqueDevices).toBeDefined(); + expect(response.body.data.timeseries).toBeDefined(); + expect(response.body.data.mobileSummary).toBeDefined(); + }); + + test('GET /dashboard/overview with custom startDate/endDate returns custom timeRange', async () => { + const response = await request(app).get( + '/dashboard/overview?timeRange=30d&startDate=2025-01-01T00:00:00.000Z&endDate=2025-01-31T23:59:59.999Z' + ); + + expect(response.statusCode).toBe(200); + expect(response.body.data.timeRange).toBe('custom'); + expect(response.body.data.startDate).toBeDefined(); + }); }); diff --git a/frontend/src/assets/AdminBackground.png b/frontend/src/assets/AdminBackground.png new file mode 100644 index 00000000..f5c0e464 Binary files /dev/null and b/frontend/src/assets/AdminBackground.png differ diff --git a/frontend/src/components/Analytics/VisitsChart/AnalyticsChart.scss b/frontend/src/components/Analytics/VisitsChart/AnalyticsChart.scss index e7d33cb8..f15f2304 100644 --- a/frontend/src/components/Analytics/VisitsChart/AnalyticsChart.scss +++ b/frontend/src/components/Analytics/VisitsChart/AnalyticsChart.scss @@ -18,6 +18,7 @@ .header-content{ display:flex; flex-direction: row; + align-items: flex-end; } h2{ font-size: 0.85rem; diff --git a/frontend/src/constants/orgBetaFeatures.js b/frontend/src/constants/orgBetaFeatures.js new file mode 100644 index 00000000..2e1525c6 --- /dev/null +++ b/frontend/src/constants/orgBetaFeatures.js @@ -0,0 +1,21 @@ +/** + * Per-organization beta feature keys for Club Dash / Atlas UI. + * Keep keys in sync with Meridian/backend/constants/orgBetaFeatures.js + */ +export const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks'; + +export const ORG_BETA_FEATURE_CATALOG = { + [ORG_BETA_FEATURE_ORG_TASKS]: { + label: 'Organization task hub', + description: 'Tasks tab and org-level task hub APIs.', + clubDashMenuKey: 'tasks' + } +}; + +export const ORG_BETA_FEATURE_KEYS = Object.freeze(Object.keys(ORG_BETA_FEATURE_CATALOG)); + +export function orgHasBetaFeature(overview, featureKey) { + const keys = overview && overview.betaFeatureKeys; + if (!Array.isArray(keys)) return false; + return keys.includes(featureKey); +} diff --git a/frontend/src/pages/Admin/Admin.jsx b/frontend/src/pages/Admin/Admin.jsx index 4243a586..4e9d0c94 100644 --- a/frontend/src/pages/Admin/Admin.jsx +++ b/frontend/src/pages/Admin/Admin.jsx @@ -14,6 +14,8 @@ import AnalyticsDashboard from '../FeatureAdmin/AnalyticsDashboard/AnalyticsDash import MobileAnalyticsDashboard from '../FeatureAdmin/MobileAnalyticsDashboard/MobileAnalyticsDashboard'; import UserJourneyAnalytics from '../FeatureAdmin/UserJourneyAnalytics/UserJourneyAnalytics'; import IndividualUserJourney from '../FeatureAdmin/IndividualUserJourney/IndividualUserJourney'; +import OrgBetaFeatures from '../FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures'; +import AdminTenantDropdown from './AdminTenantDropdown/AdminTenantDropdown'; import AdminLogo from '../../assets/Brand Image/ADMIN.svg'; @@ -43,6 +45,11 @@ function Admin(){ icon: 'mdi:view-dashboard-variant', element: , }, + { + label: 'Beta features', + icon: 'mdi:flask-outline', + element: , + }, { label: 'Analytics', icon: 'bx:stats', @@ -101,6 +108,7 @@ function Admin(){ menuItems={menuItems} additionalClass='admin' logo={AdminLogo} + middleItem={} onBack={()=>navigate('/events-dashboard')} enableSubSidebar={true} > diff --git a/frontend/src/pages/Admin/Admin.scss b/frontend/src/pages/Admin/Admin.scss index 9697cd31..effd603b 100644 --- a/frontend/src/pages/Admin/Admin.scss +++ b/frontend/src/pages/Admin/Admin.scss @@ -1,9 +1,5 @@ .admin{ &.general-dash { - .dash-left { - width: 240px; - min-width: 240px; - } .dash-left .nav ul li p { white-space: nowrap; diff --git a/frontend/src/pages/Admin/AdminTenantDropdown/AdminTenantDropdown.jsx b/frontend/src/pages/Admin/AdminTenantDropdown/AdminTenantDropdown.jsx new file mode 100644 index 00000000..bf2a810a --- /dev/null +++ b/frontend/src/pages/Admin/AdminTenantDropdown/AdminTenantDropdown.jsx @@ -0,0 +1,174 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Icon } from '@iconify-icon/react'; +import useAuth from '../../../hooks/useAuth'; +import { + getTenantDefinitions, + getTenantRedirectUrl, + getCurrentTenantKey, + getCurrentTenantDisplayName, + setLastTenant, + setTenantConfigCache, +} from '../../../config/tenantRedirect'; +import defaultAvatar from '../../../assets/defaultAvatar.svg'; +import '../../ClubDash/OrgDropdown/OrgDropdown.scss'; +import './AdminTenantDropdown.scss'; + +const DEV_OVERRIDE_KEY = 'devTenantOverride'; + +function AdminTenantDropdown() { + const { user } = useAuth(); + const [showDrop, setShowDrop] = useState(false); + const [tenants, setTenants] = useState(() => getTenantDefinitions()); + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + let cancelled = false; + async function loadTenantConfig() { + try { + const response = await fetch('/api/tenant-config', { credentials: 'include' }); + if (!response.ok) return; + const payload = await response.json(); + if (!payload?.success || !Array.isArray(payload?.data?.tenants)) return; + setTenantConfigCache(payload.data.tenants); + if (!cancelled) { + setTenants(getTenantDefinitions()); + } + } catch (_) { + /* ignore */ + } + } + loadTenantConfig(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (showDrop) { + setShouldRender(true); + setIsAnimating(true); + } else { + setIsAnimating(false); + const timer = setTimeout(() => { + setShouldRender(false); + }, 200); + return () => clearTimeout(timer); + } + }, [showDrop]); + + const currentKey = getCurrentTenantKey(); + const displayLabel = getCurrentTenantDisplayName(); + const isLocalhostDev = + process.env.NODE_ENV !== 'production' && typeof window !== 'undefined' && window.location.hostname === 'localhost'; + const hasDevOverride = + isLocalhostDev && + (() => { + try { + return !!localStorage.getItem(DEV_OVERRIDE_KEY); + } catch (_) { + return false; + } + })(); + + const handleSelectTenant = useCallback( + (tenantKey) => { + if (!tenantKey || tenantKey === currentKey) { + setShowDrop(false); + return; + } + setLastTenant(tenantKey); + const path = `${window.location.pathname}${window.location.search || ''}`; + if (isLocalhostDev) { + try { + localStorage.setItem(DEV_OVERRIDE_KEY, tenantKey); + } catch (_) { + /* ignore */ + } + window.location.href = `${window.location.origin}${path}`; + return; + } + window.location.href = getTenantRedirectUrl( + tenantKey, + window.location.pathname, + window.location.search || '' + ); + }, + [currentKey, isLocalhostDev] + ); + + const handleClearDevOverride = useCallback(() => { + try { + localStorage.removeItem(DEV_OVERRIDE_KEY); + } catch (_) { + /* ignore */ + } + window.location.reload(); + }, []); + + return ( +
setShowDrop(!showDrop)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowDrop(!showDrop); + } + }} + aria-expanded={showDrop} + aria-haspopup="listbox" + > + +
+

{displayLabel}

+ {currentKey ? ( + {currentKey} + ) : null} +
+ + {shouldRender && ( +
e.stopPropagation()}> +
+ {tenants.map((tenant) => ( +
handleSelectTenant(tenant.tenantKey)} + > + +
+

{tenant.name}

+ {(tenant.status && tenant.status !== 'active') || tenant.location ? ( + + {tenant.status !== 'active' + ? `${String(tenant.status).replace(/_/g, ' ')} · ` + : ''} + {tenant.location || tenant.subdomain} + + ) : null} +
+
+ ))} +
+ {isLocalhostDev && hasDevOverride && ( + + )} +
+ )} +
+ ); +} + +export default AdminTenantDropdown; diff --git a/frontend/src/pages/Admin/AdminTenantDropdown/AdminTenantDropdown.scss b/frontend/src/pages/Admin/AdminTenantDropdown/AdminTenantDropdown.scss new file mode 100644 index 00000000..e9988254 --- /dev/null +++ b/frontend/src/pages/Admin/AdminTenantDropdown/AdminTenantDropdown.scss @@ -0,0 +1,77 @@ +.admin-tenant-dropdown { + &.org-dropdown { + min-width: 0; + max-width: 100%; + + > img { + flex-shrink: 0; + } + } + + &.org-dropdown h1 { + width: 100%; + max-width: 100%; + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .admin-tenant-dropdown__titles { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + flex: 1 1 0; + overflow: hidden; + padding-right: 2px; + } + + .admin-tenant-dropdown__key { + font-size: 11px; + color: var(--light-text, #6b7280); + font-weight: 500; + text-transform: lowercase; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .admin-tenant-dropdown__chevron { + flex-shrink: 0; + margin-left: auto; + } + + .admin-tenant-dropdown__option-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-width: 0; + + p { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .admin-tenant-dropdown__meta { + font-size: 12px; + font-weight: 400; + color: var(--light-text, #6b7280); + text-transform: none; + } + + .admin-tenant-dropdown__row-icon { + flex-shrink: 0; + color: var(--light-text, #64748b); + } + + .create-org { + cursor: pointer; + } +} diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx new file mode 100644 index 00000000..3f8959a2 --- /dev/null +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx @@ -0,0 +1,789 @@ +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { DateRangePicker } from 'rsuite'; +import { + format, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + startOfDay, + endOfDay, + parseISO, + subMonths, + addMonths, + subWeeks, + addWeeks, + subDays, + addDays +} from 'date-fns'; +import { Icon } from '@iconify-icon/react'; +import { useFetch } from '../../../../hooks/useFetch'; +import useAuth from '../../../../hooks/useAuth'; +import KpiCard from '../../../../components/Analytics/Dashboard/KpiCard'; +import ComparisonBadge from '../../../../components/Analytics/Dashboard/ComparisonBadge'; +import { + formatAnalyticsNumber, + formatAnalyticsDuration, + buildComparisonVisxSeries, + buildComparisonVisxSeriesForCalendarMonthView +} from '../../../../utils/analyticsDashboardUtils'; +import AdminPlatformMetricChart from './AdminPlatformMetricChart'; +import './AdminPlatformAnalytics.scss'; +import 'rsuite/DateRangePicker/styles/index.css'; + +const TREND_METRICS_PARAM = + 'screen_views,sessions,unique_visitors,explore_screen_views,new_users'; + +const TREND_METRIC_DEFS = [ + { key: 'screen_views', title: 'Page views', color: '#45A1FC' }, + { key: 'sessions', title: 'Sessions', color: '#8052FB' }, + { key: 'unique_visitors', title: 'Unique visitors', color: '#2BB673' }, + { key: 'explore_screen_views', title: 'Explore screen views', color: '#FA756D' }, + { key: 'new_users', title: 'New users', color: '#f59e0b' } +]; + +function computeRange(rangeMode, anchorDate) { + const now = new Date(); + if (rangeMode === 'all') { + const start = new Date(now.getTime() - 366 * 24 * 60 * 60 * 1000); + return { start, end: now }; + } + if (rangeMode === 'month') { + return { start: startOfMonth(anchorDate), end: endOfMonth(anchorDate) }; + } + if (rangeMode === 'week') { + return { + start: startOfWeek(anchorDate, { weekStartsOn: 0 }), + end: endOfWeek(anchorDate, { weekStartsOn: 0 }) + }; + } + const d = new Date(anchorDate); + d.setHours(0, 0, 0, 0); + const end = new Date(d); + end.setHours(23, 59, 59, 999); + return { start: d, end }; +} + +function isTypingTarget(target) { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; +} + +function parseBucketBoundary(bucketValue, boundary = 'start') { + const raw = String(bucketValue || ''); + if (!raw) return null; + + // Parse yyyy-mm-dd as local calendar-day boundaries. + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + const parsed = parseISO(`${raw}T12:00:00`); + if (Number.isNaN(parsed.getTime())) return null; + return boundary === 'end' ? endOfDay(parsed) : startOfDay(parsed); + } + + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + +function KpiInfoTitle({ label, info }) { + return ( + + {label} + + + {info} + + + ); +} + +function AdminPlatformAnalytics() { + const { isAuthenticated } = useAuth(); + const [rangeMode, setRangeMode] = useState('month'); + const [anchorDate, setAnchorDate] = useState(() => new Date()); + const [customRange, setCustomRange] = useState(null); + const [platformView, setPlatformView] = useState('web'); + const [previousPeriodMode, setPreviousPeriodMode] = useState('adjacent'); + /** 'average' | 'median' — session length KPI (same underlying first→last event window) */ + const [sessionDurationMode, setSessionDurationMode] = useState('average'); + const [debouncedAnchor, setDebouncedAnchor] = useState(() => new Date()); + const [debouncedMode, setDebouncedMode] = useState('month'); + const [debouncedCustomRange, setDebouncedCustomRange] = useState(null); + const [chartHoverSync, setChartHoverSync] = useState(null); + const [showFiltersPopup, setShowFiltersPopup] = useState(false); + const [isCustomRangePickerOpen, setIsCustomRangePickerOpen] = useState(false); + const handleChartHoverSyncChange = useCallback((signal) => { + if (!signal || signal.type === 'leave') { + setChartHoverSync(null); + return; + } + setChartHoverSync(signal); + }, []); + + useEffect(() => { + const t = setTimeout(() => { + setDebouncedAnchor(anchorDate); + setDebouncedMode(rangeMode); + setDebouncedCustomRange(customRange); + }, 350); + return () => clearTimeout(t); + }, [anchorDate, rangeMode, customRange]); + + const snapshotParams = useMemo(() => { + const { start, end } = + debouncedMode === 'custom' && debouncedCustomRange + ? debouncedCustomRange + : computeRange(debouncedMode, debouncedAnchor); + const prev = + previousPeriodMode === 'lastYear' + ? 'year_ago' + : 'adjacent'; + const granularity = debouncedMode === 'day' ? 'hour' : 'day'; + const params = { + startDate: start.toISOString(), + endDate: end.toISOString(), + previousPeriodMode: prev, + timeseriesGranularity: debouncedMode === 'all' ? 'week' : granularity + }; + if (platformView !== 'all') { + params.platform = platformView; + } + return params; + }, [debouncedAnchor, debouncedMode, debouncedCustomRange, platformView, previousPeriodMode]); + + const snapshotUrl = isAuthenticated ? '/dashboard/general-snapshot' : null; + + const { data: snapRes, loading, error, refetch } = useFetch(snapshotUrl, { + method: 'GET', + params: snapshotParams + }); + + const payload = snapRes?.data; + const kpi = payload?.kpiSummary; + const ts = payload?.timeseries; + const mobile = payload?.mobileSummary; + const kpiCurrent = kpi?.current || {}; + + const comparisonEnabled = previousPeriodMode !== 'none'; + + const prevTimeseriesParams = useMemo(() => { + if (!comparisonEnabled || !kpi?.windows?.previous || !ts?.granularity) return null; + const w = kpi.windows.previous; + const p = { + startDate: w.start, + endDate: w.end, + granularity: ts.granularity, + metrics: TREND_METRICS_PARAM + }; + if (platformView !== 'all') { + p.platform = platformView; + } + return p; + }, [comparisonEnabled, kpi, ts?.granularity, platformView]); + + const prevTsUrl = isAuthenticated && comparisonEnabled && prevTimeseriesParams ? '/dashboard/timeseries' : null; + const { data: prevTsRes, loading: prevTsLoading } = useFetch(prevTsUrl, { + method: 'GET', + params: prevTimeseriesParams || {} + }); + + const comparePeriodLabel = + previousPeriodMode === 'lastYear' ? 'Same window last year' : 'Previous period'; + const metricTotalsFromKpi = useMemo( + () => ({ + screen_views: kpiCurrent.pageViews, + sessions: kpiCurrent.sessions, + unique_visitors: kpiCurrent.uniqueUsers, + explore_screen_views: kpiCurrent.exploreScreenViews ?? kpiCurrent.explore_screen_views, + new_users: kpiCurrent.newUsers + }), + [kpiCurrent] + ); + + const trendCharts = useMemo(() => { + const prevInner = prevTsRes?.data; + const useFullMonthDayDomain = debouncedMode === 'month' && ts?.granularity === 'day'; + + return TREND_METRIC_DEFS.map((def) => { + const curRows = ts?.series?.[def.key]; + const pRows = comparisonEnabled ? prevInner?.series?.[def.key] : null; + const curRowsExcludingEnd = Array.isArray(curRows) && curRows.length > 1 ? curRows.slice(0, -1) : curRows; + const pRowsExcludingEnd = Array.isArray(pRows) && pRows.length > 1 ? pRows.slice(0, -1) : pRows; + + if (useFullMonthDayDomain && curRowsExcludingEnd?.length) { + const spec = buildComparisonVisxSeriesForCalendarMonthView( + debouncedAnchor, + curRowsExcludingEnd, + pRowsExcludingEnd, + def.color, + { thisPeriod: 'This period', compare: comparePeriodLabel }, + { + compareSubMonths: previousPeriodMode === 'lastYear' ? 12 : 1, + excludePreviousPeriodEnd: false + } + ); + return { + def, + series: spec.series, + xDomain: Array.isArray(spec.xDomain) && spec.xDomain.length > 1 ? spec.xDomain.slice(0, -1) : spec.xDomain, + showEndGlyph: spec.showEndGlyph, + totalValue: + metricTotalsFromKpi[def.key] ?? + curRowsExcludingEnd.reduce((sum, row) => sum + (Number(row?.value) || 0), 0) + }; + } + + const { series } = buildComparisonVisxSeries( + curRowsExcludingEnd, + pRowsExcludingEnd, + def.color, + { thisPeriod: 'This period', compare: comparePeriodLabel }, + { excludePreviousPeriodEnd: false } + ); + return { + def, + series, + xDomain: undefined, + showEndGlyph: false, + totalValue: + metricTotalsFromKpi[def.key] ?? + (curRowsExcludingEnd || []).reduce((sum, row) => sum + (Number(row?.value) || 0), 0) + }; + }); + }, [ts, prevTsRes, comparisonEnabled, comparePeriodLabel, debouncedMode, debouncedAnchor, previousPeriodMode, metricTotalsFromKpi]); + + const showCompare = previousPeriodMode !== 'none' && kpi?.deltas; + + const handleRangeModeChange = useCallback((mode) => { + setRangeMode(mode); + if (mode !== 'custom') { + setCustomRange(null); + } + if (mode === 'all') return; + if (mode === 'custom') return; + const now = new Date(); + if (mode === 'month') setAnchorDate(startOfMonth(now)); + else if (mode === 'week') setAnchorDate(startOfWeek(now, { weekStartsOn: 0 })); + else setAnchorDate(now); + }, []); + const handleTrendRangeSelect = useCallback(({ startXValue, endXValue }) => { + if (!startXValue || !endXValue) return; + const normalizedStart = parseBucketBoundary(startXValue, 'start'); + const normalizedEnd = parseBucketBoundary(endXValue, 'end'); + if (!normalizedStart || !normalizedEnd) return; + setCustomRange({ start: normalizedStart, end: normalizedEnd }); + setRangeMode('custom'); + setAnchorDate(normalizedStart); + }, []); + const resetCustomRange = useCallback(() => { + setCustomRange(null); + setRangeMode('month'); + setAnchorDate(startOfMonth(new Date())); + }, []); + const applyCustomDateRange = useCallback((nextRange, shouldClosePicker = false) => { + if (!Array.isArray(nextRange) || nextRange.length !== 2 || !nextRange[0] || !nextRange[1]) return; + const [start, end] = nextRange; + const startDate = new Date(start); + const endDate = new Date(end); + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return; + if (endDate <= startDate) return; + setCustomRange({ start: startDate, end: endDate }); + setRangeMode('custom'); + setAnchorDate(startDate); + if (shouldClosePicker) { + setIsCustomRangePickerOpen(false); + } + }, []); + const handleCustomDateRangeChange = useCallback((nextRange) => { + applyCustomDateRange(nextRange, true); + }, [applyCustomDateRange]); + const handleCustomDateRangeSelect = useCallback((nextRange) => { + applyCustomDateRange(nextRange, true); + }, [applyCustomDateRange]); + + const navPrev = useCallback(() => { + if (rangeMode === 'month') setAnchorDate((d) => subMonths(startOfMonth(d), 1)); + else if (rangeMode === 'week') setAnchorDate((d) => subWeeks(startOfWeek(d, { weekStartsOn: 0 }), 1)); + else if (rangeMode === 'day') setAnchorDate((d) => subDays(d, 1)); + }, [rangeMode]); + const navNext = useCallback(() => { + if (rangeMode === 'month') setAnchorDate((d) => addMonths(startOfMonth(d), 1)); + else if (rangeMode === 'week') setAnchorDate((d) => addWeeks(startOfWeek(d, { weekStartsOn: 0 }), 1)); + else if (rangeMode === 'day') setAnchorDate((d) => addDays(d, 1)); + }, [rangeMode]); + + useEffect(() => { + const handleKeyDown = (event) => { + if (rangeMode === 'all' || isTypingTarget(event.target)) return; + if (showFiltersPopup && event.key === 'Escape') { + setShowFiltersPopup(false); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + navPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + navNext(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [rangeMode, navPrev, navNext, showFiltersPopup]); + + if (!isAuthenticated) { + return null; + } + + if (loading && !payload) { + return
Loading platform analytics…
; + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + const cur = kpi?.current || {}; + const prev = kpi?.previous || {}; + const d = kpi?.deltas || {}; + + const sessionSeconds = + sessionDurationMode === 'median' + ? cur.medianSessionDuration ?? cur.avgSessionDuration + : cur.avgSessionDuration; + const sessionPrevSeconds = + sessionDurationMode === 'median' + ? prev.medianSessionDuration ?? prev.avgSessionDuration + : prev.avgSessionDuration; + const sessionDelta = + sessionDurationMode === 'median' + ? d.medianSessionDuration ?? d.avgSessionDuration + : d.avgSessionDuration; + const uniqueDevicesCurrentRaw = cur.uniqueDevices ?? cur.unique_devices ?? mobile?.overview?.uniqueDevices; + const uniqueDevicesPrevious = prev.uniqueDevices ?? prev.unique_devices; + const uniqueDevicesDelta = d.uniqueDevices ?? d.unique_devices; + const hasUniqueDevicesMetric = uniqueDevicesCurrentRaw !== undefined && uniqueDevicesCurrentRaw !== null; + const windowLabelMap = { + month: 'Month', + week: 'Week', + day: 'Day', + custom: 'Custom', + all: 'All time' + }; + const comparisonLabelMap = { + none: 'No comparison', + adjacent: 'Previous period', + lastYear: 'Same window last year' + }; + + return ( +
+
+
+
+ {rangeMode !== 'all' && rangeMode !== 'custom' ? ( +
+ + + {rangeMode === 'month' && + `${format(startOfMonth(anchorDate), 'MMM d')} – ${format(endOfMonth(anchorDate), 'MMM d, yyyy')}`} + {rangeMode === 'week' && + `${format(startOfWeek(anchorDate, { weekStartsOn: 0 }), 'MMM d')} – ${format( + endOfWeek(anchorDate, { weekStartsOn: 0 }), + 'MMM d, yyyy' + )}`} + {rangeMode === 'day' && format(anchorDate, 'MMM d, yyyy')} + + +
+ ) : rangeMode === 'custom' && customRange ? ( +
+ + {`${format(customRange.start, 'MMM d, yyyy')} – ${format(customRange.end, 'MMM d, yyyy')}`} + +
+ ) : ( +
+ All time range (last 366 days) +
+ )} +
+ {['month', 'week', 'day'].map((m) => ( + + ))} +
+
+ Window: {windowLabelMap[rangeMode] || rangeMode} + Comparison: {comparisonLabelMap[previousPeriodMode] || previousPeriodMode} + Surface: {platformView === 'all' ? 'All platforms' : platformView} + Session stat: {sessionDurationMode === 'median' ? 'Median' : 'Average'} +
+
+
+
+ setIsCustomRangePickerOpen(true)} + onClose={() => setIsCustomRangePickerOpen(false)} + /> +
+
+ + {showFiltersPopup ? ( +
+
+ Window +
+ {['month', 'week', 'day', 'all'].map((m) => ( + + ))} +
+
+
+ Comparison +
+ {[ + { id: 'none', label: 'No comparison' }, + { id: 'adjacent', label: 'Previous period' }, + { id: 'lastYear', label: 'Same window last year' } + ].map(({ id, label }) => ( + + ))} +
+
+
+ Surface +
+ {['web', 'mobile', 'all'].map((p) => ( + + ))} +
+
+
+ Session length +
+ {[ + { id: 'average', label: 'Average' }, + { id: 'median', label: 'Median' } + ].map(({ id, label }) => ( + + ))} +
+
+
+ +
+
+ ) : null} +
+
+
+ +
+ + } + value={formatAnalyticsNumber(cur.totalUsers)} + subtitle={Cumulative (User model)} + icon="mdi:account-group" + /> + + } + value={formatAnalyticsNumber(cur.newUsers)} + subtitle={ + showCompare ? ( + + ) : ( + Registered in window + ) + } + icon="mdi:account-plus" + /> + + } + value={formatAnalyticsNumber(cur.uniqueUsers)} + subtitle={ + showCompare ? : Pipeline + } + icon="mdi:account-multiple" + /> + + } + value={hasUniqueDevicesMetric ? formatAnalyticsNumber(uniqueDevicesCurrentRaw) : '—'} + subtitle={ + showCompare && uniqueDevicesPrevious != null && hasUniqueDevicesMetric ? ( + + ) : ( + + {hasUniqueDevicesMetric + ? 'Distinct devices in window' + : 'Not available for this surface yet'} + + ) + } + icon="mdi:devices" + /> + + } + value={formatAnalyticsNumber(cur.sessions)} + subtitle={ + showCompare ? : Distinct session_id + } + icon="mdi:chart-timeline" + /> + + } + value={formatAnalyticsNumber(cur.pageViews)} + subtitle={ + showCompare ? : screen_view + } + icon="mdi:eye" + /> + + } + value={formatAnalyticsDuration(sessionSeconds)} + subtitle={ + showCompare ? ( + + ) : ( + + {sessionDurationMode === 'median' + ? 'Median · first to last event' + : 'Mean · first to last event'} + + ) + } + icon="mdi:clock-outline" + /> +
+
+ +
+
+

Trends

+ +
+
+ {trendCharts.map(({ def, series, xDomain, showEndGlyph, totalValue }) => ( + + ))} +
+
+ +
+

+ Mobile app snapshot +

+

iOS + Android (same pipeline as web). API errors are a release-health signal.

+
+
+ Sessions +
{formatAnalyticsNumber(mobile?.overview?.sessions)}
+
+
+ Screen views +
{formatAnalyticsNumber(mobile?.overview?.pageViews)}
+
+
+ Unique users +
{formatAnalyticsNumber(mobile?.overview?.uniqueUsers)}
+
+
+
+
+

Top screens

+
    + {(mobile?.topScreens || []).slice(0, 8).map((row, i) => ( +
  • + {row.screen} + {formatAnalyticsNumber(row.views)} +
  • + ))} +
+
+
+

Top events

+
    + {(mobile?.topEvents || []).slice(0, 8).map((row, i) => ( +
  • + {row.event} + {formatAnalyticsNumber(row.count)} +
  • + ))} +
+
+
+
+
+

Versions

+
    + {(mobile?.versionAdoption || []).slice(0, 6).map((row, i) => ( +
  • + + {row.platform} {row.app_version || '—'} + + {formatAnalyticsNumber(row.users)} +
  • + ))} +
+
+
+

API errors by version

+
    + {(mobile?.apiErrorsByVersion || []).map((row, i) => ( +
  • + {row.app_version} + {formatAnalyticsNumber(row.count)} +
  • + ))} +
+
+
+
+ +
+ Open full web analytics tables +
+ +

+ When “Exclude admins from tracking” is enabled in Beacon, admin users are not counted in pipeline metrics. Totals from + the User collection are unaffected. +

+
+ ); +} + +export default AdminPlatformAnalytics; diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss new file mode 100644 index 00000000..9bf8ebbd --- /dev/null +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss @@ -0,0 +1,501 @@ +.admin-platform-analytics { + + .top-content { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0 20px 20px 20px; + } + + &.loading, + &.error { + padding: 1rem 0; + color: #6b7280; + } + + &__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: flex-start; + gap: 0.75rem; + margin-bottom: 0.35rem; + } + + &__toolbar-left { + flex: 1 1 420px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + &__toolbar-right { + margin-left: auto; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__date-range-picker-wrap { + min-width: 300px; + + .rs-picker-toggle.rs-btn { + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.12); + min-height: 34px; + font-size: 0.8125rem; + background: #fff; + } + } + + &__filters-trigger-wrap { + position: relative; + } + + &__filters-trigger { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.6rem; + font-size: 0.8125rem; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + cursor: pointer; + + iconify-icon { + font-size: 0.95rem; + } + } + + &__filters-popup { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 10; + width: min(500px, 92vw); + padding: 0.65rem; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 10px; + background: var(--background, #fff); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16); + display: flex; + flex-direction: column; + gap: 0.6rem; + } + + &__popup-group { + display: flex; + flex-direction: column; + gap: 0.35rem; + + > span { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6b7280; + } + } + + &__popup-buttons { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + + button { + padding: 0.35rem 0.65rem; + font-size: 0.8125rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: #fff; + cursor: pointer; + + &.active { + border-color: #45a1fc; + color: #1e40af; + background: rgba(69, 161, 252, 0.08); + } + } + } + + &__popup-footer { + display: flex; + justify-content: flex-end; + + button { + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: #fff; + cursor: pointer; + } + } + + &__select { + padding: 0.35rem 0.5rem; + font-size: 0.8125rem; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + } + + &__nav { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; + font-size: 0.9375rem; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + background: #fff; + cursor: pointer; + } + } + + &__nav--inline { + margin-bottom: 0; + font-size: 0.88rem; + min-width: 0; + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__nav--all { + color: #6b7280; + } + + &__active-filters { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + + span { + font-size: 0.72rem; + color: #475569; + background: rgba(148, 163, 184, 0.13); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 999px; + padding: 0.18rem 0.5rem; + white-space: nowrap; + } + } + + &__quick-window-buttons { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + + button { + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: #fff; + cursor: pointer; + text-transform: capitalize; + + &.active { + border-color: #45a1fc; + color: #1e40af; + background: rgba(69, 161, 252, 0.08); + } + } + } + + &__kpis.analytics-container { + margin-bottom: 1rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 16px; + + .kpi-card { + width: 100%; + box-sizing: border-box; + } + } + + &__kpi-title { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } + + &__kpi-info-wrap { + position: relative; + display: inline-flex; + align-items: center; + color: #94a3b8; + cursor: help; + + iconify-icon { + font-size: 0.85rem; + } + + &:hover .admin-platform-analytics__kpi-info-tooltip { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + } + + &__kpi-info-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + width: min(280px, 70vw); + padding: 0.45rem 0.55rem; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.14); + background: var(--background, #fff); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16); + color: var(--text, #111827); + font-size: 0.72rem; + line-height: 1.35; + font-weight: 400; + z-index: 12; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; + } + + &__chart-wrap { + margin-bottom: 1rem; + padding: 0 !important; + background: transparent !important; + border: none !important; + overflow: hidden; + } + + &__chart-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.6rem; + padding: 0 20px; + + h3 { + margin: 0; + } + } + + &__chart-reset-btn { + padding: 0.3rem 0.65rem; + font-size: 0.78rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: #fff; + cursor: pointer; + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + } + + &__chart { + height: 280px; + position: relative; + } + + &__mobile { + margin-bottom: 1rem; + + h3 { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0 0 0.5rem 0; + font-size: 1rem; + } + + h4 { + margin: 0 0 0.35rem 0; + font-size: 0.875rem; + color: #374151; + } + } + + &__mobile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; + + strong { + display: block; + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; + } + + div div { + font-size: 1.125rem; + font-weight: 600; + } + } + + &__two-col { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 0.75rem; + + ul { + list-style: none; + margin: 0; + padding: 0; + font-size: 0.8125rem; + } + + li { + display: flex; + justify-content: space-between; + gap: 0.5rem; + padding: 0.25rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + } + + &__links { + margin-bottom: 0.75rem; + font-size: 0.875rem; + + a { + color: #2563eb; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__footnote { + font-size: 0.75rem; + color: #9ca3af; + margin: 0; + max-width: 52rem; + line-height: 1.4; + } + + &__btn { + margin-top: 0.5rem; + padding: 0.4rem 0.75rem; + border-radius: 6px; + border: 1px solid #d1d5db; + background: #fff; + cursor: pointer; + } + + &__charts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr)); + gap: 0; + border-top: 1px solid var(--lighterborder); + + > * { + border-right: 1px solid var(--lighterborder); + border-bottom: 1px solid var(--lighterborder); + } + } + + /* Match legacy `.visit-chart` / AnalyticsChart look (General admin) */ + .admin-platform-trend-chart.visit-chart { + width: 100%; + padding: 14px 14px 10px; + background: transparent; + border-radius: 0; + box-shadow: none; + border: none; + box-sizing: border-box; + margin-bottom: 0; + } + + .admin-platform-trend-chart .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + margin-left: 8px; + } + + .admin-platform-trend-chart .header h2 { + font-size: 0.85rem; + font-weight: 500; + color: var(--lighter-text); + font-family: 'Inter', sans-serif; + margin: 0; + text-transform: uppercase; + } + + .admin-platform-trend-chart__total { + margin-right: 10px; + font-size: 1rem; + font-weight: 600; + color: var(--text, #111827); + + } + + .admin-platform-trend-chart__loading { + font-size: 0.75rem; + color: #94a3b8; + white-space: nowrap; + } + + .chart-empty-visx { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: #94a3b8; + font-size: 0.875rem; + } + + /* Visx tooltip (EventDashboardChart) — EventDashboard.scss may not load here */ + .rsvp-chart-tooltip { + padding: 8px 10px; + background: var(--background, #fff); + border: 1px solid var(--lighterborder, #e5e7eb); + border-radius: 8px; + font-size: 12px; + box-shadow: var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.08)); + } + + .rsvp-chart-tooltip-date { + font-weight: 600; + margin-bottom: 4px; + color: var(--text, #111827); + } + + .rsvp-chart-tooltip-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + color: var(--lighter-text, #64748b); + } + + .rsvp-chart-tooltip-dot { + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; + } +} diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx new file mode 100644 index 00000000..e74ae905 --- /dev/null +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import EventDashboardChart from '../../../ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart'; +import { formatBucketAxisLabel } from '../../../../utils/analyticsDashboardUtils'; + +/** + * Single metric chart styled like legacy `.visit-chart` (General / Analytics), + * backed by visx via EventDashboardChart. + */ +function AdminPlatformMetricChart({ + title, + totalValue, + series, + granularity = 'day', + height = 200, + emptyMessage = 'No data', + loadingCompare = false, + xDomain, + showEndGlyph = false, + syncId, + hoverSyncSignal, + onHoverSyncChange, + enableRangeSelection = false, + onRangeSelect +}) { + const xTickFormat = React.useCallback((x) => formatBucketAxisLabel(x, granularity), [granularity]); + + const isEmpty = !series?.length || series.every((s) => !s?.data?.length); + + return ( +
+
+
+ {totalValue != null ? ( +
{totalValue}
+ ) : null} +

{title}

+
+ {loadingCompare ? Loading comparison… : null} +
+ {isEmpty ? ( +
{emptyMessage}
+ ) : ( + + )} +
+ ); +} + +export default AdminPlatformMetricChart; diff --git a/frontend/src/pages/Admin/General/General.jsx b/frontend/src/pages/Admin/General/General.jsx index 60a1a2a2..10e1c7d0 100644 --- a/frontend/src/pages/Admin/General/General.jsx +++ b/frontend/src/pages/Admin/General/General.jsx @@ -1,135 +1,21 @@ -import React, { useState } from 'react'; -import { Icon } from '@iconify-icon/react'; +import React from 'react'; import './General.scss'; -import GradientHeader from '../../../assets/Gradients/ApprovalGrad.png'; +import { useGradient } from '../../../hooks/useGradient'; import SiteHealth from './SiteHealth/SiteHealth'; -import Analytics from '../../../components/Analytics/Analytics'; -import { useNotification } from '../../../NotificationContext'; -import apiRequest from '../../../utils/postRequest'; -import { useFetch } from '../../../hooks/useFetch'; +import AdminPlatformAnalytics from './AdminPlatformAnalytics/AdminPlatformAnalytics'; function General() { - const { addNotification } = useNotification(); - const [classroomBuildingMigrating, setClassroomBuildingMigrating] = useState(false); - const [savingAutoClaim, setSavingAutoClaim] = useState(false); - const { data: configData, refetch: refetchConfig } = useFetch('/org-management/config'); - const config = configData?.data; - const autoClaimEnabled = config?.autoClaimEnabled ?? false; - - const handleAutoClaimToggle = async (checked) => { - setSavingAutoClaim(true); - try { - const res = await apiRequest('/org-management/config', { autoClaimEnabled: checked }, { method: 'PUT' }); - if (res?.success) { - addNotification({ title: 'Saved', message: 'Auto-claim setting updated', type: 'success' }); - refetchConfig(); - } else { - addNotification({ title: 'Error', message: res?.message || 'Failed to save', type: 'error' }); - } - } catch (e) { - addNotification({ title: 'Error', message: e?.message || 'Failed to save', type: 'error' }); - } finally { - setSavingAutoClaim(false); - } - }; - - const runClassroomBuildingMigration = async () => { - if ( - !window.confirm( - 'Create Building documents from classroom building names and switch classrooms to ObjectId refs? This is intended to run once per school database. Continue?' - ) - ) { - return; - } - setClassroomBuildingMigrating(true); - try { - const res = await apiRequest('/admin/migrate-classroom-building-refs', {}); - if (res?.success) { - const d = res.data || {}; - if (d.skipped) { - addNotification({ - title: 'Already completed', - message: d.reason === 'already_run' ? 'This migration was already run for this tenant.' : 'Skipped.', - type: 'info', - }); - } else { - addNotification({ - title: 'Migration complete', - message: `Rooms updated: ${d.classroomsUpdated ?? 0}. New buildings: ${d.buildingsCreatedCount ?? 0}.`, - type: 'success', - }); - } - } else { - addNotification({ - title: 'Migration failed', - message: res?.message || res?.error || 'Unknown error', - type: 'error', - }); - } - } catch (e) { - addNotification({ - title: 'Migration failed', - message: e?.message || 'Request failed', - type: 'error', - }); - } finally { - setClassroomBuildingMigrating(false); - } - }; - + const { AdminGrad } = useGradient(); return ( -
- -
+
+ +

Administrator

-
+

Manage your platform and track key metrics

+
- -
-

Auto-claim event registrations

-

When enabled, anonymous event registrations are automatically linked to user accounts when they sign up with a matching email. Applies to all events with registration forms.

- -
-
-

Classrooms: Building references

-

- Backfills the buildings collection from each classroom's legacy string building field, - then stores an ObjectId reference on the classroom. Guarded so it normally runs once per tenant; - support can re-run by calling the same endpoint with a JSON body of force: true if needed. - Upgrading an existing database: run the CLI migration once before restarting servers on this - release (see backend/migrations/migrateClassroomBuildingRefs.js header), or run this button - immediately after deploy before other traffic hits room APIs. -

- -
- +
diff --git a/frontend/src/pages/Admin/General/General.scss b/frontend/src/pages/Admin/General/General.scss index 6df238a6..85fab2a4 100644 --- a/frontend/src/pages/Admin/General/General.scss +++ b/frontend/src/pages/Admin/General/General.scss @@ -2,7 +2,6 @@ z-index: 1; height:100vh; box-sizing: border-box; - padding: 25px 30px; width:100%; .general-content{ display: flex; @@ -11,20 +10,19 @@ .analytics-container{ display: flex; gap: 20px; - padding: 10px; box-sizing: border-box; flex-wrap: wrap; } .admin-migration-section { - padding: 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 8px; + padding: 20px; background: rgba(0, 0, 0, 0.02); h3 { - margin: 0 0 0.25rem 0; - font-size: 1rem; + margin: 0 0 1rem 0; + font-size: 1.2rem; + color: var(--text); + } } diff --git a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx index 8925fa0d..a31dde8f 100644 --- a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx +++ b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx @@ -244,7 +244,7 @@ function OperatorHubMode() { }; return ( -
+
-
+

Community organizer

+
+

Choose how staff experience Meridian on /root-dashboard. Community organizer mode is the home for makerspaces and smaller managed communities; classic mode keeps the full multi-app setup. Club and member apps are unchanged.

-
-
- {loading && !config ? ( -

- Loading… -

- ) : ( - <> -
    - {MODES.map((m) => ( -
  • - -
  • - ))} -
-
- - {applySuggestedDefaults && current !== 'engagement_hub' && ( -

- +

+ {loading && !config ? ( +

+ Loading… +

+ ) : ( + <> +
    + {MODES.map((m) => ( +
  • + +
  • + ))} +
+
+
- - )} + + {applySuggestedDefaults && current !== 'engagement_hub' && ( +

+ + + Switching back to Classic later will not undo + these defaults—you will need to restore org approval, request types, and + verification settings manually if required. + +

+ )} +
+ + )} +
); diff --git a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.scss b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.scss index 9af8dce1..84b13633 100644 --- a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.scss +++ b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.scss @@ -1,27 +1,11 @@ .operator-hub-mode { - position: relative; - min-height: 100%; - - .grad { - position: absolute; - top: 0; - right: 0; - width: min(420px, 55%); - max-height: 200px; - object-fit: contain; - pointer-events: none; - opacity: 0.85; - z-index: 0; - } - .simple-header { - position: relative; - z-index: 1; - padding: 1rem 0 0.5rem; - h1 { - margin: 0 0 0.5rem; - } + .content { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0 40px 20px 40px; } &__lede { diff --git a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx index 3b7322c9..831a9778 100644 --- a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx +++ b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx @@ -1,16 +1,22 @@ import React, { useState, useCallback, useEffect } from 'react'; +import { Icon } from '@iconify-icon/react'; import { useFetch, authenticatedRequest } from '../../../hooks/useFetch'; import { setTenantConfigCache } from '../../../config/tenantRedirect'; -import GradientHeader from '../../../assets/Gradients/ApprovalGrad.png'; +import { useNotification } from '../../../NotificationContext'; +import { useGradient } from '../../../hooks/useGradient'; +import apiRequest from '../../../utils/postRequest'; import '../General/General.scss'; import './PlatformAdminsPage.scss'; function PlatformAdminsPage() { + const { addNotification } = useNotification(); + const { AdminGrad } = useGradient(); const [addEmail, setAddEmail] = useState(''); const [adding, setAdding] = useState(false); const [mutationError, setMutationError] = useState(null); const [tenantDrafts, setTenantDrafts] = useState({}); const [savingTenants, setSavingTenants] = useState(false); + const [savingAutoClaim, setSavingAutoClaim] = useState(false); const { data: listResponse, loading, error: fetchError, refetch } = useFetch('/admin/platform-admins'); const list = listResponse?.success ? (listResponse.data || []) : []; @@ -22,6 +28,27 @@ function PlatformAdminsPage() { } = useFetch('/admin/tenant-config'); const tenantRows = tenantConfigResponse?.success ? (tenantConfigResponse.data?.tenants || []) : []; + const { data: orgConfigResponse, refetch: refetchOrgConfig } = useFetch('/org-management/config'); + const orgConfig = orgConfigResponse?.data; + const autoClaimEnabled = orgConfig?.autoClaimEnabled ?? false; + + const handleAutoClaimToggle = useCallback(async (checked) => { + setSavingAutoClaim(true); + try { + const res = await apiRequest('/org-management/config', { autoClaimEnabled: checked }, { method: 'PUT' }); + if (res?.success) { + addNotification({ title: 'Saved', message: 'Auto-claim setting updated', type: 'success' }); + refetchOrgConfig(); + } else { + addNotification({ title: 'Error', message: res?.message || 'Failed to save', type: 'error' }); + } + } catch (e) { + addNotification({ title: 'Error', message: e?.message || 'Failed to save', type: 'error' }); + } finally { + setSavingAutoClaim(false); + } + }, [addNotification, refetchOrgConfig]); + useEffect(() => { const nextDrafts = {}; tenantRows.forEach((tenant) => { @@ -109,14 +136,31 @@ function PlatformAdminsPage() { const error = fetchError || tenantConfigFetchError || mutationError; return ( -
- -
+
+ +

Platform Admins

-

Users with platform_admin can access admin features on every tenant.

-
+

Users with platform_admin can access admin features on every tenant.

+
{error &&
{error}
} +
+

Auto-claim event registrations

+

+ When enabled, anonymous event registrations are automatically linked to user accounts when they sign up + with a matching email. Applies tenant-wide to events that use registration forms. +

+ +
{ + if (orgData.loading) return; + if (!tasksComingSoon) return; + const page = parseInt(searchParams.get('page') ?? '0', 10); + if (page !== 2) return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set('page', '0'); + return next; + }, + { replace: true } + ); + }, [orgData.loading, tasksComingSoon, searchParams, setSearchParams]); const menuItems = useMemo(() => { const baseMenuItems = [ @@ -315,7 +333,8 @@ function ClubDash(){ label: 'Tasks', icon: 'mdi:check-all', key: 'tasks', - element: ( + comingSoon: tasksComingSoon, + element: tasksComingSoon ? null : ( Loading tasks…
}> @@ -454,7 +473,8 @@ function ClubDash(){ userPermissions, adminBypass, isAdminView, - isSiteAdmin + isSiteAdmin, + tasksComingSoon ]); menuItemsRef.current = menuItems; @@ -512,10 +532,14 @@ function ClubDash(){ const isPending = org?.approvalStatus === 'pending'; const allowedActions = configData?.orgApproval?.pendingOrgLimits?.allowedActions ?? ['view_page', 'edit_profile', 'manage_members']; + const hasOrgTasksBeta = + adminBypass || + orgHasBetaFeature(org, ORG_BETA_FEATURE_ORG_TASKS); + const pageToAction = [ 'view_page', // 0: Dashboard 'create_events', // 1: Events - 'create_events', // 2: Tasks (org/event ops; same gate as Events management) + hasOrgTasksBeta ? 'create_events' : 'view_page', // 2: Tasks hub beta 'post_messages', // 3: Announcements 'manage_members', // 4: Members 'edit_profile', // 5: Settings diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss index ce0614f0..467f9aef 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss @@ -1427,6 +1427,25 @@ pointer-events: none; } +/* Visx renders vertical crosshair in a portal (sibling to chart), not inside .chart-container-visx */ +.visx-crosshair.visx-crosshair-vertical { + z-index: 10001 !important; + pointer-events: none; +} + +.visx-crosshair.visx-crosshair-vertical line { + stroke: #94a3b8 !important; + stroke-width: 1px !important; + stroke-opacity: 1 !important; + stroke-dasharray: 1 4 !important; + stroke-linecap: round !important; +} + +.visx-tooltip-glyph { + z-index: 10003 !important; + pointer-events: none; +} + .rsvp-chart-tooltip { padding: 0.5rem 0.75rem; background: var(--background); diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart.jsx index aee0e898..16dae97d 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart.jsx @@ -8,6 +8,8 @@ import { GlyphSeries, buildChartTheme, Tooltip, + DataContext, + TooltipContext } from '@visx/xychart'; import { curveMonotoneX } from '@visx/curve'; import '../../EventDashboard.scss'; @@ -25,8 +27,385 @@ const chartTheme = buildChartTheme({ tickLength: 0, }); -const MAX_X_TICKS = 8; -const MAX_X_TICKS_DENSE = 6; +function bandCenterX(xScale, bandwidth, xCat) { + return Number(xScale(xCat)) + bandwidth / 2; +} + +const CHART_BAND_PADDING_INNER = 0.12; +const CHART_BAND_PADDING_OUTER = 0; + +function centerRatioForBandIndex(index, total, paddingInner = CHART_BAND_PADDING_INNER, paddingOuter = CHART_BAND_PADDING_OUTER) { + if (!Number.isFinite(index) || total <= 0) return null; + const denominator = total - paddingInner + 2 * paddingOuter; + if (denominator <= 0) return null; + return (index + paddingOuter + (1 - paddingInner) / 2) / denominator; +} + +function nearestBandIndexForLocalX(localX, width, total, paddingInner = CHART_BAND_PADDING_INNER, paddingOuter = CHART_BAND_PADDING_OUTER) { + if (!Number.isFinite(localX) || !Number.isFinite(width) || width <= 0 || total <= 0) return null; + const denominator = total - paddingInner + 2 * paddingOuter; + if (denominator <= 0) return null; + const stepPx = width / denominator; + const raw = localX / stepPx - paddingOuter - (1 - paddingInner) / 2; + return Math.max(0, Math.min(total - 1, Math.round(raw))); +} + +/** Pixel y along straight segments between band centers (smooth x vs snapped tooltip values). */ +function interpolateLineYAtPixelX(pxX, sortedPoints, xScale, yScale, xAcc, yAcc, bandwidth) { + if (!sortedPoints?.length || !xScale || !yScale) return null; + const centers = sortedPoints.map((d) => bandCenterX(xScale, bandwidth, xAcc(d))); + const ys = sortedPoints.map((d) => Number(yScale(yAcc(d)))); + if (!Number.isFinite(centers[0]) || !Number.isFinite(ys[0])) return null; + + if (pxX <= centers[0]) { + return { cx: pxX, cy: ys[0] }; + } + const last = centers.length - 1; + if (pxX >= centers[last]) { + return { cx: pxX, cy: ys[last] }; + } + for (let i = 0; i < last; i += 1) { + const p0 = centers[i]; + const p1 = centers[i + 1]; + if (pxX >= p0 && pxX <= p1) { + const t = p1 === p0 ? 0 : (pxX - p0) / (p1 - p0); + return { cx: pxX, cy: ys[i] + t * (ys[i + 1] - ys[i]) }; + } + } + return null; +} + +function SmoothHoverDots({ isMultiSeries, series, data, xAccessor, yAccessor, color }) { + const { xScale, yScale } = React.useContext(DataContext) || {}; + const tooltipCtx = React.useContext(TooltipContext); + + if (!tooltipCtx?.tooltipOpen || xScale == null || yScale == null) return null; + const pxX = tooltipCtx.tooltipLeft; + if (pxX == null || Number.isNaN(Number(pxX))) return null; + + const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 0; + + const renderDot = (sorted, lineColor, fillOpacity = 1) => { + const pos = interpolateLineYAtPixelX(pxX, sorted, xScale, yScale, xAccessor, yAccessor, bw); + if (!pos || !Number.isFinite(pos.cx) || !Number.isFinite(pos.cy)) return null; + return ( + + ); + }; + + if (isMultiSeries && series?.length) { + return ( + + {series.map((s, i) => { + if (!s.data?.length) return null; + const sorted = [...s.data].sort((a, b) => { + const xa = xAccessor(a); + const xb = xAccessor(b); + return xa < xb ? -1 : xa > xb ? 1 : 0; + }); + const fillOpacity = typeof s.strokeOpacity === 'number' ? s.strokeOpacity : 1; + const dot = renderDot(sorted, s.color, fillOpacity); + return dot ? {dot} : null; + })} + + ); + } + + if (data?.length) { + const sorted = [...data].sort((a, b) => { + const xa = xAccessor(a); + const xb = xAccessor(b); + return xa < xb ? -1 : xa > xb ? 1 : 0; + }); + const dot = renderDot(sorted, color, 1); + return dot ? {dot} : null; + } + + return null; +} + +function SyncedHoverOverlay({ + syncId, + hoverSyncSignal, + xValues, + xAccessor, + yAccessor, + xTickFormat, + isMultiSeries, + series, + data, + color, +}) { + const { xScale, yScale } = React.useContext(DataContext) || {}; + if (!xScale || !yScale || !hoverSyncSignal || hoverSyncSignal.sourceId === syncId || hoverSyncSignal.type !== 'move') { + return null; + } + if (!xValues?.length) return null; + + const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 0; + const xRange = xScale.range?.() || []; + if (xRange.length < 2) return null; + const xLeft = Math.min(...xRange); + const xRight = Math.max(...xRange); + const ratio = Math.max(0, Math.min(1, Number(hoverSyncSignal.ratio) || 0)); + // Keep mirrored line smooth by following the shared normalized x-position. + const pxX = xLeft + ratio * (xRight - xLeft); + if (!Number.isFinite(pxX)) return null; + + // Keep mirrored values notched to the nearest bucket for consistent numbers. + let idx = xValues.indexOf(hoverSyncSignal.xValue); + if (idx < 0) { + let nearestIdx = 0; + let nearestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < xValues.length; i += 1) { + const center = bandCenterX(xScale, bw, xValues[i]); + const dist = Math.abs(center - pxX); + if (dist < nearestDist) { + nearestDist = dist; + nearestIdx = i; + } + } + idx = nearestIdx; + } + const xVal = xValues[idx]; + if (xVal == null) return null; + + const yRange = yScale.range?.() || []; + if (!yRange.length) return null; + const yTop = Math.min(...yRange); + const yBottom = Math.max(...yRange); + + const rows = isMultiSeries + ? (series || []).map((s) => { + const datum = s?.data?.find((d) => xAccessor(d) === xVal); + return datum + ? { + label: s?.label || 'Series', + value: Math.round(datum.y), + color: s?.color || '#94a3b8', + } + : null; + }).filter(Boolean) + : (() => { + const datum = (data || []).find((d) => xAccessor(d) === xVal); + return datum ? [{ label: 'Value', value: Math.round(datum.y), color }] : []; + })(); + const syncedDots = isMultiSeries + ? (series || []).map((s, i) => { + if (!s?.data?.length) return null; + const sorted = [...s.data].sort((a, b) => { + const xa = xAccessor(a); + const xb = xAccessor(b); + return xa < xb ? -1 : xa > xb ? 1 : 0; + }); + const pos = interpolateLineYAtPixelX(pxX, sorted, xScale, yScale, xAccessor, yAccessor, bw); + if (!pos || !Number.isFinite(pos.cy)) return null; + const fillOpacity = typeof s?.strokeOpacity === 'number' ? s.strokeOpacity : 1; + return ( + + ); + }) + : (() => { + if (!data?.length) return []; + const sorted = [...data].sort((a, b) => { + const xa = xAccessor(a); + const xb = xAccessor(b); + return xa < xb ? -1 : xa > xb ? 1 : 0; + }); + const pos = interpolateLineYAtPixelX(pxX, sorted, xScale, yScale, xAccessor, yAccessor, bw); + if (!pos || !Number.isFinite(pos.cy)) return []; + return [ + + ]; + })(); + + const dateLabel = xTickFormat ? xTickFormat(xVal) : String(xVal); + const tooltipWidth = 180; + const tooltipHeight = 30 + rows.length * 22; + const tooltipX = Math.max(xLeft + 6, Math.min(pxX + 12, xRight - tooltipWidth - 6)); + const tooltipY = yTop + 8; + + return ( + + + {syncedDots} + +
+
{dateLabel}
+ {rows.map((r, i) => ( +
+ + {r.label}: {r.value} +
+ ))} +
+
+
+ ); +} + +function HoverSyncReporter({ syncId, onHoverSyncChange, xAccessor, isPointerInsideRef }) { + const { xScale } = React.useContext(DataContext) || {}; + const tooltipCtx = React.useContext(TooltipContext); + const lastEmittedRef = React.useRef({ xValue: null, ratio: null, tooltipOpen: false }); + + React.useEffect(() => { + if (!syncId || !onHoverSyncChange || !xScale || !tooltipCtx?.tooltipOpen || !isPointerInsideRef?.current) { + lastEmittedRef.current = { xValue: null, ratio: null, tooltipOpen: false }; + return; + } + const tooltipLeft = Number(tooltipCtx.tooltipLeft); + if (!Number.isFinite(tooltipLeft)) return; + const range = xScale.range?.() || []; + if (range.length < 2) return; + const xLeft = Math.min(...range); + const xRight = Math.max(...range); + const width = xRight - xLeft; + if (width <= 0) return; + + const nearestDatum = tooltipCtx.tooltipData?.nearestDatum?.datum; + const datumByKey = tooltipCtx.tooltipData?.datumByKey; + const fallbackDatum = datumByKey ? Object.values(datumByKey)[0]?.datum : null; + const xValue = nearestDatum ? xAccessor(nearestDatum) : fallbackDatum ? xAccessor(fallbackDatum) : null; + if (xValue == null) return; + + const ratio = Math.max(0, Math.min(1, (tooltipLeft - xLeft) / width)); + const roundedRatio = Math.round(ratio * 1000) / 1000; + const last = lastEmittedRef.current; + if (last.tooltipOpen && last.xValue === xValue && last.ratio === roundedRatio) { + return; + } + + lastEmittedRef.current = { xValue, ratio: roundedRatio, tooltipOpen: true }; + onHoverSyncChange({ + sourceId: syncId, + type: 'move', + xValue, + ratio: roundedRatio, + ts: Date.now() + }); + }, [syncId, onHoverSyncChange, xAccessor, xScale, tooltipCtx, isPointerInsideRef]); + + return null; +} + +function XScaleRangeReporter({ xScaleRangeRef }) { + const { xScale } = React.useContext(DataContext) || {}; + + React.useEffect(() => { + const range = xScale?.range?.(); + if (!range || range.length < 2) return; + const xLeft = Math.min(...range); + const xRight = Math.max(...range); + if (!Number.isFinite(xLeft) || !Number.isFinite(xRight) || xRight <= xLeft) return; + xScaleRangeRef.current = { xLeft, xRight }; + }, [xScale, xScaleRangeRef]); + + return null; +} + +function RangeSelectionOverlay({ xValues, selection }) { + const { xScale, yScale } = React.useContext(DataContext) || {}; + if (!xScale || !yScale || !selection || !xValues?.length) return null; + + const xRange = xScale.range?.() || []; + const yRange = yScale.range?.() || []; + if (xRange.length < 2 || yRange.length < 2) return null; + + const xLeft = Math.min(...xRange); + const xRight = Math.max(...xRange); + const yTop = Math.min(...yRange); + const yBottom = Math.max(...yRange); + const bandWidth = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 0; + + const start = Math.max(0, Math.min(selection.startIdx, xValues.length - 1)); + const end = Math.max(0, Math.min(selection.endIdx, xValues.length - 1)); + const from = Math.min(start, end); + const to = Math.max(start, end); + + const fromX = Number(xScale(xValues[from])); + const toX = Number(xScale(xValues[to])); + if (!Number.isFinite(fromX) || !Number.isFinite(toX)) return null; + + const left = Math.max(xLeft, Math.min(fromX, toX)); + const right = Math.min(xRight, Math.max(fromX, toX) + bandWidth); + if (!Number.isFinite(left) || !Number.isFinite(right) || right <= left) return null; + + return ( + + + + + + ); +} + +/** Max labeled notches on the x-axis (bands / hover stay full width; only tick labels are thinned). */ +const MAX_X_TICKS = 6; +const MAX_X_TICKS_LONG_RANGE = 5; function getSparseTickValues(values, maxTicks = MAX_X_TICKS) { if (!values?.length) return undefined; @@ -70,7 +449,8 @@ function formatSemanticDate(dateStr) { * @param {boolean} [props.showGlyph=true] - End point dot * @param {string} [props.emptyMessage='No data'] * @param {string[]} [props.xDomain] - Optional x-axis domain (e.g. full date range when data stops earlier) - * @param {Array<{ data: Array<{x,y}>, color: string, label: string }>} [props.series] - Multiple series (overrides data when provided) + * @param {Array<{ data: Array<{x,y}>, color: string, label: string, strokeDasharray?: string, strokeOpacity?: number, fillOpacity?: number }>} [props.series] - Multiple series (overrides data when provided). Optional strokeDasharray for dashed comparison lines; strokeOpacity for dashed stroke only; fillOpacity overrides default area opacity per series. + * @param {string} [props.dashedLineBackdropStroke] - Solid stroke drawn under dashed lines so gaps match the chart surface (not stacked area fills). Defaults to CSS var --background with white fallback. */ const SERIES_COLORS = ['#4DAA57', '#2563eb', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4']; @@ -85,37 +465,79 @@ function EventDashboardChart({ showArea = true, showLine = true, showGlyph = true, + /** When true with multi-series, only the first series gets an end-point glyph (e.g. “today” marker). */ + showGlyphPrimaryOnly = false, showPointMarkers = false, emptyMessage = 'No data', xDomain, series, + dashedLineBackdropStroke = 'var(--background, #ffffff)', debugInteractions = false, debugPrefix = 'EventDashboardChart', + syncId, + hoverSyncSignal, + onHoverSyncChange, + enableRangeSelection = false, + onRangeSelect, }) { + const containerRef = React.useRef(null); const lastMoveLogAtRef = React.useRef(0); const lastTooltipLogAtRef = React.useRef(0); + const pointerInsideRef = React.useRef(false); + const xScaleRangeRef = React.useRef(null); + const [isPointerInside, setIsPointerInside] = React.useState(false); + const [dragSelection, setDragSelection] = React.useState(null); const accessors = { xAccessor, yAccessor }; const isMultiSeries = series && series.length > 0; const displayData = isMultiSeries ? series.flatMap((s) => s.data) : data; const allValues = isMultiSeries ? series.flatMap((s) => s.data.map((d) => d.y)) : data.map((d) => d.y); - const xValues = isMultiSeries + const baseXValues = isMultiSeries ? [...new Set(series.flatMap((s) => s.data.map((d) => xAccessor(d))))].sort() : data.map((d) => xAccessor(d)); + const xValues = + xDomain && Array.isArray(xDomain) && xDomain.length ? [...xDomain] : baseXValues; const xTickValues = getSparseTickValues( xValues, - xValues.length > 30 ? MAX_X_TICKS_DENSE : MAX_X_TICKS + xValues.length > 40 ? MAX_X_TICKS_LONG_RANGE : MAX_X_TICKS ); - if ((!isMultiSeries && (!data || data.length === 0)) || (isMultiSeries && series.every((s) => !s.data?.length))) { - return ( -
-
{emptyMessage}
-
- ); - } - const yMax = Math.max(...allValues, 0) * 1.1 || 10; const gradientId = (c) => `chart-gradient-${c.replace('#', '')}`; + const getBandIndexFromMouseEvent = React.useCallback( + (e) => { + if (!containerRef.current || !xScaleRangeRef.current || !xValues?.length) return null; + const { xLeft, xRight } = xScaleRangeRef.current; + const width = xRight - xLeft; + if (!Number.isFinite(width) || width <= 0) return null; + const rect = containerRef.current.getBoundingClientRect(); + const localX = e.clientX - rect.left - xLeft; + return nearestBandIndexForLocalX(localX, width, xValues.length); + }, + [xValues] + ); + const finishRangeSelection = React.useCallback(() => { + if (!enableRangeSelection || !onRangeSelect || !dragSelection || !xValues?.length) { + setDragSelection(null); + return; + } + const from = Math.max(0, Math.min(Math.min(dragSelection.startIdx, dragSelection.endIdx), xValues.length - 1)); + const to = Math.max(0, Math.min(Math.max(dragSelection.startIdx, dragSelection.endIdx), xValues.length - 1)); + if (to <= from) { + setDragSelection(null); + return; + } + onRangeSelect({ + startXValue: xValues[from], + endXValue: xValues[to] + }); + setDragSelection(null); + }, [enableRangeSelection, onRangeSelect, dragSelection, xValues]); + React.useEffect(() => { + if (!dragSelection) return; + const handleMouseUp = () => finishRangeSelection(); + window.addEventListener('mouseup', handleMouseUp); + return () => window.removeEventListener('mouseup', handleMouseUp); + }, [dragSelection, finishRangeSelection]); const maybeLog = (ref, message, payload, everyMs = 350) => { if (!debugInteractions) return; const now = Date.now(); @@ -124,10 +546,27 @@ function EventDashboardChart({ console.log(`[${debugPrefix}] ${message}`, payload); }; + if ((!isMultiSeries && (!data || data.length === 0)) || (isMultiSeries && series.every((s) => !s.data?.length))) { + return ( +
+
{emptyMessage}
+
+ ); + } + return (
+ onMouseMove={(e) => { + pointerInsideRef.current = true; + setIsPointerInside((prev) => (prev ? prev : true)); + if (enableRangeSelection && dragSelection) { + const idx = getBandIndexFromMouseEvent(e); + if (idx != null) { + setDragSelection((prev) => (prev ? { ...prev, endIdx: idx } : prev)); + } + } maybeLog( lastMoveLogAtRef, 'mousemove', @@ -138,16 +577,43 @@ function EventDashboardChart({ isMultiSeries }, 450 - ) - } + ); + }} onMouseLeave={() => { - if (!debugInteractions) return; - console.log(`[${debugPrefix}] mouseleave`); + pointerInsideRef.current = false; + setIsPointerInside(false); + if (enableRangeSelection && dragSelection) { + finishRangeSelection(); + } + if (debugInteractions) { + console.log(`[${debugPrefix}] mouseleave`); + } + if (!syncId || !onHoverSyncChange) return; + onHoverSyncChange({ + sourceId: syncId, + type: 'leave', + ts: Date.now() + }); + }} + onMouseDown={(e) => { + if (!enableRangeSelection || !onRangeSelect || e.button !== 0) return; + const idx = getBandIndexFromMouseEvent(e); + if (idx == null) return; + setDragSelection({ startIdx: idx, endIdx: idx }); + }} + onMouseUp={() => { + if (!dragSelection) return; + finishRangeSelection(); }} > ( - + )) ) : ( - + )} ({ @@ -182,10 +650,16 @@ function EventDashboardChart({ fontSize: 10, textAnchor: 'middle', })} - numTicks={xTickValues ? xTickValues.length : Math.min(xDomain?.length ?? displayData.length, 12)} + numTicks={ + xTickValues != null + ? xTickValues.length + : Math.min(xDomain?.length ?? xValues.length, 8) + } /> ({ fill: 'var(--light-text)', fontSize: 10, @@ -197,7 +671,7 @@ function EventDashboardChart({ {isMultiSeries ? ( <> {series.map((s, i) => ( - + {showArea && s.data?.length > 0 && ( )} - {showLine && s.data?.length > 0 && ( - <> - - {showGlyph && ( - ( - - )} + + ))} + {series.map((s, i) => + showLine && s.data?.length > 0 && s.strokeDasharray ? ( + + + + + ) : null + )} + {series.map((s, i) => + showLine && s.data?.length > 0 && !s.strokeDasharray ? ( + + ) : null + )} + {series.map((s, i) => ( + + {showLine && + showGlyph && + s.data?.length > 0 && + (!showGlyphPrimaryOnly || i === 0) && ( + ( + )} - {showPointMarkers && ( - ( - - )} + /> + )} + {showLine && showPointMarkers && s.data?.length > 0 && ( + ( + )} - + /> )} ))} @@ -277,7 +792,7 @@ function EventDashboardChart({ xAccessor={accessors.xAccessor} yAccessor={accessors.yAccessor} curve={curveMonotoneX} - fillOpacity={0.2} + fillOpacity={0.3} fill={`url(#${gradientId(color)})`} /> )} @@ -339,60 +854,111 @@ function EventDashboardChart({ )} )} - { - maybeLog( - lastTooltipLogAtRef, - 'tooltip render', - { - hasNearest: !!tooltipData?.nearestDatum, - keys: tooltipData?.datumByKey ? Object.keys(tooltipData.datumByKey) : [], - }, - 200 - ); - if (!tooltipData?.datumByKey) return null; - const entries = Object.entries(tooltipData.datumByKey).filter( - ([k, v]) => v?.datum && (k.startsWith('series-') && !k.includes('series-end')) - ); - if (entries.length === 0) return null; - const first = entries[0][1]; - const dateStr = first?.datum?.x; - if (!dateStr) return null; - return ( -
-
{xTickFormat(dateStr)}
- {isMultiSeries - ? entries.map(([key, v], i) => { - const idx = parseInt(key.replace('series-', ''), 10); - const s = series[idx]; - return ( -
- - {s?.label}: {Math.round(v.datum.y)} -
- ); - }) - : ( -
- - {Math.round(first.datum.y)} -
- )} -
- ); - }} + {isPointerInside ? ( + { + maybeLog( + lastTooltipLogAtRef, + 'tooltip render', + { + hasNearest: !!tooltipData?.nearestDatum, + keys: tooltipData?.datumByKey ? Object.keys(tooltipData.datumByKey) : [], + }, + 200 + ); + if (!tooltipData?.datumByKey) return null; + const entries = Object.entries(tooltipData.datumByKey).filter( + ([k, v]) => + v?.datum && + k.startsWith('series-') && + !k.includes('series-end') && + !k.includes('dash-backdrop') + ); + if (entries.length === 0) return null; + const first = entries[0][1]; + const dateStr = first?.datum?.x; + if (!dateStr) return null; + return ( +
+
{xTickFormat(dateStr)}
+ {isMultiSeries + ? entries.map(([key, v], i) => { + const idx = parseInt(key.replace('series-', ''), 10); + const s = series[idx]; + return ( +
+ + {s?.label}: {Math.round(v.datum.y)} +
+ ); + }) + : ( +
+ + {Math.round(first.datum.y)} +
+ )} +
+ ); + }} + /> + ) : null} + + + + + {enableRangeSelection ? ( + + ) : null}
); diff --git a/frontend/src/pages/FeatureAdmin/AnalyticsDashboard/AnalyticsDashboard.jsx b/frontend/src/pages/FeatureAdmin/AnalyticsDashboard/AnalyticsDashboard.jsx index cbbb0efd..7ba949d1 100644 --- a/frontend/src/pages/FeatureAdmin/AnalyticsDashboard/AnalyticsDashboard.jsx +++ b/frontend/src/pages/FeatureAdmin/AnalyticsDashboard/AnalyticsDashboard.jsx @@ -1,38 +1,20 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useFetch } from '../../../hooks/useFetch'; import { Icon } from '@iconify-icon/react'; import ProportionalBarList from '../../../components/ProportionalBarList/ProportionalBarList'; +import { formatAnalyticsDuration, formatAnalyticsNumber } from '../../../utils/analyticsDashboardUtils'; import './AnalyticsDashboard.scss'; function AnalyticsDashboard() { const [timeRange, setTimeRange] = useState('30d'); - const { data: dashboardData, loading, error, refetch } = useFetch(`/dashboard/all?timeRange=${timeRange}&platform=web`); - - const formatNumber = (num) => { - if (num === null || num === undefined) return '0'; - return new Intl.NumberFormat().format(num); - }; + const dashboardParams = useMemo(() => ({ timeRange, platform: 'web' }), [timeRange]); + const { data: dashboardData, loading, error, refetch } = useFetch('/dashboard/all', { + method: 'GET', + params: dashboardParams + }); - const formatDuration = (seconds) => { - if (!seconds) return '0s'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - if (mins > 0) { - return `${mins}m ${secs}s`; - } - return `${secs}s`; - }; - - const getTimeRangeLabel = (range) => { - const labels = { - '1h': 'Last Hour', - '24h': 'Last 24 Hours', - '7d': 'Last 7 Days', - '30d': 'Last 30 Days', - '90d': 'Last 90 Days' - }; - return labels[range] || range; - }; + const formatNumber = formatAnalyticsNumber; + const formatDuration = formatAnalyticsDuration; if (loading) { return ( diff --git a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx new file mode 100644 index 00000000..4852587f --- /dev/null +++ b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx @@ -0,0 +1,294 @@ +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { useFetch } from '../../../../hooks/useFetch'; +import { useGradient } from '../../../../hooks/useGradient'; +import { useNotification } from '../../../../NotificationContext'; +import apiRequest from '../../../../utils/postRequest'; +import { Icon } from '@iconify-icon/react'; +import { + ORG_BETA_FEATURE_KEYS, + ORG_BETA_FEATURE_CATALOG +} from '../../../../constants/orgBetaFeatures'; +import './OrgBetaFeatures.scss'; + +const PAGE_SIZE = 20; + +function buildOrgListQuery(filters) { + const p = new URLSearchParams(); + if (filters.search) p.set('search', filters.search); + if (filters.verified === 'true' || filters.verified === 'false') { + p.set('verified', filters.verified); + } + p.set('page', String(filters.page)); + p.set('limit', String(PAGE_SIZE)); + return p.toString(); +} + +function OrgBetaFeatures() { + const { addNotification } = useNotification(); + const { AtlasMain, AdminGrad } = useGradient(); + const [searchDraft, setSearchDraft] = useState(''); + const [filters, setFilters] = useState({ + search: '', + verified: '', + page: 1 + }); + const [savingByOrgId, setSavingByOrgId] = useState({}); + + useEffect(() => { + const id = window.setTimeout(() => { + setFilters((prev) => + prev.search === searchDraft ? prev : { ...prev, search: searchDraft, page: 1 } + ); + }, 280); + return () => window.clearTimeout(id); + }, [searchDraft]); + + const listQuery = useMemo(() => buildOrgListQuery(filters), [filters]); + const { data: catalogRes } = useFetch('/org-management/beta-feature-catalog'); + const features = useMemo(() => { + const fromApi = catalogRes?.data?.features; + if (Array.isArray(fromApi) && fromApi.length) return fromApi; + return ORG_BETA_FEATURE_KEYS.map((key) => ({ + key, + label: ORG_BETA_FEATURE_CATALOG[key]?.label || key, + description: ORG_BETA_FEATURE_CATALOG[key]?.description || '' + })); + }, [catalogRes]); + + const { data: orgs, loading, error, refetch } = useFetch( + `/org-management/organizations?${listQuery}` + ); + + const pagination = orgs?.pagination; + const totalItems = pagination?.total ?? 0; + const pageSize = pagination?.limit ?? PAGE_SIZE; + const totalPages = + pagination?.totalPages ?? Math.max(1, Math.ceil(totalItems / pageSize)); + const currentPage = Math.min(Math.max(1, filters.page), totalPages); + const rangeStart = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const rangeEnd = totalItems === 0 ? 0 : Math.min(currentPage * pageSize, totalItems); + + useEffect(() => { + if (pagination?.totalPages == null) return; + const tp = pagination.totalPages; + setFilters((f) => (f.page > tp ? { ...f, page: tp } : f)); + }, [pagination?.totalPages]); + + const patchOrgBetaFeatures = useCallback( + async (orgId, enabledKeys) => { + setSavingByOrgId((m) => ({ ...m, [orgId]: true })); + try { + const res = await apiRequest( + `/org-management/organizations/${orgId}/beta-features`, + { enabledKeys }, + { method: 'PATCH' } + ); + if (res.success) { + addNotification({ + title: 'Saved', + message: 'Beta features updated for this organization.', + type: 'success' + }); + refetch(); + } else { + addNotification({ + title: 'Error', + message: res.message || 'Could not update beta features.', + type: 'error' + }); + } + } catch (e) { + addNotification({ + title: 'Error', + message: e.message || 'Could not update beta features.', + type: 'error' + }); + } finally { + setSavingByOrgId((m) => { + const next = { ...m }; + delete next[orgId]; + return next; + }); + } + }, + [addNotification, refetch] + ); + + const handleToggle = useCallback( + (org, featureKey, nextEnabled) => { + const current = Array.isArray(org.betaFeatureKeys) ? [...org.betaFeatureKeys] : []; + let nextKeys; + if (nextEnabled) { + nextKeys = [...new Set([...current, featureKey])]; + } else { + nextKeys = current.filter((k) => k !== featureKey); + } + nextKeys = nextKeys.filter((k) => ORG_BETA_FEATURE_KEYS.includes(k)); + patchOrgBetaFeatures(org._id, nextKeys); + }, + [patchOrgBetaFeatures] + ); + + const isInitialLoad = loading && orgs == null; + + if (isInitialLoad) { + return ( +
+
Loading…
+
+ ); + } + + if (error && orgs == null) { + return ( +
+
+ {error} +
+
+ ); + } + + return ( +
+
+

Beta features

+

Grant per-organization access to features that are still in beta.

+ +
+ +
+
+ +
+ +
+
+ + {error && orgs != null && ( +
+ Could not refresh: {error} +
+ )} + + {pagination && totalItems > 0 && ( +

+ {totalItems} organization{totalItems === 1 ? '' : 's'} + {filters.search ? ` matching “${filters.search}”` : ''} +

+ )} + +
+ + + + + {features.map((f) => ( + + ))} + + + + {!orgs?.data?.length ? ( + + + + ) : ( + orgs.data.map((org) => ( + + + {features.map((f) => { + const enabled = + Array.isArray(org.betaFeatureKeys) && + org.betaFeatureKeys.includes(f.key); + const busy = savingByOrgId[org._id]; + const inputId = `beta-${org._id}-${f.key}`; + return ( + + ); + })} + + )) + )} + +
Organization + {f.label} + Beta +
+ No organizations match your filters. +
+
+ +
+
+ {org.org_name} +
+
+
+
+ + handleToggle(org, f.key, e.target.checked) + } + aria-label={`${f.label} for ${org.org_name}`} + /> +
+
+ + {pagination && totalItems > 0 && ( +
+ + Showing {rangeStart}–{rangeEnd} of {totalItems} + +
+ + +
+
+ )} +
+ +
+ ); +} + +export default OrgBetaFeatures; diff --git a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss new file mode 100644 index 00000000..9101754c --- /dev/null +++ b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss @@ -0,0 +1,204 @@ +.org-beta-features { + + &__header { + // h1 { + // margin: 0 0 0.35rem; + // font-size: 1.75rem; + // } + + // p { + // margin: 0; + // color: var(--text-muted, #5c6570); + // max-width: 40rem; + // } + + // img { + // position: absolute; + // right: 0; + // top: 0; + // width: 120px; + // height: auto; + // opacity: 0.35; + // pointer-events: none; + // } + } + + .content { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0 40px 20px 40px; + } + + &__toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; + } + + &__search { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 200px; + max-width: 420px; + padding: 0.35rem 0.75rem; + border: 1px solid var(--border-color, #d0d7de); + border-radius: 8px; + background: var(--surface, #fff); + + input { + border: none; + flex: 1; + min-width: 0; + font: inherit; + background: transparent; + outline: none; + } + } + + &__filter select { + padding: 0.45rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border-color, #d0d7de); + font: inherit; + } + + &__meta { + font-size: 0.9rem; + color: var(--text-muted, #5c6570); + margin: 0 0 0.75rem; + } + + &__table-wrap { + overflow-x: auto; + border: 1px solid var(--border-color, #d0d7de); + border-radius: 10px; + background: var(--surface, #fff); + + &--dim { + opacity: 0.65; + } + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; + + th, + td { + padding: 0.65rem 0.85rem; + text-align: left; + border-bottom: 1px solid var(--border-color, #e8eaed); + vertical-align: middle; + } + + thead th { + background: var(--table-header-bg, #f6f8f6); + font-weight: 600; + } + } + + &__th-feature { + white-space: nowrap; + } + + &__th-title { + margin-right: 0.35rem; + } + + &__th-badge { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.15rem 0.4rem; + border-radius: 4px; + background: #e8f5e9; + color: #2e7d32; + } + + &__org-cell { + display: flex; + align-items: center; + gap: 0.65rem; + } + + &__org-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; + } + + &__org-name { + font-weight: 600; + } + + &__td-toggle { + text-align: center; + } + + &__checkbox { + width: 1.1rem; + height: 1.1rem; + cursor: pointer; + accent-color: #4daa57; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + &__empty { + text-align: center; + color: var(--text-muted, #5c6570); + padding: 2rem 1rem !important; + } + + &__pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1rem; + font-size: 0.9rem; + } + + &__pagination-btns { + display: flex; + gap: 0.5rem; + + button { + padding: 0.4rem 0.85rem; + border-radius: 8px; + border: 1px solid var(--border-color, #d0d7de); + background: var(--surface, #fff); + font: inherit; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + &__loading, + &__error { + padding: 2rem; + text-align: center; + } + + &__inline-error { + color: #b71c1c; + margin-bottom: 0.75rem; + font-size: 0.9rem; + } +} diff --git a/frontend/src/utils/analyticsDashboardUtils.js b/frontend/src/utils/analyticsDashboardUtils.js new file mode 100644 index 00000000..06b6792a --- /dev/null +++ b/frontend/src/utils/analyticsDashboardUtils.js @@ -0,0 +1,244 @@ +import { + eachDayOfInterval, + endOfMonth, + format, + isSameMonth, + parseISO, + startOfMonth, + subMonths +} from 'date-fns'; + +/** + * Shared formatters and helpers for platform analytics dashboards + * (Admin General + Feature Admin Analytics). + */ + +export function formatAnalyticsNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat().format(num); +} + +export function formatAnalyticsDuration(seconds) { + if (!seconds && seconds !== 0) return '0s'; + const s = Math.round(Number(seconds)); + const mins = Math.floor(s / 60); + const secs = s % 60; + if (mins > 0) return `${mins}m ${secs}s`; + return `${secs}s`; +} + +export function formatDeltaPercent(delta) { + if (delta === null || delta === undefined || Number.isNaN(delta)) return '—'; + const pct = Math.round(delta * 100); + const sign = pct > 0 ? '+' : ''; + return `${sign}${pct}%`; +} + +/** + * Merge timeseries series objects into Chart.js-friendly labels + datasets. + * @param {Record>} series + */ +export function buildTimeseriesChartData(series, colors) { + const palette = colors || ['#45A1FC', '#8052FB', '#2BB673', '#FA756D', '#f59e0b']; + if (!series || typeof series !== 'object') { + return { labels: [], datasets: [] }; + } + const allBuckets = new Set(); + Object.values(series).forEach((rows) => { + if (Array.isArray(rows)) rows.forEach((r) => r?.bucket && allBuckets.add(r.bucket)); + }); + const labels = [...allBuckets].sort(); + const keys = Object.keys(series).filter((k) => Array.isArray(series[k]) && series[k].length); + const datasets = keys.map((key, i) => ({ + label: key.replace(/_/g, ' '), + data: labels.map((lb) => { + const row = series[key].find((r) => r.bucket === lb); + return row ? row.value : 0; + }), + borderColor: palette[i % palette.length], + backgroundColor: 'transparent', + tension: 0.2, + fill: false, + pointRadius: 0, + borderWidth: 2 + })); + return { labels, datasets }; +} + +/** + * Axis / tick label for a timeseries bucket string from the API. + * @param {'hour'|'day'|'week'} granularity + */ +export function formatBucketAxisLabel(bucket, granularity) { + if (bucket == null || bucket === '') return ''; + if (granularity === 'hour') { + const d = new Date(bucket); + if (Number.isNaN(d.getTime())) return String(bucket).slice(11, 16); + return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + } + if (granularity === 'week') { + return String(bucket).replace(/^(\d{4})-W(\d+)$/, (_, y, w) => `${y} W${w}`); + } + const d = new Date(bucket); + if (!Number.isNaN(d.getTime())) { + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + return String(bucket).slice(0, 10); +} + +/** + * Build series config for @visx/xychart (e.g. EventDashboardChart) with optional comparison line. + * Uses the same x (bucket key) for both lines so the band scale aligns. + * Comparison uses the same stroke color as the primary series with a dashed stroke; both get area fills. + */ +export function buildComparisonVisxSeries( + currentRows, + previousRows, + color, + labels = {}, + options = {} +) { + const labelThis = labels.thisPeriod || 'This period'; + const labelCompare = labels.compare || 'Comparison'; + const compareStrokeDasharray = options.compareStrokeDasharray ?? '5 5'; + const compareStrokeOpacity = + typeof options.compareStrokeOpacity === 'number' ? options.compareStrokeOpacity : 0.6; + const primaryFillOpacity = options.primaryFillOpacity ?? 0.36; + const compareFillOpacity = options.compareFillOpacity ?? 0.24; + const excludePreviousPeriodEnd = options.excludePreviousPeriodEnd ?? true; + + if (!currentRows?.length) { + return { series: [] }; + } + + if (!previousRows?.length) { + const data = currentRows.map((r) => ({ x: r.bucket, y: r.value })); + return { + series: [{ data, color, label: labelThis, fillOpacity: primaryFillOpacity }] + }; + } + + const len = Math.min(currentRows.length, previousRows.length); + const cur = []; + const prev = []; + for (let i = 0; i < len; i++) { + const x = currentRows[i].bucket; + cur.push({ x, y: currentRows[i].value }); + if (excludePreviousPeriodEnd && i === len - 1) continue; + prev.push({ x, y: previousRows[i]?.value ?? 0 }); + } + + return { + series: [ + { data: cur, color, label: labelThis, fillOpacity: primaryFillOpacity }, + { + data: prev, + color, + label: labelCompare, + strokeDasharray: compareStrokeDasharray, + strokeOpacity: compareStrokeOpacity, + fillOpacity: compareFillOpacity + } + ] + }; +} + +/** + * Ordered `yyyy-MM-dd` keys for every calendar day in the month containing `anchorDate`. + */ +export function fullDayDomainForCalendarMonth(anchorDate) { + return eachDayOfInterval({ + start: startOfMonth(anchorDate), + end: endOfMonth(anchorDate) + }).map((d) => format(d, 'yyyy-MM-dd')); +} + +/** + * Month view (day buckets): full month on the x-axis. Primary “this period” only includes days + * through today when viewing the current month. Comparison uses one point per calendar day for + * the whole month (same x labels as the domain), with y from `compareSubMonths` earlier + * (1 = adjacent month, 12 = year-ago same calendar day). + */ +export function buildComparisonVisxSeriesForCalendarMonthView( + anchorDate, + currentRows, + previousRows, + color, + labels = {}, + options = {} +) { + const labelThis = labels.thisPeriod || 'This period'; + const labelCompare = labels.compare || 'Comparison'; + const compareStrokeDasharray = options.compareStrokeDasharray ?? '5 5'; + const compareStrokeOpacity = + typeof options.compareStrokeOpacity === 'number' ? options.compareStrokeOpacity : 0.6; + const primaryFillOpacity = options.primaryFillOpacity ?? 0.36; + const compareFillOpacity = options.compareFillOpacity ?? 0.24; + const compareSubMonths = typeof options.compareSubMonths === 'number' ? options.compareSubMonths : 1; + const excludePreviousPeriodEnd = options.excludePreviousPeriodEnd ?? true; + + const fullDomain = fullDayDomainForCalendarMonth(anchorDate); + const monthStart = fullDomain[0]; + const monthEnd = fullDomain[fullDomain.length - 1]; + const now = new Date(); + const viewingCurrentMonth = isSameMonth(anchorDate, now); + const todayIso = format(now, 'yyyy-MM-dd'); + const cutoffIso = viewingCurrentMonth && todayIso < monthEnd ? todayIso : monthEnd; + + const curInRange = (currentRows || []) + .filter((r) => r?.bucket && r.bucket >= monthStart && r.bucket <= cutoffIso) + .sort((a, b) => (a.bucket < b.bucket ? -1 : a.bucket > b.bucket ? 1 : 0)); + + if (!curInRange.length) { + return { series: [], xDomain: undefined, showEndGlyph: false }; + } + + const prevBy = new Map((previousRows || []).map((r) => [r.bucket, r.value])); + const prevEnd = (previousRows || []).length + ? (previousRows || []) + .map((r) => r?.bucket) + .filter(Boolean) + .sort() + .slice(-1)[0] + : null; + + const cur = curInRange.map((r) => ({ x: r.bucket, y: r.value })); + + const buildPrevAcrossFullMonth = () => + fullDomain + .map((x) => { + const prevKey = format(subMonths(parseISO(`${x}T12:00:00`), compareSubMonths), 'yyyy-MM-dd'); + if (excludePreviousPeriodEnd && prevEnd && prevKey === prevEnd) { + return null; + } + if (!prevBy.has(prevKey)) { + return null; + } + return { x, y: prevBy.get(prevKey) }; + }) + .filter(Boolean); + + if (!previousRows?.length) { + return { + series: [{ data: cur, color, label: labelThis, fillOpacity: primaryFillOpacity }], + xDomain: fullDomain, + showEndGlyph: viewingCurrentMonth && cur.length > 0 + }; + } + + return { + series: [ + { data: cur, color, label: labelThis, fillOpacity: primaryFillOpacity }, + { + data: buildPrevAcrossFullMonth(), + color, + label: labelCompare, + strokeDasharray: compareStrokeDasharray, + strokeOpacity: compareStrokeOpacity, + fillOpacity: compareFillOpacity + } + ], + xDomain: fullDomain, + showEndGlyph: viewingCurrentMonth && cur.length > 0 + }; +} diff --git a/private-deps.lock b/private-deps.lock index 3d573c2b..edc7255b 100644 --- a/private-deps.lock +++ b/private-deps.lock @@ -1,7 +1,7 @@ { "events": { "repo": "git@github.com:Study-Compass/Events-Backend.git", - "ref": "5dbc9cb33e49828b46a0056c5bd8ddef6b6e2a25", + "ref": "4af5cda46b48fcd78f6be03a1e124868c8a36a15", "dest": "backend/events" } }