diff --git a/backend/src/database/DB.go b/backend/src/database/DB.go index c81a378da..ce80ac4cb 100644 --- a/backend/src/database/DB.go +++ b/backend/src/database/DB.go @@ -104,6 +104,7 @@ func MigrateTesting(db *gorm.DB) { &models.Role{}, &models.User{}, &models.LoginMetrics{}, + &models.LoginActivity{}, &models.Facility{}, &models.ProviderPlatform{}, &models.ProviderUserMapping{}, diff --git a/backend/src/database/users.go b/backend/src/database/users.go index e111f1789..15d58b2f4 100644 --- a/backend/src/database/users.go +++ b/backend/src/database/users.go @@ -468,6 +468,19 @@ func (db *DB) GetTotalUsers(args *models.QueryContext, facilityId *uint) (int64, return totalResidents, nil } +func (db *DB) GetTotalAdmins(args *models.QueryContext, facilityId *uint) (int64, error) { + var totalAdmins int64 + staffRoles := []models.UserRole{models.FacilityAdmin, models.DepartmentAdmin} + tx := db.WithContext(args.Ctx).Model(&models.User{}).Where("role IN ?", staffRoles) + if facilityId != nil { + tx = tx.Where("facility_id = ?", *facilityId) + } + if err := tx.Count(&totalAdmins).Error; err != nil { + return 0, newGetRecordsDBError(err, "users") + } + return totalAdmins, nil +} + func (db *DB) GetLoginActivity(args *models.QueryContext, start, end *time.Time, facilityID *uint) ([]models.LoginActivity, error) { acitvity := make([]models.LoginActivity, 0, 3) sql := `SELECT time_interval, total_logins @@ -492,6 +505,106 @@ func (db *DB) GetLoginActivity(args *models.QueryContext, start, end *time.Time, return acitvity, nil } +func (db *DB) GetDailyLoginActivity(args *models.QueryContext, start, end *time.Time, facilityID *uint) ([]models.DailyLoginCount, error) { + activities := make([]models.LoginActivity, 0) + tx := db.WithContext(args.Ctx).Model(&models.LoginActivity{}).Select("time_interval, total_logins") + if facilityID != nil { + tx = tx.Where("facility_id = ?", *facilityID) + } + if start != nil && end != nil { + tx = tx.Where("time_interval >= ? AND time_interval < ?", *start, *end) + } + if err := tx.Find(&activities).Error; err != nil { + return nil, newGetRecordsDBError(err, "login_activity") + } + return bucketLoginsByDay(activities), nil +} + +func bucketLoginsByDay(activities []models.LoginActivity) []models.DailyLoginCount { + totals := make(map[string]int64) + days := make([]string, 0) + for _, activity := range activities { + day := activity.TimeInterval.UTC().Format(time.DateOnly) + if _, seen := totals[day]; !seen { + days = append(days, day) + } + totals[day] += activity.TotalLogins + } + slices.Sort(days) + trend := make([]models.DailyLoginCount, 0, len(days)) + for _, day := range days { + trend = append(trend, models.DailyLoginCount{Date: day, TotalLogins: totals[day]}) + } + return trend +} + +func (db *DB) GetFacilityEngagementComparison(args *models.QueryContext, start, end *time.Time) ([]models.FacilityEngagement, error) { + facilities := make([]models.Facility, 0) + if err := db.WithContext(args.Ctx).Order("name ASC").Find(&facilities).Error; err != nil { + return nil, newGetRecordsDBError(err, "facilities") + } + + type facilityCount struct { + FacilityID uint + Count int64 + } + toMap := func(rows []facilityCount) map[uint]int64 { + m := make(map[uint]int64, len(rows)) + for _, row := range rows { + m[row.FacilityID] = row.Count + } + return m + } + + registered := make([]facilityCount, 0, len(facilities)) + if err := db.WithContext(args.Ctx).Model(&models.User{}). + Select("facility_id, count(*) as count"). + Where("role = ?", models.Student). + Group("facility_id"). + Find(®istered).Error; err != nil { + return nil, newGetRecordsDBError(err, "users") + } + + active := make([]facilityCount, 0, len(facilities)) + activeTx := db.WithContext(args.Ctx).Model(&models.User{}). + Select("users.facility_id, count(*) as count"). + Joins("JOIN login_metrics ON login_metrics.user_id = users.id"). + Where("users.role = ?", models.Student) + if start != nil && end != nil { + activeTx = activeTx.Where("login_metrics.last_login >= ? AND login_metrics.last_login < ?", *start, *end) + } + if err := activeTx.Group("users.facility_id").Find(&active).Error; err != nil { + return nil, newGetRecordsDBError(err, "users") + } + + logins := make([]facilityCount, 0, len(facilities)) + loginsTx := db.WithContext(args.Ctx).Model(&models.LoginActivity{}). + Select("facility_id, coalesce(sum(total_logins), 0) as count") + if start != nil && end != nil { + loginsTx = loginsTx.Where("time_interval >= ? AND time_interval < ?", *start, *end) + } + if err := loginsTx.Group("facility_id").Find(&logins).Error; err != nil { + return nil, newGetRecordsDBError(err, "login_activity") + } + + registeredByFacility := toMap(registered) + activeByFacility := toMap(active) + loginsByFacility := toMap(logins) + + comparison := make([]models.FacilityEngagement, 0, len(facilities)) + for i := range facilities { + facilityID := facilities[i].ID + comparison = append(comparison, models.FacilityEngagement{ + FacilityID: facilityID, + FacilityName: facilities[i].Name, + Registered: registeredByFacility[facilityID], + Active: activeByFacility[facilityID], + Logins: loginsByFacility[facilityID], + }) + } + return comparison, nil +} + func (db *DB) GetUserSessionEngagement(userID int, days int) ([]models.SessionEngagement, error) { var ( sessionEngagement []models.SessionEngagement diff --git a/backend/src/handlers/dashboard.go b/backend/src/handlers/dashboard.go index c63b1645e..7d573cf25 100644 --- a/backend/src/handlers/dashboard.go +++ b/backend/src/handlers/dashboard.go @@ -19,6 +19,8 @@ func (srv *Server) registerDashboardRoutes() []routeDef { resolver := UserRoleResolver("id") return []routeDef{ newAdminRoute("GET /api/department-metrics", srv.handleDepartmentMetrics), + newAdminRoute("GET /api/department-metrics/login-trend", srv.handleDepartmentLoginTrend), + newAdminRoute("GET /api/department-metrics/facility-comparison", srv.handleFacilityEngagementComparison), newAdminRoute("GET /api/dashboard/class-metrics", srv.handleClassDashboardMetrics), newAdminRoute("GET /api/dashboard/facility-health", srv.handleFacilityHealthSummary), newAdminRoute("GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2), @@ -220,6 +222,10 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques if err != nil { return newDatabaseServiceError(err) } + totalAdmins, err := srv.Db.GetTotalAdmins(&args, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } newResidentsAdded, err := srv.Db.NewUsersInTimePeriod(&args, start, end, facilityId) if err != nil { return newDatabaseServiceError(err) @@ -242,6 +248,7 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques PercentActive: percentActive, PercentInactive: percentInactive, TotalResidents: totalResidents, + TotalAdmins: totalAdmins, Facility: facilityName, NewResidentsAdded: newResidentsAdded, PeakLoginTimes: loginTimes, @@ -271,6 +278,52 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques return writeJsonResponse(w, http.StatusOK, cachedData) } +func (srv *Server) handleDepartmentLoginTrend(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + claims := r.Context().Value(ClaimsKey).(*Claims) + facility := r.URL.Query().Get("facility") + var facilityID *uint + switch { + case facility == "all" && claims.canSwitchFacility(): + facilityID = nil + case facility != "" && facility != "all" && claims.canSwitchFacility(): + facilityIdInt, err := strconv.Atoi(facility) + if err != nil { + return newInvalidIdServiceError(err, "facility") + } + ref := uint(facilityIdInt) + facilityID = &ref + default: + facilityID = &args.FacilityID + } + start, end, _, err := parseDateRangeRequest(r) + if err != nil { + return err + } + trend, err := srv.Db.GetDailyLoginActivity(&args, start, end, facilityID) + if err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, trend) +} + +func (srv *Server) handleFacilityEngagementComparison(w http.ResponseWriter, r *http.Request, log sLog) error { + claims := r.Context().Value(ClaimsKey).(*Claims) + if !claims.canSwitchFacility() { + return newUnauthorizedServiceError() + } + args := srv.getQueryContext(r) + start, end, _, err := parseDateRangeRequest(r) + if err != nil { + return err + } + comparison, err := srv.Db.GetFacilityEngagementComparison(&args, start, end) + if err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, comparison) +} + func (srv *Server) handleClassDashboardMetrics(w http.ResponseWriter, r *http.Request, log sLog) error { args := srv.getQueryContext(r) facility := r.URL.Query().Get("facility") diff --git a/backend/src/models/logins.go b/backend/src/models/logins.go index 2862433c7..2d8f2d550 100644 --- a/backend/src/models/logins.go +++ b/backend/src/models/logins.go @@ -22,6 +22,19 @@ type LoginActivity struct { func (LoginActivity) TableName() string { return "login_activity" } +type DailyLoginCount struct { + Date string `json:"date"` + TotalLogins int64 `json:"total_logins"` +} + +type FacilityEngagement struct { + FacilityID uint `json:"facility_id"` + FacilityName string `json:"facility_name"` + Registered int64 `json:"registered"` + Active int64 `json:"active"` + Logins int64 `json:"logins"` +} + type UserSessionTracking struct { ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` UserID uint `gorm:"not null" json:"user_id"` diff --git a/backend/tests/integration/department_metrics_test.go b/backend/tests/integration/department_metrics_test.go new file mode 100644 index 000000000..be8a12b0c --- /dev/null +++ b/backend/tests/integration/department_metrics_test.go @@ -0,0 +1,58 @@ +package integration + +import ( + "context" + "testing" + + "UnlockEdv2/src/models" + + "github.com/stretchr/testify/require" +) + +// TestGetTotalAdmins verifies the staff-account count that backs the +// "N staff accounts · not counted" sub-label on the Registered Residents card: +// only admin/staff roles are counted, residents are excluded, and the count is +// scoped to the requested facility. +func TestGetTotalAdmins(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + + facility, err := env.CreateTestFacility("Staff Count Facility") + require.NoError(t, err) + otherFacility, err := env.CreateTestFacility("Other Facility") + require.NoError(t, err) + + // Residents at the facility — must NOT be counted as staff. + _, err = env.CreateTestUser("res1", models.Student, facility.ID, "r1") + require.NoError(t, err) + _, err = env.CreateTestUser("res2", models.Student, facility.ID, "r2") + require.NoError(t, err) + + // Staff at the facility — counted. + _, err = env.CreateTestUser("fadmin1", models.FacilityAdmin, facility.ID, "") + require.NoError(t, err) + _, err = env.CreateTestUser("fadmin2", models.FacilityAdmin, facility.ID, "") + require.NoError(t, err) + _, err = env.CreateTestUser("dadmin", models.DepartmentAdmin, facility.ID, "") + require.NoError(t, err) + + // System admin at the facility — platform-wide operator, NOT facility staff, + // so it must be excluded from the count. + _, err = env.CreateTestUser("sysadmin", models.SystemAdmin, facility.ID, "") + require.NoError(t, err) + + // Staff at a different facility — must be excluded when scoped. + _, err = env.CreateTestUser("otheradmin", models.FacilityAdmin, otherFacility.ID, "") + require.NoError(t, err) + + args := &models.QueryContext{Ctx: context.Background()} + + scoped, err := env.DB.GetTotalAdmins(args, &facility.ID) + require.NoError(t, err) + require.Equal(t, int64(3), scoped, "should count only the 3 facility/department staff at the scoped facility, excluding residents, system admins, and other-facility staff") + + // Residents are still counted as residents, never as staff. + residents, err := env.DB.GetTotalUsers(args, &facility.ID) + require.NoError(t, err) + require.Equal(t, int64(2), residents, "resident total should be unaffected by staff accounts") +} diff --git a/backend/tests/integration/facility_comparison_test.go b/backend/tests/integration/facility_comparison_test.go new file mode 100644 index 000000000..506caa600 --- /dev/null +++ b/backend/tests/integration/facility_comparison_test.go @@ -0,0 +1,72 @@ +package integration + +import ( + "UnlockEdv2/src/handlers" + "UnlockEdv2/src/models" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFacilityEngagementComparison(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + + facilityA, err := env.CreateTestFacility("Comparison Facility A") + require.NoError(t, err) + facilityB, err := env.CreateTestFacility("Comparison Facility B") + require.NoError(t, err) + + inRange := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + + // Facility A: 2 registered students, 1 active (logged in during range), 10 logins + a1, err := env.CreateTestUser("aactive", models.Student, facilityA.ID, "") + require.NoError(t, err) + _, err = env.CreateTestUser("ainactive", models.Student, facilityA.ID, "") + require.NoError(t, err) + require.NoError(t, env.DB.Create(&models.LoginMetrics{UserID: a1.ID, Total: 4, LastLogin: inRange}).Error) + require.NoError(t, env.DB.Create(&models.LoginActivity{TimeInterval: inRange, FacilityID: facilityA.ID, TotalLogins: 10}).Error) + + // Facility B: 1 registered student, 1 active, 4 logins + b1, err := env.CreateTestUser("bactive", models.Student, facilityB.ID, "") + require.NoError(t, err) + require.NoError(t, env.DB.Create(&models.LoginMetrics{UserID: b1.ID, Total: 2, LastLogin: inRange}).Error) + require.NoError(t, env.DB.Create(&models.LoginActivity{TimeInterval: inRange, FacilityID: facilityB.ID, TotalLogins: 4}).Error) + + rows := NewRequest[[]models.FacilityEngagement](env.Client, t, http.MethodGet, + "/api/department-metrics/facility-comparison?start_date=2026-01-15&end_date=2026-01-16", nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facilityA.ID}). + Do(). + ExpectStatus(http.StatusOK). + GetData() + + byID := make(map[uint]models.FacilityEngagement, len(rows)) + for _, row := range rows { + byID[row.FacilityID] = row + } + + require.Equal(t, models.FacilityEngagement{ + FacilityID: facilityA.ID, FacilityName: "Comparison Facility A", + Registered: 2, Active: 1, Logins: 10, + }, byID[facilityA.ID]) + require.Equal(t, models.FacilityEngagement{ + FacilityID: facilityB.ID, FacilityName: "Comparison Facility B", + Registered: 1, Active: 1, Logins: 4, + }, byID[facilityB.ID]) +} + +func TestFacilityEngagementComparison_ForbiddenForFacilityAdmin(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + + facility, err := env.CreateTestFacility("Solo Facility") + require.NoError(t, err) + + NewRequest[[]models.FacilityEngagement](env.Client, t, http.MethodGet, + "/api/department-metrics/facility-comparison?start_date=2026-01-15&end_date=2026-01-16", nil). + WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}). + Do(). + ExpectStatus(http.StatusUnauthorized) +} diff --git a/backend/tests/integration/login_trend_test.go b/backend/tests/integration/login_trend_test.go new file mode 100644 index 000000000..08fd93e2b --- /dev/null +++ b/backend/tests/integration/login_trend_test.go @@ -0,0 +1,72 @@ +package integration + +import ( + "UnlockEdv2/src/handlers" + "UnlockEdv2/src/models" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDepartmentLoginTrend(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + + facility, err := env.CreateTestFacility("Trend Facility") + require.NoError(t, err) + + rows := []models.LoginActivity{ + {TimeInterval: time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC), FacilityID: facility.ID, TotalLogins: 5}, + {TimeInterval: time.Date(2026, 1, 15, 14, 0, 0, 0, time.UTC), FacilityID: facility.ID, TotalLogins: 7}, + {TimeInterval: time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC), FacilityID: facility.ID, TotalLogins: 3}, + } + for i := range rows { + require.NoError(t, env.DB.Create(&rows[i]).Error) + } + + endpoint := "/api/department-metrics/login-trend?facility=" + + strconv.Itoa(int(facility.ID)) + "&start_date=2026-01-15&end_date=2026-01-16" + + resp := NewRequest[[]models.DailyLoginCount](env.Client, t, http.MethodGet, endpoint, nil). + WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}). + Do(). + ExpectStatus(http.StatusOK) + + require.Equal(t, []models.DailyLoginCount{ + {Date: "2026-01-15", TotalLogins: 12}, + {Date: "2026-01-16", TotalLogins: 3}, + }, resp.GetData()) +} + +func TestDepartmentLoginTrend_AllFacilitiesAggregates(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + + facilityA, err := env.CreateTestFacility("Facility A") + require.NoError(t, err) + facilityB, err := env.CreateTestFacility("Facility B") + require.NoError(t, err) + + rows := []models.LoginActivity{ + {TimeInterval: time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC), FacilityID: facilityA.ID, TotalLogins: 5}, + {TimeInterval: time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC), FacilityID: facilityB.ID, TotalLogins: 4}, + {TimeInterval: time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC), FacilityID: facilityB.ID, TotalLogins: 3}, + } + for i := range rows { + require.NoError(t, env.DB.Create(&rows[i]).Error) + } + + resp := NewRequest[[]models.DailyLoginCount](env.Client, t, http.MethodGet, + "/api/department-metrics/login-trend?facility=all&start_date=2026-01-15&end_date=2026-01-16", nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facilityA.ID}). + Do(). + ExpectStatus(http.StatusOK) + + require.Equal(t, []models.DailyLoginCount{ + {Date: "2026-01-15", TotalLogins: 9}, + {Date: "2026-01-16", TotalLogins: 3}, + }, resp.GetData()) +} diff --git a/frontend/src/components/charts/LoginTrendChart.tsx b/frontend/src/components/charts/LoginTrendChart.tsx new file mode 100644 index 000000000..5e563fec2 --- /dev/null +++ b/frontend/src/components/charts/LoginTrendChart.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer +} from 'recharts'; +import { DailyLoginCount } from '@/types'; +import { BRAND, BRAND_DARK, SURFACE_HOVER } from '@/lib/brand'; + +interface LoginTrendChartProps { + data: DailyLoginCount[]; +} + +interface TrendPoint { + label: string; + smoothed: number; +} + +const SMOOTHING_WINDOW = 7; + +function formatLabel(isoDate: string): string { + const date = new Date(`${isoDate}T00:00:00`); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); +} + +function buildPoints(data: DailyLoginCount[]): TrendPoint[] { + return data.map((point, index) => { + const windowStart = Math.max(0, index - (SMOOTHING_WINDOW - 1)); + const window = data.slice(windowStart, index + 1); + const average = + window.reduce((sum, item) => sum + item.total_logins, 0) / + window.length; + return { + label: formatLabel(point.date), + smoothed: Math.round(average) + }; + }); +} + +export default function LoginTrendChart({ data }: LoginTrendChartProps) { + const points = useMemo(() => buildPoints(data), [data]); + const tickInterval = Math.max(0, Math.floor(points.length / 7)); + + return ( + + + + + + [ + value.toLocaleString(), + '7-day avg' + ]} + /> + + + + ); +} diff --git a/frontend/src/components/charts/OperationalInsightsCharts.tsx b/frontend/src/components/charts/OperationalInsightsCharts.tsx deleted file mode 100644 index a6c059371..000000000 --- a/frontend/src/components/charts/OperationalInsightsCharts.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { useEffect, useState } from 'react'; -import useSWR from 'swr'; -import { - DepartmentMetrics, - ServerResponseOne, - FilterPastTime, - Facility, - ServerResponseMany -} from '@/types'; -import { useAuth, canSwitchFacility } from '@/auth/useAuth'; -import { - Tooltip, - TooltipTrigger, - TooltipContent -} from '@/components/ui/tooltip'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - UsersIcon, - ArrowTrendingUpIcon, - ArrowTrendingDownIcon, - UserPlusIcon, - ArrowPathIcon -} from '@heroicons/react/24/outline'; -import EngagementRateGraph from './EngagementRateGraph'; - -interface StatsCardProps { - title: string; - value: string; - label: string; - tooltip: string; - icon: React.ReactNode; - iconBg: string; -} - -function StatsCard({ - title, - value, - label, - tooltip, - icon, - iconBg -}: StatsCardProps) { - return ( - - -
-
- {icon} -
-
-

{title}

-

- {value} -

-

- {label} -

-
-
-
- {tooltip} -
- ); -} - -export default function OperationalInsightsCharts() { - const [facility, setFacility] = useState('all'); - const [timeFilter, setTimeFilter] = useState( - FilterPastTime['Past 30 days'] - ); - const [resetCache, setResetCache] = useState(false); - const { user } = useAuth(); - - const { data, error, isLoading, mutate } = useSWR< - ServerResponseOne, - Error - >( - `/api/department-metrics?facility=${facility}&days=${timeFilter}&reset=${resetCache}` - ); - - const { data: facilitiesResp } = useSWR>( - user && canSwitchFacility(user) ? '/api/facilities' : null - ); - - useEffect(() => { - void mutate(); - }, [facility, timeFilter, resetCache, mutate]); - - useEffect(() => { - if (user && !canSwitchFacility(user)) { - setFacility(''); - } - }, [user]); - - if (isLoading) { - return ( -
-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- -
- ); - } - - if (error) { - return ( -
-

- Error loading operational insights data. -

-
- ); - } - - const metrics = data?.data; - if (!data || !metrics) return null; - - const formattedDate = new Date(metrics.last_cache).toLocaleString('en-US'); - const totalUsers = metrics.data.total_residents ?? 0; - const activePercent = - totalUsers > 0 - ? ((metrics.data.active_users / totalUsers) * 100).toFixed(1) - : '0'; - const inactiveUsers = totalUsers - metrics.data.active_users; - const timeLabel = - timeFilter === 'all' ? 'all time' : `the last ${timeFilter} days`; - - return ( -
-
-
-
- - -
- {user && canSwitchFacility(user) && ( -
- - -
- )} -
-
-

- Last updated: {formattedDate} -

- -
-
- -
- } - iconBg="bg-brand-dark" - /> - } - iconBg="bg-brand" - /> - - } - iconBg="bg-muted0" - /> - } - iconBg="bg-brand-gold" - /> - } - iconBg="bg-brand" - /> -
- -
-

