-
-
+
+
+
+
+
+
+ Insights
+
+
+ Cross-facility activity, engagement & outcomes
+
+
+
+
+ Export Report
+
+
+
+
+
+
+
+ {RANGE_OPTIONS.map((range) => (
+ setActiveRange(range)}
+ className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
+ activeRange === range
+ ? 'bg-white dark:bg-card shadow-sm text-brand-dark dark:text-white'
+ : 'text-gray-500 dark:text-gray-400 hover:text-brand-dark dark:hover:text-white'
+ }`}
+ >
+ {range}
+
+ ))}
+
+
+ {activeRange === 'Custom' && (
+ <>
+
+ setCustomFrom(e.target.value)
+ }
+ className="h-9 text-sm border border-border rounded-md px-3 bg-card text-foreground outline-none focus:ring-2 focus:ring-brand"
+ />
+
+ to
+
+
+ setCustomTo(e.target.value)
+ }
+ className="h-9 text-sm border border-border rounded-md px-3 bg-card text-foreground outline-none focus:ring-2 focus:ring-brand"
+ />
+ >
+ )}
+
+ {canSwitch && (
+
+
+
+
+
+
+ All Facilities
+
+ {facilitiesResp?.data?.map((facility) => (
+
+ {facility.name}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Overview
+
+
+ Knowledge Center
+
+
+
+
+
+
+
+
+
);
diff --git a/frontend/src/pages/insights/OverviewTab.tsx b/frontend/src/pages/insights/OverviewTab.tsx
new file mode 100644
index 000000000..cb1e6889e
--- /dev/null
+++ b/frontend/src/pages/insights/OverviewTab.tsx
@@ -0,0 +1,398 @@
+import { useMemo, useState } from 'react';
+import useSWR from 'swr';
+import {
+ UsersIcon,
+ UserPlusIcon,
+ ArrowTrendingUpIcon,
+ ChartBarIcon,
+ ArrowPathIcon
+} from '@heroicons/react/24/outline';
+import { ArrowsUpDownIcon } from '@heroicons/react/24/solid';
+import {
+ DepartmentMetrics,
+ DailyLoginCount,
+ FacilityEngagement,
+ ServerResponseOne
+} from '@/types';
+import API from '@/api/api';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from '@/components/ui/table';
+import LoginTrendChart from '@/components/charts/LoginTrendChart';
+import { MetricCard } from './MetricCard';
+import { InsightsDateParams } from './insightsRange';
+
+interface OverviewTabProps {
+ dateParams: InsightsDateParams;
+ selectedFacility: string;
+ canSwitch: boolean;
+ rangeLabel: string;
+}
+
+type SortKey =
+ | 'facility_name'
+ | 'registered'
+ | 'active'
+ | 'logins'
+ | 'avgPerActive'
+ | 'activation';
+
+interface ComparisonRow extends FacilityEngagement {
+ avgPerActive: number;
+ activation: number;
+}
+
+function pct(part: number, whole: number): number {
+ return whole > 0 ? Math.round((part / whole) * 100) : 0;
+}
+
+function ratio(part: number, whole: number): number {
+ return whole > 0 ? Math.round((part / whole) * 10) / 10 : 0;
+}
+
+export default function OverviewTab({
+ dateParams,
+ selectedFacility,
+ canSwitch,
+ rangeLabel
+}: OverviewTabProps) {
+ const [sortKey, setSortKey] = useState
('activation');
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const query = `facility=${selectedFacility}&start_date=${dateParams.start_date}&end_date=${dateParams.end_date}`;
+
+ const {
+ data: metricsResp,
+ isLoading: metricsLoading,
+ mutate: mutateMetrics
+ } = useSWR>(
+ `/api/department-metrics?${query}`
+ );
+
+ const { data: trendResp, mutate: mutateTrend } = useSWR<
+ ServerResponseOne
+ >(`/api/department-metrics/login-trend?${query}`);
+
+ const { data: comparisonResp, mutate: mutateComparison } = useSWR<
+ ServerResponseOne
+ >(
+ canSwitch
+ ? `/api/department-metrics/facility-comparison?start_date=${dateParams.start_date}&end_date=${dateParams.end_date}`
+ : null
+ );
+
+ const rows = useMemo(() => {
+ const data = comparisonResp?.data ?? [];
+ const derived = data.map((row) => ({
+ ...row,
+ avgPerActive: ratio(row.logins, row.active),
+ activation: pct(row.active, row.registered)
+ }));
+ return derived.sort((a, b) => {
+ const dir = sortDir === 'desc' ? -1 : 1;
+ if (sortKey === 'facility_name') {
+ return a.facility_name.localeCompare(b.facility_name) * dir;
+ }
+ return (a[sortKey] - b[sortKey]) * dir;
+ });
+ }, [comparisonResp, sortKey, sortDir]);
+
+ const toggleSort = (key: SortKey) => {
+ if (sortKey === key) {
+ setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortKey(key);
+ setSortDir('desc');
+ }
+ };
+
+ const handleRefresh = async () => {
+ setIsRefreshing(true);
+ try {
+ await API.get(`department-metrics?${query}&reset=true`);
+ await Promise.all([
+ mutateMetrics(),
+ mutateTrend(),
+ mutateComparison()
+ ]);
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ if (metricsLoading) {
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ const metrics = metricsResp?.data.data;
+ if (!metrics) {
+ return (
+
+ No insights data available for this selection.
+
+ );
+ }
+
+ const avgPerActive = ratio(metrics.total_logins, metrics.active_users);
+ const lastUpdated = metricsResp?.data.last_cache
+ ? new Date(metricsResp.data.last_cache).toLocaleString()
+ : '';
+
+ return (
+
+
+
+
+
+ At a glance
+
+
+ {rangeLabel}
+
+
+
+
void handleRefresh()}
+ disabled={isRefreshing}
+ className="gap-1.5"
+ >
+
+ Refresh
+
+ {lastUpdated && (
+
+ Updated {lastUpdated}
+
+ )}
+
+
+
+
+ Total residents who have a platform
+ account. Staff accounts are not included in this
+ total, but are shown below.
+ >
+ }
+ />
+
+ Registered residents who{' '}
+ logged in at least once
+ in the range. % of Registered is active
+ residents ÷ registered residents
+ >
+ }
+ />
+
+ Residents whose account was first created {' '}
+ within the range. The +12% compares
+ against the prior window.
+ >
+ }
+ />
+
+ Every resident login event in the range
+ (5 logins by one person count as 5).{' '}
+ Staff logins excluded.
+ >
+ }
+ />
+
+ Total resident logins ÷ active residents
+ (18,210 ÷ 1,552 ). Divided by{' '}
+ active , not registered, so inactive
+ accounts don't dilute it.
+ >
+ }
+ />
+
+
+
+
+
Login Trend
+
+ 7-day smoothed average of daily logins across the selected
+ range
+
+
+
+
+ {canSwitch && (
+
+
+
+ Facility Comparison
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {rows.map((row) => (
+
+
+ {row.facility_name}
+
+
+ {row.registered.toLocaleString()}
+
+
+ {row.active.toLocaleString()}
+
+
+ {row.logins.toLocaleString()}
+
+
+ {row.avgPerActive}
+
+
+
+
+
+ {row.activation}%
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+interface SortableHeadProps {
+ label: string;
+ sortKey: SortKey;
+ activeKey: SortKey;
+ onSort: (key: SortKey) => void;
+ alignRight?: boolean;
+}
+
+function SortableHead({
+ label,
+ sortKey,
+ activeKey,
+ onSort,
+ alignRight
+}: SortableHeadProps) {
+ return (
+ onSort(sortKey)}
+ >
+
+
+ );
+}
diff --git a/frontend/src/pages/insights/insightsRange.ts b/frontend/src/pages/insights/insightsRange.ts
new file mode 100644
index 000000000..7cd28506a
--- /dev/null
+++ b/frontend/src/pages/insights/insightsRange.ts
@@ -0,0 +1,63 @@
+import { InsightsRangeKey } from '@/types';
+
+export interface InsightsDateParams {
+ start_date: string;
+ end_date: string;
+}
+
+export const RANGE_OPTIONS: InsightsRangeKey[] = [
+ '7D',
+ '30D',
+ '90D',
+ 'YTD',
+ 'Custom'
+];
+
+export const RANGE_LABELS: Record = {
+ '7D': 'last 7 days',
+ '30D': 'last 30 days',
+ '90D': 'last 90 days',
+ YTD: 'year to date',
+ Custom: 'custom range'
+};
+
+function formatDate(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+function daysAgo(end: Date, count: number): Date {
+ const start = new Date(end);
+ start.setDate(end.getDate() - count);
+ return start;
+}
+
+export function rangeToParams(
+ range: InsightsRangeKey,
+ customFrom: string,
+ customTo: string
+): InsightsDateParams {
+ if (range === 'Custom') {
+ return { start_date: customFrom, end_date: customTo };
+ }
+ const end = new Date();
+ let start: Date;
+ switch (range) {
+ case '7D':
+ start = daysAgo(end, 6);
+ break;
+ case 'YTD':
+ start = new Date(end.getFullYear(), 0, 1);
+ break;
+ case '90D':
+ start = daysAgo(end, 89);
+ break;
+ case '30D':
+ default:
+ start = daysAgo(end, 29);
+ break;
+ }
+ return { start_date: formatDate(start), end_date: formatDate(end) };
+}
diff --git a/frontend/src/routes/app-routes.tsx b/frontend/src/routes/app-routes.tsx
index 1a4338e28..b174cc00a 100644
--- a/frontend/src/routes/app-routes.tsx
+++ b/frontend/src/routes/app-routes.tsx
@@ -68,7 +68,7 @@ const adminRoutes = declareAuthenticatedRoutes(
path: 'operational-insights',
element: ,
errorElement: ,
- handle: { title: 'Operational Insights' }
+ handle: { title: 'Insights' }
},
{
path: 'residents',
diff --git a/frontend/src/types/insights.ts b/frontend/src/types/insights.ts
index 17d87e757..8fbb570ff 100644
--- a/frontend/src/types/insights.ts
+++ b/frontend/src/types/insights.ts
@@ -9,6 +9,7 @@ export interface DepartmentMetrics {
percent_active: number;
percent_inactive: number;
total_residents: number;
+ total_admins: number;
facility: string;
new_residents_added: number;
new_admins_added: number;
@@ -197,3 +198,18 @@ export enum FilterPastTime {
'Past 90 days' = '90',
'All time' = 'all'
}
+
+export interface DailyLoginCount {
+ date: string;
+ total_logins: number;
+}
+
+export interface FacilityEngagement {
+ facility_id: number;
+ facility_name: string;
+ registered: number;
+ active: number;
+ logins: number;
+}
+
+export type InsightsRangeKey = '7D' | '30D' | '90D' | 'YTD' | 'Custom';