diff --git a/backend/constants/orgBetaFeatures.js b/backend/constants/orgBetaFeatures.js index f12b6339..44e7d05e 100644 --- a/backend/constants/orgBetaFeatures.js +++ b/backend/constants/orgBetaFeatures.js @@ -3,12 +3,42 @@ * Keep keys in sync with Meridian/frontend/src/constants/orgBetaFeatures.js */ const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks'; +const ORG_BETA_FEATURE_ORG_BUDGETING = 'org_budgeting'; +const ORG_BETA_FEATURE_ORG_GOVERNANCE = 'org_governance'; +const ORG_BETA_FEATURE_ORG_LIFECYCLE = 'org_lifecycle'; +const ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS = 'org_verification_requests'; +const ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON = 'coming_soon'; 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' + clubDashMenuKey: 'tasks', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_BUDGETING]: { + label: 'Budgeting', + description: 'Budgets page inside club settings.', + clubDashMenuKey: 'settings.budgets', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_GOVERNANCE]: { + label: 'Governance', + description: 'Governance page inside club settings.', + clubDashMenuKey: 'settings.governance', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_LIFECYCLE]: { + label: 'Life cycle', + description: 'Lifecycle page inside club settings.', + clubDashMenuKey: 'settings.lifecycle', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS]: { + label: 'Verification requests', + description: 'Verification requests page inside club settings.', + clubDashMenuKey: 'settings.verification_requests', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON } }; @@ -41,12 +71,18 @@ function getBetaFeatureCatalogForApi() { key, label: ORG_BETA_FEATURE_CATALOG[key].label, description: ORG_BETA_FEATURE_CATALOG[key].description, - clubDashMenuKey: ORG_BETA_FEATURE_CATALOG[key].clubDashMenuKey || null + clubDashMenuKey: ORG_BETA_FEATURE_CATALOG[key].clubDashMenuKey || null, + disabledMenuBehavior: + ORG_BETA_FEATURE_CATALOG[key].disabledMenuBehavior || ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON })); } module.exports = { ORG_BETA_FEATURE_ORG_TASKS, + ORG_BETA_FEATURE_ORG_BUDGETING, + ORG_BETA_FEATURE_ORG_GOVERNANCE, + ORG_BETA_FEATURE_ORG_LIFECYCLE, + ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS, ORG_BETA_FEATURE_KEYS, ORG_BETA_FEATURE_CATALOG, orgHasBetaFeature, diff --git a/backend/schemas/orgManagementConfig.js b/backend/schemas/orgManagementConfig.js index b0e82a36..0fecec20 100644 --- a/backend/schemas/orgManagementConfig.js +++ b/backend/schemas/orgManagementConfig.js @@ -178,6 +178,40 @@ const orgManagementConfigSchema = new mongoose.Schema({ } } }, + + // How disabled org beta pages appear in Club Dashboard navigation. + betaFeatures: { + disabledMenuBehaviorByKey: { + type: { + org_tasks: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_budgeting: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_governance: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_lifecycle: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_verification_requests: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + } + }, + default: () => ({}) + } + }, // Verification type settings enableCustomVerificationTypes: { diff --git a/frontend/src/assets/Brand Image/ADMIN.svg b/frontend/src/assets/Brand Image/ADMIN.svg index f7bfca26..5f7ec97b 100644 --- a/frontend/src/assets/Brand Image/ADMIN.svg +++ b/frontend/src/assets/Brand Image/ADMIN.svg @@ -1,25 +1,20 @@ - - - - + + + - + - + - - - - - + diff --git a/frontend/src/assets/Brand Image/BEACON1.svg b/frontend/src/assets/Brand Image/BEACON1.svg new file mode 100644 index 00000000..30269811 --- /dev/null +++ b/frontend/src/assets/Brand Image/BEACON1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx new file mode 100644 index 00000000..6820aade --- /dev/null +++ b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import './KeybindTooltip.scss'; + +/** + * Reusable keybind tooltip for hover-triggered UI hints. + * + * Intended usage: + * - Place inside a parent trigger that has `position: relative`. + * - Reveal on hover/focus from the parent container CSS. + * - Keep tooltip non-interactive (`pointer-events: none`) for simple hint behavior. + * + * @param {Object} props + * @param {string|React.ReactNode} props.label - Tooltip body label (e.g. "Month") + * @param {string|React.ReactNode} [props.keybind] - Optional keyboard hint shown in a boxed (e.g. "M") + * @param {string} [props.className] - Optional class name for placement/style overrides + * + * @example + * + */ +function KeybindTooltip({ label, keybind, className = '' }) { + return ( + + {label} + {keybind ? {keybind} : null} + + ); +} + +export default KeybindTooltip; diff --git a/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss new file mode 100644 index 00000000..797a0975 --- /dev/null +++ b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss @@ -0,0 +1,37 @@ +.keybind-tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%) translateY(4px); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; + z-index: 20; + white-space: nowrap; + padding: 0.22rem 0.45rem; + border-radius: 6px; + border: 1px solid rgba(30, 41, 59, 0.25); + background: #0f172a; + color: #f8fafc; + font-size: 0.7rem; + line-height: 1.2; + font-weight: 500; + + kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.1rem; + height: 1rem; + margin-left: 0.2rem; + padding: 0 0.2rem; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.65); + background: rgba(148, 163, 184, 0.18); + color: #e2e8f0; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.01em; + font-family: 'Inter', sans-serif; + } +} diff --git a/frontend/src/constants/orgBetaFeatures.js b/frontend/src/constants/orgBetaFeatures.js index 2e1525c6..5f44659e 100644 --- a/frontend/src/constants/orgBetaFeatures.js +++ b/frontend/src/constants/orgBetaFeatures.js @@ -3,12 +3,43 @@ * Keep keys in sync with Meridian/backend/constants/orgBetaFeatures.js */ export const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks'; +export const ORG_BETA_FEATURE_ORG_BUDGETING = 'org_budgeting'; +export const ORG_BETA_FEATURE_ORG_GOVERNANCE = 'org_governance'; +export const ORG_BETA_FEATURE_ORG_LIFECYCLE = 'org_lifecycle'; +export const ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS = 'org_verification_requests'; +export const ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON = 'coming_soon'; +export const ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE = 'hide'; 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' + clubDashMenuKey: 'tasks', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_BUDGETING]: { + label: 'Budgeting', + description: 'Budgets page inside club settings.', + clubDashMenuKey: 'settings.budgets', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_GOVERNANCE]: { + label: 'Governance', + description: 'Governance page inside club settings.', + clubDashMenuKey: 'settings.governance', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_LIFECYCLE]: { + label: 'Life cycle', + description: 'Lifecycle page inside club settings.', + clubDashMenuKey: 'settings.lifecycle', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS]: { + label: 'Verification requests', + description: 'Verification requests page inside club settings.', + clubDashMenuKey: 'settings.verification_requests', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON } }; diff --git a/frontend/src/hooks/useFetch.js b/frontend/src/hooks/useFetch.js index a462cfcb..2e18e65f 100644 --- a/frontend/src/hooks/useFetch.js +++ b/frontend/src/hooks/useFetch.js @@ -1,6 +1,24 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import axios from "axios"; +const DEFAULT_CACHE_TTL_MS = 60 * 1000; +const fetchResponseCache = new Map(); + +function stableSerialize(value) { + if (value === null || value === undefined) return ""; + if (typeof value !== "object") return String(value); + if (Array.isArray(value)) return `[${value.map((v) => stableSerialize(v)).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${k}:${stableSerialize(value[k])}`).join(",")}}`; +} + +function buildFetchCacheKey(url, options) { + const method = (options?.method || "GET").toUpperCase(); + const paramsPart = stableSerialize(options?.params || {}); + const dataPart = stableSerialize(options?.data || null); + return `${method}|${url}|params:${paramsPart}|data:${dataPart}`; +} + /** * Authenticated request with credentials and 401 refresh retry. * Use for one-off mutations (POST/PUT/DELETE) so components don't use axios directly. @@ -64,16 +82,36 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { data: options.data || null, headers: options.headers || {}, params: options.params || {}, - }), [options.method, options.data, options.headers, options.params]); + cache: options.cache || null, + }), [options.method, options.data, options.headers, options.params, options.cache]); const fetchData = useCallback(async (options = {}) => { - const { silent = false } = options; + const { silent = false, bypassCache = false } = options; // Don't fetch if URL is null or undefined if (!url) { setLoading(false); setData(null); return; } + + const requestMethod = (memoizedOptions.method || "GET").toUpperCase(); + const cacheConfig = memoizedOptions.cache; + const useCache = requestMethod === "GET" && Boolean(cacheConfig?.enabled); + const cacheKey = useCache ? buildFetchCacheKey(url, memoizedOptions) : null; + const cacheTtlMs = + typeof cacheConfig?.ttlMs === "number" && cacheConfig.ttlMs > 0 + ? cacheConfig.ttlMs + : DEFAULT_CACHE_TTL_MS; + + if (useCache && cacheKey && !bypassCache) { + const cached = fetchResponseCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < cacheTtlMs) { + setData(cached.data); + setError(null); + setLoading(false); + return; + } + } if (!silent) setLoading(true); setError(null); @@ -87,6 +125,9 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { params: memoizedOptions.params, }); setData(response.data); + if (useCache && cacheKey) { + fetchResponseCache.set(cacheKey, { data: response.data, timestamp: Date.now() }); + } } catch (err) { if (err.response?.status === 401 && (err.response?.data?.code === 'TOKEN_EXPIRED' || err.response?.data?.code === 'NO_TOKEN')) { @@ -102,6 +143,9 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { params: memoizedOptions.params, }); setData(retryResponse.data); + if (useCache && cacheKey) { + fetchResponseCache.set(cacheKey, { data: retryResponse.data, timestamp: Date.now() }); + } } catch (refreshError) { const refreshCode = refreshError.response?.data?.code; const shouldForceLogin = @@ -130,6 +174,6 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { fetchData(); }, [fetchData]); - const refetch = useCallback((opts) => fetchData(opts), [fetchData]); + const refetch = useCallback((opts = {}) => fetchData({ bypassCache: true, ...opts }), [fetchData]); return { data, loading, error, refetch }; }; diff --git a/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx b/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx index 18933891..68ce6e57 100644 --- a/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx +++ b/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx @@ -7,8 +7,12 @@ import Popup from '../../../components/Popup/Popup'; import { useNotification } from '../../../NotificationContext'; import postRequest from '../../../utils/postRequest'; +const BADGE_MANAGER_CACHE_TTL_MS = 60 * 1000; + const BadgeManager = ({}) => { - const badgeGrants = useFetch('/get-badge-grants'); + const badgeGrants = useFetch('/get-badge-grants', { + cache: { enabled: true, ttlMs: BADGE_MANAGER_CACHE_TTL_MS } + }); const { addNotification } = useNotification(); const [createPopup, setCreatePopup] = useState(false); const [newBadge, setNewBadge] = useState({ diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx index 3f8959a2..185b5877 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { DateRangePicker } from 'rsuite'; import { @@ -15,11 +15,15 @@ import { subWeeks, addWeeks, subDays, - addDays + addDays, + startOfHour, + addHours } from 'date-fns'; import { Icon } from '@iconify-icon/react'; import { useFetch } from '../../../../hooks/useFetch'; import useAuth from '../../../../hooks/useAuth'; +import Popup from '../../../../components/Popup/Popup'; +import KeybindTooltip from '../../../../components/Interface/KeybindTooltip/KeybindTooltip'; import KpiCard from '../../../../components/Analytics/Dashboard/KpiCard'; import ComparisonBadge from '../../../../components/Analytics/Dashboard/ComparisonBadge'; import { @@ -34,6 +38,13 @@ import 'rsuite/DateRangePicker/styles/index.css'; const TREND_METRICS_PARAM = 'screen_views,sessions,unique_visitors,explore_screen_views,new_users'; +const ADMIN_ANALYTICS_CACHE_TTL_MS = 2 * 60 * 1000; +const ARROW_KEY_NAV_DEBOUNCE_MS = 140; +const QUICK_RANGE_OPTIONS = [ + { id: 'month', label: 'month', shortcut: 'M' }, + { id: 'week', label: 'week', shortcut: 'W' }, + { id: 'day', label: 'day', shortcut: 'D' } +]; const TREND_METRIC_DEFS = [ { key: 'screen_views', title: 'Page views', color: '#45A1FC' }, @@ -87,6 +98,72 @@ function parseBucketBoundary(bucketValue, boundary = 'start') { return parsed; } +function getRangeGranularityRank(mode) { + if (mode === 'all') return 0; + if (mode === 'month') return 1; + if (mode === 'week') return 2; + if (mode === 'day') return 3; + if (mode === 'custom') return 4; + return 5; +} + +function toBucketDate(bucket, granularity) { + if (!bucket) return null; + if (granularity === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(String(bucket))) { + const parsed = parseISO(`${bucket}T00:00:00`); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + const parsed = new Date(bucket); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatBucketValue(date, granularity, sampleBucket) { + if (granularity === 'day') { + return format(date, 'yyyy-MM-dd'); + } + if (granularity === 'hour') { + const sample = String(sampleBucket || ''); + if (sample.endsWith('Z') || sample.includes('.')) { + return date.toISOString(); + } + return format(date, "yyyy-MM-dd'T'HH:mm:ss"); + } + return date.toISOString(); +} + +function padTimeseriesRows(rows, { granularity, start, end, capEndAtNow = false }) { + if (!Array.isArray(rows) || !rows.length || !start || !end) return rows; + if (granularity !== 'day' && granularity !== 'hour') return rows; + + const now = new Date(); + const rawEnd = capEndAtNow && end > now ? now : end; + const rangeStart = granularity === 'hour' ? startOfHour(start) : startOfDay(start); + const rangeEnd = granularity === 'hour' ? startOfHour(rawEnd) : startOfDay(rawEnd); + if (rangeEnd < rangeStart) return rows; + + const sampleBucket = rows[0]?.bucket; + const byTs = new Map(); + rows.forEach((row) => { + const dt = toBucketDate(row?.bucket, granularity); + if (!dt) return; + const keyDate = granularity === 'hour' ? startOfHour(dt) : startOfDay(dt); + byTs.set(keyDate.getTime(), Number(row?.value) || 0); + }); + + const padded = []; + let cursor = rangeStart; + while (cursor <= rangeEnd) { + const cursorKey = cursor.getTime(); + padded.push({ + bucket: formatBucketValue(cursor, granularity, sampleBucket), + value: byTs.get(cursorKey) ?? 0 + }); + cursor = granularity === 'hour' ? addHours(cursor, 1) : addDays(cursor, 1); + } + + return padded; +} + function KpiInfoTitle({ label, info }) { return ( @@ -113,7 +190,11 @@ function AdminPlatformAnalytics() { const [debouncedCustomRange, setDebouncedCustomRange] = useState(null); const [chartHoverSync, setChartHoverSync] = useState(null); const [showFiltersPopup, setShowFiltersPopup] = useState(false); + const [showDetailedChartsPopup, setShowDetailedChartsPopup] = useState(false); const [isCustomRangePickerOpen, setIsCustomRangePickerOpen] = useState(false); + const [keyboardNavActive, setKeyboardNavActive] = useState(null); + const arrowKeyDebounceRef = useRef(0); + const keyboardActiveTimeoutRef = useRef(null); const handleChartHoverSyncChange = useCallback((signal) => { if (!signal || signal.type === 'leave') { setChartHoverSync(null); @@ -157,7 +238,8 @@ function AdminPlatformAnalytics() { const { data: snapRes, loading, error, refetch } = useFetch(snapshotUrl, { method: 'GET', - params: snapshotParams + params: snapshotParams, + cache: { enabled: true, ttlMs: ADMIN_ANALYTICS_CACHE_TTL_MS } }); const payload = snapRes?.data; @@ -186,7 +268,8 @@ function AdminPlatformAnalytics() { const prevTsUrl = isAuthenticated && comparisonEnabled && prevTimeseriesParams ? '/dashboard/timeseries' : null; const { data: prevTsRes, loading: prevTsLoading } = useFetch(prevTsUrl, { method: 'GET', - params: prevTimeseriesParams || {} + params: prevTimeseriesParams || {}, + cache: { enabled: true, ttlMs: ADMIN_ANALYTICS_CACHE_TTL_MS } }); const comparePeriodLabel = @@ -205,18 +288,38 @@ function AdminPlatformAnalytics() { const trendCharts = useMemo(() => { const prevInner = prevTsRes?.data; const useFullMonthDayDomain = debouncedMode === 'month' && ts?.granularity === 'day'; + const currentWindow = + debouncedMode === 'custom' && debouncedCustomRange + ? debouncedCustomRange + : computeRange(debouncedMode, debouncedAnchor); + const previousWindowStart = kpi?.windows?.previous?.start ? new Date(kpi.windows.previous.start) : null; + const previousWindowEnd = kpi?.windows?.previous?.end ? new Date(kpi.windows.previous.end) : null; 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; + const paddedCurrentRows = padTimeseriesRows(curRowsExcludingEnd, { + granularity: ts?.granularity, + start: currentWindow.start, + end: currentWindow.end, + capEndAtNow: true + }); + const paddedPreviousRows = comparisonEnabled + ? padTimeseriesRows(pRowsExcludingEnd, { + granularity: ts?.granularity, + start: previousWindowStart, + end: previousWindowEnd, + capEndAtNow: false + }) + : pRowsExcludingEnd; - if (useFullMonthDayDomain && curRowsExcludingEnd?.length) { + if (useFullMonthDayDomain && paddedCurrentRows?.length) { const spec = buildComparisonVisxSeriesForCalendarMonthView( debouncedAnchor, - curRowsExcludingEnd, - pRowsExcludingEnd, + paddedCurrentRows, + paddedPreviousRows, def.color, { thisPeriod: 'This period', compare: comparePeriodLabel }, { @@ -231,13 +334,13 @@ function AdminPlatformAnalytics() { showEndGlyph: spec.showEndGlyph, totalValue: metricTotalsFromKpi[def.key] ?? - curRowsExcludingEnd.reduce((sum, row) => sum + (Number(row?.value) || 0), 0) + paddedCurrentRows.reduce((sum, row) => sum + (Number(row?.value) || 0), 0) }; } const { series } = buildComparisonVisxSeries( - curRowsExcludingEnd, - pRowsExcludingEnd, + paddedCurrentRows, + paddedPreviousRows, def.color, { thisPeriod: 'This period', compare: comparePeriodLabel }, { excludePreviousPeriodEnd: false } @@ -249,25 +352,33 @@ function AdminPlatformAnalytics() { showEndGlyph: false, totalValue: metricTotalsFromKpi[def.key] ?? - (curRowsExcludingEnd || []).reduce((sum, row) => sum + (Number(row?.value) || 0), 0) + (paddedCurrentRows || []).reduce((sum, row) => sum + (Number(row?.value) || 0), 0) }; }); - }, [ts, prevTsRes, comparisonEnabled, comparePeriodLabel, debouncedMode, debouncedAnchor, previousPeriodMode, metricTotalsFromKpi]); + }, [ts, prevTsRes, comparisonEnabled, comparePeriodLabel, debouncedMode, debouncedAnchor, previousPeriodMode, metricTotalsFromKpi, debouncedCustomRange, kpi]); const showCompare = previousPeriodMode !== 'none' && kpi?.deltas; const handleRangeModeChange = useCallback((mode) => { + const previousMode = rangeMode; + const previousWindow = + previousMode === 'custom' && customRange + ? customRange + : computeRange(previousMode, anchorDate); + const shrinkingToMoreGranular = + getRangeGranularityRank(mode) > getRangeGranularityRank(previousMode); + const baseDate = shrinkingToMoreGranular ? previousWindow.start : new Date(); + 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); - }, []); + if (mode === 'month') setAnchorDate(startOfMonth(baseDate)); + else if (mode === 'week') setAnchorDate(startOfWeek(baseDate, { weekStartsOn: 0 })); + else setAnchorDate(startOfDay(baseDate)); + }, [rangeMode, customRange, anchorDate]); const handleTrendRangeSelect = useCallback(({ startXValue, endXValue }) => { if (!startXValue || !endXValue) return; const normalizedStart = parseBucketBoundary(startXValue, 'start'); @@ -307,32 +418,80 @@ function AdminPlatformAnalytics() { 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]); + else if (rangeMode === 'custom' && customRange?.start && customRange?.end) { + const windowMs = customRange.end.getTime() - customRange.start.getTime(); + if (windowMs <= 0) return; + const nextStart = new Date(customRange.start.getTime() - windowMs); + const nextEnd = new Date(customRange.end.getTime() - windowMs); + setCustomRange({ start: nextStart, end: nextEnd }); + setAnchorDate(nextStart); + } + }, [rangeMode, customRange]); 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]); + else if (rangeMode === 'custom' && customRange?.start && customRange?.end) { + const windowMs = customRange.end.getTime() - customRange.start.getTime(); + if (windowMs <= 0) return; + const nextStart = new Date(customRange.start.getTime() + windowMs); + const nextEnd = new Date(customRange.end.getTime() + windowMs); + setCustomRange({ start: nextStart, end: nextEnd }); + setAnchorDate(nextStart); + } + }, [rangeMode, customRange]); + + const flashKeyboardArrowState = useCallback((direction) => { + setKeyboardNavActive(direction); + if (keyboardActiveTimeoutRef.current) { + clearTimeout(keyboardActiveTimeoutRef.current); + } + keyboardActiveTimeoutRef.current = setTimeout(() => { + setKeyboardNavActive(null); + }, 120); + }, []); + + useEffect(() => { + return () => { + if (keyboardActiveTimeoutRef.current) { + clearTimeout(keyboardActiveTimeoutRef.current); + } + }; + }, []); useEffect(() => { const handleKeyDown = (event) => { if (rangeMode === 'all' || isTypingTarget(event.target)) return; + const key = String(event.key || '').toLowerCase(); if (showFiltersPopup && event.key === 'Escape') { setShowFiltersPopup(false); return; } - if (event.key === 'ArrowLeft') { + if (key === 'm' || key === 'w' || key === 'd') { event.preventDefault(); + if (key === 'm') handleRangeModeChange('month'); + else if (key === 'w') handleRangeModeChange('week'); + else handleRangeModeChange('day'); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + const now = Date.now(); + if (now - arrowKeyDebounceRef.current < ARROW_KEY_NAV_DEBOUNCE_MS) return; + arrowKeyDebounceRef.current = now; + flashKeyboardArrowState('left'); navPrev(); } else if (event.key === 'ArrowRight') { event.preventDefault(); + const now = Date.now(); + if (now - arrowKeyDebounceRef.current < ARROW_KEY_NAV_DEBOUNCE_MS) return; + arrowKeyDebounceRef.current = now; + flashKeyboardArrowState('right'); navNext(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [rangeMode, navPrev, navNext, showFiltersPopup]); + }, [rangeMode, navPrev, navNext, showFiltersPopup, handleRangeModeChange, flashKeyboardArrowState]); if (!isAuthenticated) { return null; @@ -385,6 +544,17 @@ function AdminPlatformAnalytics() { adjacent: 'Previous period', lastYear: 'Same window last year' }; + const detailedRangeBoundsLabel = (() => { + if (rangeMode === 'custom' && customRange) { + return `${format(customRange.start, 'MMM d, yyyy h:mm aa')} – ${format(customRange.end, 'MMM d, yyyy h:mm aa')}`; + } + if (rangeMode === 'all') { + const allRange = computeRange('all', anchorDate); + return `${format(allRange.start, 'MMM d, yyyy')} – ${format(allRange.end, 'MMM d, yyyy')}`; + } + const computed = computeRange(rangeMode, anchorDate); + return `${format(computed.start, 'MMM d, yyyy')} – ${format(computed.end, 'MMM d, yyyy')}`; + })(); return (
@@ -393,7 +563,12 @@ function AdminPlatformAnalytics() {
{rangeMode !== 'all' && rangeMode !== 'custom' ? (
- @@ -406,7 +581,12 @@ function AdminPlatformAnalytics() { )}`} {rangeMode === 'day' && format(anchorDate, 'MMM d, yyyy')} -
@@ -422,14 +602,18 @@ function AdminPlatformAnalytics() {
)}
- {['month', 'week', 'day'].map((m) => ( + {QUICK_RANGE_OPTIONS.map(({ id, label, shortcut }) => ( ))}
@@ -481,10 +665,16 @@ function AdminPlatformAnalytics() { ))}
@@ -672,14 +862,23 @@ function AdminPlatformAnalytics() {

Trends

- +
+ + +
{trendCharts.map(({ def, series, xDomain, showEndGlyph, totalValue }) => ( @@ -703,6 +902,81 @@ function AdminPlatformAnalytics() {
+ setShowDetailedChartsPopup(false)} + customClassName="wider-content admin-platform-analytics__detailed-popup" + > +
+
+

Detailed trend graphs

+

Expanded charts with denser point-level detail. Uses the same date range and comparison settings.

+
+
+
+ Date bounds: {detailedRangeBoundsLabel} +
+
+
+ {QUICK_RANGE_OPTIONS.map(({ id, label, shortcut }) => ( + + ))} +
+
+ +
+
+
+
+ {trendCharts.map(({ def, series, xDomain, showEndGlyph, totalValue }) => ( + + ))} +
+
+
+

Mobile app snapshot diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss index 9bf8ebbd..ecf262b1 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss @@ -160,6 +160,27 @@ border-radius: 6px; background: #fff; cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 80ms ease; + + &:hover { + border-color: rgba(69, 161, 252, 0.55); + background: rgba(69, 161, 252, 0.1); + color: #1e40af; + } + + &:active { + border-color: rgba(30, 64, 175, 0.6); + background: rgba(30, 64, 175, 0.16); + color: #1e3a8a; + transform: translateY(1px) scale(0.98); + } + + &.is-key-active { + border-color: rgba(30, 64, 175, 0.6); + background: rgba(30, 64, 175, 0.16); + color: #1e3a8a; + transform: translateY(1px) scale(0.98); + } } } @@ -217,6 +238,16 @@ } } + &__kbd-tooltip-button { + position: relative; + overflow: visible; + + &:hover .keybind-tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + &__kpis.analytics-container { margin-bottom: 1rem; display: grid; @@ -295,6 +326,12 @@ } } + &__chart-actions { + display: flex; + align-items: center; + gap: 0.45rem; + } + &__chart-reset-btn { padding: 0.3rem 0.65rem; font-size: 0.78rem; @@ -309,6 +346,97 @@ } } + &__detailed-popup { + max-height: 92vh; + max-width: min(1200px, 96vw) !important; + + .close-popup { + top: 12px; + right: 12px; + } + } + + &__detailed-popup-body { + padding-top: 1.2rem; + display: flex; + flex-direction: column; + max-height: calc(92vh - 40px); + overflow: hidden; + } + + &__detailed-popup-header { + margin-bottom: 0.7rem; + + h3 { + margin: 0; + font-size: 1.1rem; + } + + p { + margin: 0.25rem 0 0 0; + font-size: 0.84rem; + color: #64748b; + } + } + + &__detailed-popup-charts { + display: flex; + flex-direction: column; + gap: 0.7rem; + border-top: 1px solid var(--lighterborder); + padding-top: 0.7rem; + overflow-y: auto; + min-height: 0; + padding-right: 4px; + } + + &__detailed-popup-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.6rem; + margin-bottom: 0.65rem; + padding: 0.55rem; + border: 1px solid var(--lighterborder); + border-radius: 8px; + background: color-mix(in srgb, var(--background, #fff) 94%, #94a3b8 6%); + } + + &__detailed-popup-bounds { + margin-bottom: 0; + font-size: 0.8rem; + color: #334155; + font-weight: 500; + } + + &__detailed-popup-bounds--prominent { + display: flex; + align-items: center; + min-height: 34px; + padding: 0.42rem 0.65rem; + border: 1px solid rgba(37, 99, 235, 0.28); + border-radius: 7px; + background: rgba(59, 130, 246, 0.1); + color: #1e3a8a; + font-size: 0.82rem; + white-space: nowrap; + + strong { + margin-right: 0.3rem; + font-weight: 700; + } + } + + &__detailed-popup-controls-right { + margin-left: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; + } + &__chart { height: 280px; position: relative; @@ -416,86 +544,4 @@ } } - /* 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 index e74ae905..306fe717 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx @@ -1,6 +1,7 @@ import React from 'react'; import EventDashboardChart from '../../../ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart'; import { formatBucketAxisLabel } from '../../../../utils/analyticsDashboardUtils'; +import './AdminPlatformMetricChart.scss'; /** * Single metric chart styled like legacy `.visit-chart` (General / Analytics), @@ -20,14 +21,15 @@ function AdminPlatformMetricChart({ hoverSyncSignal, onHoverSyncChange, enableRangeSelection = false, - onRangeSelect + onRangeSelect, + detailedView = false }) { const xTickFormat = React.useCallback((x) => formatBucketAxisLabel(x, granularity), [granularity]); const isEmpty = !series?.length || series.every((s) => !s?.data?.length); return ( -
+
{totalValue != null ? ( @@ -48,6 +50,7 @@ function AdminPlatformMetricChart({ showLine showGlyph={showEndGlyph} showGlyphPrimaryOnly={showEndGlyph} + showPointMarkers={detailedView} xDomain={xDomain} emptyMessage={emptyMessage} xTickFormat={xTickFormat} diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss new file mode 100644 index 00000000..b5373ddd --- /dev/null +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss @@ -0,0 +1,124 @@ +.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; +} + +.admin-platform-trend-chart .chart-container-visx { + width: 100%; + min-height: 160px; + overflow: visible; +} + +.admin-platform-trend-chart .chart-container-visx > div { + width: 100% !important; +} + +.admin-platform-trend-chart--detailed { + border: 1px solid var(--lighterborder); + border-radius: 10px; + padding: 14px; + background: var(--background, #fff); +} + +.chart-empty-visx { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: #94a3b8; + font-size: 0.875rem; +} + +/* Keep tooltip visuals tied to chart component usage, not parent pages. */ +.rsvp-chart-tooltip-wrapper.visx-tooltip { + padding: 0; + background: transparent; + border: none; + box-shadow: none; + z-index: 9999 !important; + pointer-events: none; +} + +.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: 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/SiteHealth/SiteHealth.jsx b/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx index 7dc74fea..2fe50c77 100644 --- a/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx +++ b/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx @@ -5,6 +5,8 @@ import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; import PulseDot from '../../../../components/Interface/PulseDot/PulseDot'; import AnimatedNumber from '../../../../components/Interface/AnimatedNumber/AnimatedNumber'; +const SITE_HEALTH_CACHE_TTL_MS = 5000; + function formatUptime(seconds) { const d = Math.floor(seconds / (3600 * 24)); const h = Math.floor((seconds % (3600 * 24)) / 3600); @@ -20,7 +22,9 @@ function formatUptime(seconds) { } const SiteHealth = ({}) => { - const health = useFetch('/health'); + const health = useFetch('/health', { + cache: { enabled: true, ttlMs: SITE_HEALTH_CACHE_TTL_MS } + }); const [good, setGood] = useState(true); const [showDetailed, setShowDetailed] = useState(false); diff --git a/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx b/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx index 286fe732..da76d808 100644 --- a/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx +++ b/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx @@ -7,6 +7,7 @@ import defaultAvatar from '../../../assets/defaultAvatar.svg'; import './ManageUsers.scss'; const AVAILABLE_ROLES = ['user', 'admin', 'moderator', 'developer', 'oie', 'beta']; +const ADMIN_USER_ANALYTICS_CACHE_TTL_MS = 30 * 1000; function ManageUsers() { const { addNotification } = useNotification(); @@ -19,7 +20,8 @@ function ManageUsers() { const [impersonating, setImpersonating] = useState(false); const { data: analyticsData, loading: analyticsLoading } = useFetch( - selectedUser ? `/admin/user/${selectedUser._id}/analytics?limit=50` : null + selectedUser ? `/admin/user/${selectedUser._id}/analytics?limit=50` : null, + { cache: { enabled: true, ttlMs: ADMIN_USER_ANALYTICS_CACHE_TTL_MS } } ); const fetchUsers = useCallback(async () => { diff --git a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx index a31dde8f..2dcfac58 100644 --- a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx +++ b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx @@ -34,6 +34,7 @@ const SUGGESTED_COMMUNITY_DEFAULTS = { /** User must type this exactly (trimmed) to confirm a tenant mode switch. */ const CONFIRM_TYPE_PHRASE = 'UPDATE TENANT MODE'; +const ADMIN_PAGE_CACHE_TTL_MS = 60 * 1000; /** * @param {'community_with_defaults' | 'community_layout' | 'classic'} variant @@ -161,7 +162,9 @@ function OperatorHubMode() { const [confirmVariant, setConfirmVariant] = useState(null); const [typedPhrase, setTypedPhrase] = useState(''); const [phraseError, setPhraseError] = useState(''); - const { data: configData, refetch, loading } = useFetch('/org-management/config'); + const { data: configData, refetch, loading } = useFetch('/org-management/config', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const config = configData?.data; const current = config?.operatorDashboardMode === 'engagement_hub' ? 'engagement_hub' : 'classic'; diff --git a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx index 831a9778..9126dadb 100644 --- a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx +++ b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx @@ -8,6 +8,8 @@ import apiRequest from '../../../utils/postRequest'; import '../General/General.scss'; import './PlatformAdminsPage.scss'; +const ADMIN_PAGE_CACHE_TTL_MS = 60 * 1000; + function PlatformAdminsPage() { const { addNotification } = useNotification(); const { AdminGrad } = useGradient(); @@ -18,17 +20,23 @@ function PlatformAdminsPage() { const [savingTenants, setSavingTenants] = useState(false); const [savingAutoClaim, setSavingAutoClaim] = useState(false); - const { data: listResponse, loading, error: fetchError, refetch } = useFetch('/admin/platform-admins'); + const { data: listResponse, loading, error: fetchError, refetch } = useFetch('/admin/platform-admins', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const list = listResponse?.success ? (listResponse.data || []) : []; const { data: tenantConfigResponse, loading: tenantConfigLoading, error: tenantConfigFetchError, refetch: refetchTenantConfig, - } = useFetch('/admin/tenant-config'); + } = useFetch('/admin/tenant-config', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const tenantRows = tenantConfigResponse?.success ? (tenantConfigResponse.data?.tenants || []) : []; - const { data: orgConfigResponse, refetch: refetchOrgConfig } = useFetch('/org-management/config'); + const { data: orgConfigResponse, refetch: refetchOrgConfig } = useFetch('/org-management/config', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const orgConfig = orgConfigResponse?.data; const autoClaimEnabled = orgConfig?.autoClaimEnabled ?? false; diff --git a/frontend/src/pages/Admin/QRManager/QRManager.jsx b/frontend/src/pages/Admin/QRManager/QRManager.jsx index 8cc4d690..178a0779 100644 --- a/frontend/src/pages/Admin/QRManager/QRManager.jsx +++ b/frontend/src/pages/Admin/QRManager/QRManager.jsx @@ -12,6 +12,8 @@ import EventDashboardChart from '../../ClubDash/EventsManagement/components/Even import DateTimePicker from '../../../components/DateTimePicker/DateTimePicker'; import './QRManager.scss'; +const QR_MANAGER_CACHE_TTL_MS = 90 * 1000; + function SparklinePreview({ data = [], color = '#4DAA57', width = 72, height = 28, id }) { if (!data?.length) return null; const values = data.map((d) => d.y); @@ -126,8 +128,12 @@ const QRManager = () => { return `${base}?${params}`; }, [dateRangeOverride]); - const { data: qrData, loading: qrLoading, error: qrError, refetch: refetchQRCodes } = useFetch(`/api/qr?${qrParams}`); - const { data: analyticsData, refetch: refetchAnalytics } = useFetch(analyticsUrl); + const { data: qrData, loading: qrLoading, error: qrError, refetch: refetchQRCodes } = useFetch(`/api/qr?${qrParams}`, { + cache: { enabled: true, ttlMs: QR_MANAGER_CACHE_TTL_MS } + }); + const { data: analyticsData, refetch: refetchAnalytics } = useFetch(analyticsUrl, { + cache: { enabled: true, ttlMs: QR_MANAGER_CACHE_TTL_MS } + }); const qrCodes = qrData?.qrCodes || []; const totalPages = qrData?.pagination?.totalPages || 1; diff --git a/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx b/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx index 1a18d2ac..9f0e84cf 100644 --- a/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx +++ b/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx @@ -5,6 +5,8 @@ import { useNotification } from '../../../NotificationContext'; import { Icon } from '@iconify-icon/react'; import './WebSocketConnections.scss'; +const WEBSOCKET_CACHE_TTL_MS = 5000; + function formatDuration(ms) { if (!ms) return '—'; const s = Math.floor((Date.now() - ms) / 1000); @@ -18,7 +20,9 @@ const WebSocketConnections = () => { const [disconnecting, setDisconnecting] = useState(null); const [disconnectAllConfirm, setDisconnectAllConfirm] = useState(false); - const { data, loading, error, refetch } = useFetch('/websocket-connections'); + const { data, loading, error, refetch } = useFetch('/websocket-connections', { + cache: { enabled: true, ttlMs: WEBSOCKET_CACHE_TTL_MS } + }); const handleDisconnect = async (socketId) => { setDisconnecting(socketId); diff --git a/frontend/src/pages/ClubDash/ClubDash.jsx b/frontend/src/pages/ClubDash/ClubDash.jsx index 2978efbe..5148dd0d 100644 --- a/frontend/src/pages/ClubDash/ClubDash.jsx +++ b/frontend/src/pages/ClubDash/ClubDash.jsx @@ -42,7 +42,16 @@ import ClubDashOnboarding from './ClubDashOnboarding/ClubDashOnboarding'; import GovernanceSettings from './OrgSettings/components/GovernanceSettings'; import BudgetSettings from './OrgSettings/components/BudgetSettings'; import LifecycleSettings from './OrgSettings/components/LifecycleSettings'; -import { ORG_BETA_FEATURE_ORG_TASKS, orgHasBetaFeature } from '../../constants/orgBetaFeatures'; +import { + ORG_BETA_FEATURE_ORG_TASKS, + ORG_BETA_FEATURE_ORG_BUDGETING, + ORG_BETA_FEATURE_ORG_GOVERNANCE, + ORG_BETA_FEATURE_ORG_LIFECYCLE, + ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS, + ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON, + ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE, + orgHasBetaFeature +} from '../../constants/orgBetaFeatures'; /** Set to true to always show the onboarding popup (ignores localStorage) */ const FORCE_CLUB_DASH_ONBOARDING = false; @@ -295,6 +304,39 @@ function ClubDash(){ const orgMongoId = orgOverview?._id; const tasksComingSoon = !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_TASKS); + const budgetingComingSoon = + !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_BUDGETING); + const governanceComingSoon = + !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_GOVERNANCE); + const lifecycleComingSoon = + !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_LIFECYCLE); + const verificationRequestsComingSoon = + !adminBypass && + !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS); + const disabledMenuBehaviorByKey = + configData?.data?.betaFeatures?.disabledMenuBehaviorByKey || {}; + const getDisabledMenuBehavior = (featureKey) => { + const mode = disabledMenuBehaviorByKey[featureKey]; + if (mode === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE) return mode; + return ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON; + }; + const tasksDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_TASKS); + const budgetingDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_BUDGETING); + const governanceDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_GOVERNANCE); + const lifecycleDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_LIFECYCLE); + const verificationRequestsDisabledMenuBehavior = getDisabledMenuBehavior( + ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS + ); + const tasksHidden = tasksComingSoon && tasksDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const budgetingHidden = + budgetingComingSoon && budgetingDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const governanceHidden = + governanceComingSoon && governanceDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const lifecycleHidden = + lifecycleComingSoon && lifecycleDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const verificationRequestsHidden = + verificationRequestsComingSoon && + verificationRequestsDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; useEffect(() => { if (orgData.loading) return; @@ -333,7 +375,8 @@ function ClubDash(){ label: 'Tasks', icon: 'mdi:check-all', key: 'tasks', - comingSoon: tasksComingSoon, + hidden: tasksHidden, + comingSoon: tasksComingSoon && !tasksHidden, element: tasksComingSoon ? null : ( Loading tasks…
}> @@ -402,7 +445,9 @@ function ClubDash(){ { label: 'Lifecycle', icon: 'mdi:state-machine', - element: ( + hidden: lifecycleHidden, + comingSoon: lifecycleComingSoon && !lifecycleHidden, + element: lifecycleComingSoon ? null : ( ) }, @@ -420,12 +467,20 @@ function ClubDash(){ label: 'Budgets', icon: 'mdi:cash-multiple', requiresFinances: true, - element: + hidden: budgetingHidden, + comingSoon: budgetingComingSoon && !budgetingHidden, + element: budgetingComingSoon ? null : ( + + ) }, { label: 'Verification Requests', icon: 'mdi:shield-check', - element: + hidden: verificationRequestsHidden, + comingSoon: verificationRequestsComingSoon && !verificationRequestsHidden, + element: verificationRequestsComingSoon ? null : ( + + ) }, { label: 'Danger Zone', @@ -454,10 +509,13 @@ function ClubDash(){ userPermissions.canAccessBudgets; return { ...item, - subItems: item.subItems.filter((sub) => !sub.requiresFinances || showBudgets) + subItems: item.subItems.filter( + (sub) => (!sub.requiresFinances || showBudgets) && !sub.hidden + ) }; }); return withFilteredSettings.filter((item) => { + if (item.hidden) return false; if (isAdminView && isSiteAdmin) return true; if (!item.requiresPermission) return true; return userPermissions[item.requiresPermission]; @@ -474,7 +532,17 @@ function ClubDash(){ adminBypass, isAdminView, isSiteAdmin, - tasksComingSoon + tasksComingSoon, + tasksHidden, + budgetingComingSoon, + budgetingHidden, + governanceComingSoon, + governanceHidden, + lifecycleComingSoon, + lifecycleHidden, + verificationRequestsComingSoon, + verificationRequestsHidden, + configData ]); menuItemsRef.current = menuItems; diff --git a/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx b/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx index 4424a662..42a61342 100644 --- a/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx +++ b/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx @@ -425,7 +425,7 @@ const GeneralSettings = ({ org, expandedClass, adminBypass = false }) => { ) }, { - title: 'Weekly Meeting Time - NOTE: on reoccuring meeting refactor replace this with a create event button that creates popup for event creation w/ reoccurance embeded.', + title: 'Weekly Meeting Time ', subtitle: 'The time of your weekly meeting', action: (
diff --git a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx index 4852587f..2e3cb39a 100644 --- a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx +++ b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx @@ -4,9 +4,12 @@ import { useGradient } from '../../../../hooks/useGradient'; import { useNotification } from '../../../../NotificationContext'; import apiRequest from '../../../../utils/postRequest'; import { Icon } from '@iconify-icon/react'; +import Popup from '../../../../components/Popup/Popup'; import { ORG_BETA_FEATURE_KEYS, - ORG_BETA_FEATURE_CATALOG + ORG_BETA_FEATURE_CATALOG, + ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON, + ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE } from '../../../../constants/orgBetaFeatures'; import './OrgBetaFeatures.scss'; @@ -33,6 +36,8 @@ function OrgBetaFeatures() { page: 1 }); const [savingByOrgId, setSavingByOrgId] = useState({}); + const [savingDisabledBehavior, setSavingDisabledBehavior] = useState(false); + const [showDisabledBehaviorPopup, setShowDisabledBehaviorPopup] = useState(false); useEffect(() => { const id = window.setTimeout(() => { @@ -58,6 +63,18 @@ function OrgBetaFeatures() { const { data: orgs, loading, error, refetch } = useFetch( `/org-management/organizations?${listQuery}` ); + const { data: configRes, refetch: refetchConfig } = useFetch('/org-management/config'); + const disabledMenuBehaviorByKey = useMemo(() => { + const fromConfig = configRes?.data?.betaFeatures?.disabledMenuBehaviorByKey || {}; + return ORG_BETA_FEATURE_KEYS.reduce((acc, key) => { + const mode = fromConfig[key]; + acc[key] = + mode === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE + ? ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE + : ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON; + return acc; + }, {}); + }, [configRes]); const pagination = orgs?.pagination; const totalItems = pagination?.total ?? 0; @@ -129,6 +146,46 @@ function OrgBetaFeatures() { [patchOrgBetaFeatures] ); + const updateDisabledBehavior = useCallback( + async (featureKey, nextMode) => { + setSavingDisabledBehavior(true); + try { + const nextMap = { + ...disabledMenuBehaviorByKey, + [featureKey]: nextMode + }; + const res = await apiRequest( + '/org-management/config', + { betaFeatures: { disabledMenuBehaviorByKey: nextMap } }, + { method: 'PUT' } + ); + if (res?.success) { + addNotification({ + title: 'Saved', + message: 'Disabled beta menu behavior updated.', + type: 'success' + }); + refetchConfig(); + } else { + addNotification({ + title: 'Error', + message: res?.message || 'Could not save disabled menu behavior.', + type: 'error' + }); + } + } catch (e) { + addNotification({ + title: 'Error', + message: e?.message || 'Could not save disabled menu behavior.', + type: 'error' + }); + } finally { + setSavingDisabledBehavior(false); + } + }, + [addNotification, disabledMenuBehaviorByKey, refetchConfig] + ); + const isInitialLoad = loading && orgs == null; if (isInitialLoad) { @@ -181,6 +238,14 @@ function OrgBetaFeatures() {
+
{error && orgs != null && ( @@ -286,6 +351,46 @@ function OrgBetaFeatures() {
)}
+ setShowDisabledBehaviorPopup(false)} + customClassName="org-beta-features__config-popup" + > +
+

Disabled menu behavior

+

+ Choose what users see when a beta feature is disabled for an organization. +

+
+ {features.map((feature) => ( + + ))} +
+
+

); diff --git a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss index 9101754c..d9545fb5 100644 --- a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss +++ b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss @@ -1,28 +1,5 @@ .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; @@ -67,12 +44,74 @@ font: inherit; } + &__config-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border-color, #d0d7de); + background: var(--surface, #fff); + color: inherit; + font: inherit; + cursor: pointer; + transition: background 140ms ease; + + &:hover { + background: #f5f7f8; + } + } + &__meta { font-size: 0.9rem; color: var(--text-muted, #5c6570); margin: 0 0 0.75rem; } + &__disabled-config { + border-radius: 10px; + padding: 0.9rem; + background: var(--surface, #fff); + + h2 { + margin: 0 0 0.35rem; + font-size: 1rem; + } + + p { + margin: 0 0 0.75rem; + color: var(--text-muted, #5c6570); + font-size: 0.9rem; + } + } + + &__config-popup { + width: min(680px, calc(100vw - 32px)); + } + + &__disabled-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.65rem; + } + + &__disabled-item { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; + + select { + font: inherit; + font-weight: 400; + padding: 0.4rem 0.5rem; + border-radius: 8px; + border: 1px solid var(--border-color, #d0d7de); + background: var(--surface, #fff); + } + } + &__table-wrap { overflow-x: auto; border: 1px solid var(--border-color, #d0d7de); diff --git a/frontend/src/utils/analyticsDashboardUtils.js b/frontend/src/utils/analyticsDashboardUtils.js index 06b6792a..05f3c252 100644 --- a/frontend/src/utils/analyticsDashboardUtils.js +++ b/frontend/src/utils/analyticsDashboardUtils.js @@ -185,9 +185,10 @@ export function buildComparisonVisxSeriesForCalendarMonthView( 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)); + const curBy = new Map((currentRows || []).map((r) => [r.bucket, Number(r.value) || 0])); + const curInRange = fullDomain + .filter((x) => x >= monthStart && x <= cutoffIso) + .map((x) => ({ bucket: x, value: curBy.get(x) ?? 0 })); if (!curInRange.length) { return { series: [], xDomain: undefined, showEndGlyph: false }; @@ -202,7 +203,7 @@ export function buildComparisonVisxSeriesForCalendarMonthView( .slice(-1)[0] : null; - const cur = curInRange.map((r) => ({ x: r.bucket, y: r.value })); + const cur = curInRange.map((r) => ({ x: r.bucket, y: Number(r.value) || 0 })); const buildPrevAcrossFullMonth = () => fullDomain