- Peak Login Times -

-

- Residents only -

-
- -
-
-
- ); -} diff --git a/frontend/src/components/navigation/Sidebar.tsx b/frontend/src/components/navigation/Sidebar.tsx index 5fa80ab26..ef021839e 100644 --- a/frontend/src/components/navigation/Sidebar.tsx +++ b/frontend/src/components/navigation/Sidebar.tsx @@ -21,7 +21,7 @@ import { UsersIcon, UserGroupIcon, BookOpenIcon, - ChartBarIcon, + PresentationChartLineIcon, BuildingOfficeIcon, CalendarIcon, ChevronDownIcon, @@ -200,8 +200,8 @@ function AdminNav({ collapsed, isActive, onNavigate }: NavSectionProps) { )} } + title="Knowledge Center insights coming soon" + description="Library and video engagement metrics will appear here in an upcoming release." + /> + ); +} diff --git a/frontend/src/pages/insights/MetricCard.tsx b/frontend/src/pages/insights/MetricCard.tsx new file mode 100644 index 000000000..6a9776cd8 --- /dev/null +++ b/frontend/src/pages/insights/MetricCard.tsx @@ -0,0 +1,42 @@ +import { ElementType, ReactNode } from 'react'; +import { InfoTooltip } from '@/components/shared'; + +interface MetricCardProps { + icon: ElementType; + value: string; + label: string; + sub?: string; + tooltip?: ReactNode; +} + +export function MetricCard({ + icon: Icon, + value, + label, + sub, + tooltip +}: MetricCardProps) { + return ( +
+
+
+ +
+
+ {value} +
+ {tooltip && ( + {tooltip} + )} +
+
+ {label} +
+ {sub && ( +
+ {sub} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/insights/OperationalInsights.tsx b/frontend/src/pages/insights/OperationalInsights.tsx index b6d396b1a..e8c2fbe03 100644 --- a/frontend/src/pages/insights/OperationalInsights.tsx +++ b/frontend/src/pages/insights/OperationalInsights.tsx @@ -1,15 +1,169 @@ -import { PageHeader } from '@/components/shared'; -import OperationalInsightsCharts from '@/components/charts/OperationalInsightsCharts'; +import { useState } from 'react'; +import useSWR from 'swr'; +import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useAuth, canSwitchFacility } from '@/auth/useAuth'; +import { Facility, InsightsRangeKey, ServerResponseMany } from '@/types'; +import OverviewTab from './OverviewTab'; +import KnowledgeCenterTab from './KnowledgeCenterTab'; +import { RANGE_OPTIONS, RANGE_LABELS, rangeToParams } from './insightsRange'; + +const TAB_TRIGGER_CLASS = + 'flex-none h-auto px-4 py-2.5 rounded-lg transition-all duration-200 data-[state=active]:bg-brand data-[state=active]:text-white data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-brand-dark data-[state=inactive]:hover:bg-muted'; export default function OperationalInsightsPage() { + const { user } = useAuth(); + const canSwitch = user ? canSwitchFacility(user) : false; + + const [activeRange, setActiveRange] = useState('30D'); + const [customFrom, setCustomFrom] = useState(''); + const [customTo, setCustomTo] = useState(''); + const [selectedFacility, setSelectedFacility] = useState( + canSwitch ? 'all' : '' + ); + + const { data: facilitiesResp } = useSWR>( + canSwitch ? '/api/facilities' : null + ); + + const dateParams = rangeToParams(activeRange, customFrom, customTo); + + const facilityLabel = + selectedFacility === 'all' + ? 'All Facilities' + : (facilitiesResp?.data?.find( + (f) => String(f.id) === selectedFacility + )?.name ?? 'Selected facility'); + + const rangeLabel = `${RANGE_LABELS[activeRange]} · ${canSwitch ? facilityLabel : 'Your facility'}`; + return ( -
-
- - +
+
+
+
+
+

+ Insights +

+

+ Cross-facility activity, engagement & outcomes +

+
+ +
+
+ +
+
+
+ {RANGE_OPTIONS.map((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 && ( + + )} +
+
+ + + + + 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} +

+
+
+ + {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)} + > +
+ {label} + +
+
+ ); +} 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';