From ce924872e8e9b75c63462281fca4050e6ecbe2a7 Mon Sep 17 00:00:00 2001 From: carddev81 Date: Tue, 9 Jun 2026 15:12:03 -0500 Subject: [PATCH 1/2] feat: op insights knowledge center --- backend/src/database/DB.go | 2 + backend/src/database/knowledge_center.go | 196 ++++++++++++++ backend/src/handlers/dashboard.go | 72 +++++- backend/src/models/open_content.go | 28 ++ backend/src/models/tags.go | 8 + .../integration/knowledge_center_test.go | 106 ++++++++ .../src/pages/insights/KCContentTable.tsx | 76 ++++++ .../src/pages/insights/KnowledgeCenterTab.tsx | 242 +++++++++++++++++- .../pages/insights/OperationalInsights.tsx | 8 +- frontend/src/pages/insights/OverviewTab.tsx | 26 +- frontend/src/pages/insights/insightsRange.ts | 15 ++ frontend/src/styles/globals.css | 2 + frontend/src/types/insights.ts | 28 ++ 13 files changed, 787 insertions(+), 22 deletions(-) create mode 100644 backend/src/database/knowledge_center.go create mode 100644 backend/tests/integration/knowledge_center_test.go create mode 100644 frontend/src/pages/insights/KCContentTable.tsx diff --git a/backend/src/database/DB.go b/backend/src/database/DB.go index ce80ac4cb..866109eaa 100644 --- a/backend/src/database/DB.go +++ b/backend/src/database/DB.go @@ -139,6 +139,8 @@ func MigrateTesting(db *gorm.DB) { &models.UserCourseActivityTotals{}, &models.ProgramClassesHistory{}, &models.UserAccountHistory{}, + &models.Tag{}, + &models.OpenContentTag{}, } logrus.Println("Running up migrations...") for _, table := range TableList { diff --git a/backend/src/database/knowledge_center.go b/backend/src/database/knowledge_center.go new file mode 100644 index 000000000..6e0746923 --- /dev/null +++ b/backend/src/database/knowledge_center.go @@ -0,0 +1,196 @@ +package database + +import ( + "UnlockEdv2/src/models" + "math" + "time" + + "gorm.io/gorm" +) + +func kcPriorWindow(start, end *time.Time) (*time.Time, *time.Time) { + if start == nil || end == nil { + return nil, nil + } + duration := end.Sub(*start) + priorStart := start.Add(-duration) + priorEnd := *start + return &priorStart, &priorEnd +} + +func kcPctChange(current, prior int64) int { + if prior <= 0 { + return 0 + } + return int(math.Round(float64(current-prior) / float64(prior) * 100)) +} + +func (db *DB) kcActivityScope(args *models.QueryContext, start, end *time.Time, facilityID *uint) *gorm.DB { + tx := db.WithContext(args.Ctx).Model(&models.OpenContentActivity{}) + if facilityID != nil { + tx = tx.Where("facility_id = ?", *facilityID) + } + if start != nil && end != nil { + tx = tx.Where("request_ts >= ? AND request_ts < ?", *start, *end) + } + return tx +} + +func (db *DB) GetKCInteractionStats(args *models.QueryContext, start, end *time.Time, facilityID *uint) (int64, int64, int, error) { + var total int64 + if err := db.kcActivityScope(args, start, end, facilityID).Count(&total).Error; err != nil { + return 0, 0, 0, newGetRecordsDBError(err, "open_content_activities") + } + var unique int64 + if err := db.kcActivityScope(args, start, end, facilityID). + Distinct("user_id").Count(&unique).Error; err != nil { + return 0, 0, 0, newGetRecordsDBError(err, "open_content_activities") + } + change := 0 + if priorStart, priorEnd := kcPriorWindow(start, end); priorStart != nil { + var priorTotal int64 + if err := db.kcActivityScope(args, priorStart, priorEnd, facilityID).Count(&priorTotal).Error; err != nil { + return 0, 0, 0, newGetRecordsDBError(err, "open_content_activities") + } + change = kcPctChange(total, priorTotal) + } + return total, unique, change, nil +} + +func (db *DB) GetKCAvgSessionMinutes(args *models.QueryContext, start, end *time.Time, facilityID *uint) (float64, error) { + type sessionRow struct { + RequestTS time.Time + StopTS time.Time + } + rows := make([]sessionRow, 0) + if err := db.kcActivityScope(args, start, end, facilityID). + Select("request_ts, stop_ts"). + Where("stop_ts IS NOT NULL"). + Find(&rows).Error; err != nil { + return 0, newGetRecordsDBError(err, "open_content_activities") + } + var totalMinutes float64 + var counted int + for _, row := range rows { + if row.StopTS.After(row.RequestTS) { + totalMinutes += row.StopTS.Sub(row.RequestTS).Minutes() + counted++ + } + } + if counted == 0 { + return 0, nil + } + return totalMinutes / float64(counted), nil +} + +func (db *DB) GetKCRepeatEngagement(args *models.QueryContext, start, end *time.Time, facilityID *uint) (models.RepeatEngagement, error) { + type userCount struct { + UserID uint + Cnt int64 + } + counts := make([]userCount, 0) + if err := db.kcActivityScope(args, start, end, facilityID). + Select("user_id, count(*) as cnt"). + Group("user_id"). + Find(&counts).Error; err != nil { + return models.RepeatEngagement{}, newGetRecordsDBError(err, "open_content_activities") + } + var engagement models.RepeatEngagement + for _, row := range counts { + switch { + case row.Cnt >= 5: + engagement.FivePlus++ + case row.Cnt >= 2: + engagement.TwoToFour++ + default: + engagement.Once++ + } + } + return engagement, nil +} + +func (db *DB) GetKCLibraryViewsByCategory(args *models.QueryContext, start, end *time.Time, facilityID *uint) ([]models.CategoryViews, error) { + views := make([]models.CategoryViews, 0) + tx := db.WithContext(args.Ctx).Table("open_content_activities oca"). + Select("t.name as category, count(oca.id) as views"). + Joins("JOIN open_content_tags oct ON oct.content_id = oca.content_id AND oct.open_content_provider_id = oca.open_content_provider_id"). + Joins("JOIN tags t ON t.id = oct.tag_id") + if facilityID != nil { + tx = tx.Where("oca.facility_id = ?", *facilityID) + } + if start != nil && end != nil { + tx = tx.Where("oca.request_ts >= ? AND oca.request_ts < ?", *start, *end) + } + if err := tx.Group("t.name").Order("views DESC, t.name ASC").Scan(&views).Error; err != nil { + return nil, newGetRecordsDBError(err, "open_content_tags") + } + return views, nil +} + +func (db *DB) getKCTopContent(args *models.QueryContext, table, alias string, start, end *time.Time, facilityID *uint, limit int) ([]models.KCContentRow, error) { + type contentRow struct { + ContentID uint + Title string + Visits int64 + } + current := make([]contentRow, 0, limit) + tx := db.WithContext(args.Ctx).Table("open_content_activities oca"). + Select(alias + ".id as content_id, " + alias + ".title as title, count(oca.id) as visits"). + Joins("JOIN " + table + " " + alias + " ON " + alias + ".id = oca.content_id AND " + alias + ".open_content_provider_id = oca.open_content_provider_id AND " + alias + ".deleted_at IS NULL") + if facilityID != nil { + tx = tx.Where("oca.facility_id = ?", *facilityID) + } + if start != nil && end != nil { + tx = tx.Where("oca.request_ts >= ? AND oca.request_ts < ?", *start, *end) + } + if err := tx.Group(alias + ".id, " + alias + ".title"). + Order("visits DESC, " + alias + ".title ASC"). + Limit(limit). + Scan(¤t).Error; err != nil { + return nil, newGetRecordsDBError(err, table) + } + rows := make([]models.KCContentRow, 0, len(current)) + if len(current) == 0 { + return rows, nil + } + + priorVisits := make(map[uint]int64) + if priorStart, priorEnd := kcPriorWindow(start, end); priorStart != nil { + ids := make([]uint, 0, len(current)) + for _, c := range current { + ids = append(ids, c.ContentID) + } + priors := make([]contentRow, 0, len(ids)) + ptx := db.WithContext(args.Ctx).Table("open_content_activities oca"). + Select(alias+".id as content_id, count(oca.id) as visits"). + Joins("JOIN "+table+" "+alias+" ON "+alias+".id = oca.content_id AND "+alias+".open_content_provider_id = oca.open_content_provider_id AND "+alias+".deleted_at IS NULL"). + Where(alias+".id IN ?", ids). + Where("oca.request_ts >= ? AND oca.request_ts < ?", *priorStart, *priorEnd) + if facilityID != nil { + ptx = ptx.Where("oca.facility_id = ?", *facilityID) + } + if err := ptx.Group(alias + ".id").Scan(&priors).Error; err != nil { + return nil, newGetRecordsDBError(err, table) + } + for _, p := range priors { + priorVisits[p.ContentID] = p.Visits + } + } + + for _, c := range current { + rows = append(rows, models.KCContentRow{ + Title: c.Title, + Visits: c.Visits, + Change: kcPctChange(c.Visits, priorVisits[c.ContentID]), + }) + } + return rows, nil +} + +func (db *DB) GetKCTopLibraries(args *models.QueryContext, start, end *time.Time, facilityID *uint, limit int) ([]models.KCContentRow, error) { + return db.getKCTopContent(args, "libraries", "l", start, end, facilityID, limit) +} + +func (db *DB) GetKCTopVideos(args *models.QueryContext, start, end *time.Time, facilityID *uint, limit int) ([]models.KCContentRow, error) { + return db.getKCTopContent(args, "videos", "v", start, end, facilityID, limit) +} diff --git a/backend/src/handlers/dashboard.go b/backend/src/handlers/dashboard.go index 7d573cf25..8cf1f1771 100644 --- a/backend/src/handlers/dashboard.go +++ b/backend/src/handlers/dashboard.go @@ -21,6 +21,7 @@ func (srv *Server) registerDashboardRoutes() []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/department-metrics/knowledge-center", srv.handleKnowledgeCenterMetrics), 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), @@ -278,23 +279,28 @@ 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 +func resolveFacilityFilter(claims *Claims, facility string, ownFacilityID uint) (*uint, error) { switch { case facility == "all" && claims.canSwitchFacility(): - facilityID = nil + return nil, nil case facility != "" && facility != "all" && claims.canSwitchFacility(): facilityIdInt, err := strconv.Atoi(facility) if err != nil { - return newInvalidIdServiceError(err, "facility") + return nil, newInvalidIdServiceError(err, "facility") } ref := uint(facilityIdInt) - facilityID = &ref + return &ref, nil default: - facilityID = &args.FacilityID + return &ownFacilityID, nil + } +} + +func (srv *Server) handleDepartmentLoginTrend(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + claims := r.Context().Value(ClaimsKey).(*Claims) + facilityID, err := resolveFacilityFilter(claims, r.URL.Query().Get("facility"), args.FacilityID) + if err != nil { + return err } start, end, _, err := parseDateRangeRequest(r) if err != nil { @@ -307,6 +313,54 @@ func (srv *Server) handleDepartmentLoginTrend(w http.ResponseWriter, r *http.Req return writeJsonResponse(w, http.StatusOK, trend) } +func (srv *Server) handleKnowledgeCenterMetrics(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + claims := r.Context().Value(ClaimsKey).(*Claims) + facilityID, err := resolveFacilityFilter(claims, r.URL.Query().Get("facility"), args.FacilityID) + if err != nil { + return err + } + start, end, _, err := parseDateRangeRequest(r) + if err != nil { + return err + } + total, unique, totalChange, err := srv.Db.GetKCInteractionStats(&args, start, end, facilityID) + if err != nil { + return newDatabaseServiceError(err) + } + avgSession, err := srv.Db.GetKCAvgSessionMinutes(&args, start, end, facilityID) + if err != nil { + return newDatabaseServiceError(err) + } + repeat, err := srv.Db.GetKCRepeatEngagement(&args, start, end, facilityID) + if err != nil { + return newDatabaseServiceError(err) + } + categories, err := srv.Db.GetKCLibraryViewsByCategory(&args, start, end, facilityID) + if err != nil { + return newDatabaseServiceError(err) + } + topLibraries, err := srv.Db.GetKCTopLibraries(&args, start, end, facilityID, 8) + if err != nil { + return newDatabaseServiceError(err) + } + topVideos, err := srv.Db.GetKCTopVideos(&args, start, end, facilityID, 8) + if err != nil { + return newDatabaseServiceError(err) + } + metrics := models.KnowledgeCenterMetrics{ + TotalInteractions: total, + TotalInteractionsChange: totalChange, + UniqueResidents: unique, + AvgSessionMinutes: avgSession, + RepeatEngagement: repeat, + LibraryViewsByCategory: categories, + TopLibraries: topLibraries, + TopVideos: topVideos, + } + return writeJsonResponse(w, http.StatusOK, metrics) +} + func (srv *Server) handleFacilityEngagementComparison(w http.ResponseWriter, r *http.Request, log sLog) error { claims := r.Context().Value(ClaimsKey).(*Claims) if !claims.canSwitchFacility() { diff --git a/backend/src/models/open_content.go b/backend/src/models/open_content.go index e66bbaa6c..d6b7ac74b 100644 --- a/backend/src/models/open_content.go +++ b/backend/src/models/open_content.go @@ -56,6 +56,34 @@ type OpenContentParams struct { func (OpenContentActivity) TableName() string { return "open_content_activities" } +type KnowledgeCenterMetrics struct { + TotalInteractions int64 `json:"total_interactions"` + TotalInteractionsChange int `json:"total_interactions_change"` + UniqueResidents int64 `json:"unique_residents"` + AvgSessionMinutes float64 `json:"avg_session_minutes"` + RepeatEngagement RepeatEngagement `json:"repeat_engagement"` + LibraryViewsByCategory []CategoryViews `json:"library_views_by_category"` + TopLibraries []KCContentRow `json:"top_libraries"` + TopVideos []KCContentRow `json:"top_videos"` +} + +type RepeatEngagement struct { + Once int64 `json:"once"` + TwoToFour int64 `json:"two_to_four"` + FivePlus int64 `json:"five_plus"` +} + +type CategoryViews struct { + Category string `json:"category"` + Views int64 `json:"views"` +} + +type KCContentRow struct { + Title string `json:"title"` + Visits int64 `json:"visits"` + Change int `json:"change"` +} + type OpenContentUrl struct { ID uint `gorm:"primaryKey" json:"-"` ContentURL string `gorm:"size:255" json:"content_url"` diff --git a/backend/src/models/tags.go b/backend/src/models/tags.go index d4f3ab4df..397ebb91c 100644 --- a/backend/src/models/tags.go +++ b/backend/src/models/tags.go @@ -6,3 +6,11 @@ type Tag struct { } func (Tag) TableName() string { return "tags" } + +type OpenContentTag struct { + TagID uint `gorm:"primaryKey" json:"tag_id"` + ContentID uint `gorm:"primaryKey" json:"content_id"` + OpenContentProviderID uint `gorm:"primaryKey" json:"open_content_provider_id"` +} + +func (OpenContentTag) TableName() string { return "open_content_tags" } diff --git a/backend/tests/integration/knowledge_center_test.go b/backend/tests/integration/knowledge_center_test.go new file mode 100644 index 000000000..b3f6eb43e --- /dev/null +++ b/backend/tests/integration/knowledge_center_test.go @@ -0,0 +1,106 @@ +package integration + +import ( + "UnlockEdv2/src/handlers" + "UnlockEdv2/src/models" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestKnowledgeCenterMetrics(t *testing.T) { + env := SetupTestEnv(t) + defer env.CleanupTestEnv() + + facility, err := env.CreateTestFacility("KC Facility") + require.NoError(t, err) + + kiwix := &models.OpenContentProvider{Title: "Kiwix", Url: "http://kiwix"} + require.NoError(t, env.DB.Create(kiwix).Error) + youtube := &models.OpenContentProvider{Title: "YouTube", Url: "http://youtube"} + require.NoError(t, env.DB.Create(youtube).Error) + + careerLib := &models.Library{OpenContentProviderID: kiwix.ID, Title: "Career Library", Url: "/career"} + require.NoError(t, env.DB.Create(careerLib).Error) + recoveryLib := &models.Library{OpenContentProviderID: kiwix.ID, Title: "Recovery Library", Url: "/recovery"} + require.NoError(t, env.DB.Create(recoveryLib).Error) + weldingVideo := &models.Video{OpenContentProviderID: youtube.ID, Title: "Welding Basics", Url: "/welding", ExternalID: "vid1"} + require.NoError(t, env.DB.Create(weldingVideo).Error) + + vocational := &models.Tag{Name: "Vocational"} + require.NoError(t, env.DB.Create(vocational).Error) + lifeSkills := &models.Tag{Name: "Life Skills"} + require.NoError(t, env.DB.Create(lifeSkills).Error) + require.NoError(t, env.DB.Create(&models.OpenContentTag{ + TagID: vocational.ID, ContentID: careerLib.ID, OpenContentProviderID: kiwix.ID, + }).Error) + require.NoError(t, env.DB.Create(&models.OpenContentTag{ + TagID: lifeSkills.ID, ContentID: recoveryLib.ID, OpenContentProviderID: kiwix.ID, + }).Error) + + u1, err := env.CreateTestUser("userone", models.Student, facility.ID, "") + require.NoError(t, err) + u2, err := env.CreateTestUser("usertwo", models.Student, facility.ID, "") + require.NoError(t, err) + u3, err := env.CreateTestUser("userthree", models.Student, facility.ID, "") + require.NoError(t, err) + + day := func(hour, min int) time.Time { + return time.Date(2026, 1, 15, hour, min, 0, 0, time.UTC) + } + // prior window for [01-15, 01-17) is [01-13, 01-15); seed on 01-14 + priorDay := func(hour, min int) time.Time { + return time.Date(2026, 1, 14, hour, min, 0, 0, time.UTC) + } + activities := []models.OpenContentActivity{ + // U1: career library twice + one video (3 interactions) + {FacilityID: facility.ID, UserID: u1.ID, OpenContentProviderID: kiwix.ID, ContentID: careerLib.ID, OpenContentUrlID: 1, RequestTS: day(9, 0), StopTS: day(9, 10)}, + {FacilityID: facility.ID, UserID: u1.ID, OpenContentProviderID: kiwix.ID, ContentID: careerLib.ID, OpenContentUrlID: 1, RequestTS: day(14, 0), StopTS: day(14, 5)}, + {FacilityID: facility.ID, UserID: u1.ID, OpenContentProviderID: youtube.ID, ContentID: weldingVideo.ID, OpenContentUrlID: 1, RequestTS: day(16, 0), StopTS: day(16, 10)}, + // U2: recovery library once + {FacilityID: facility.ID, UserID: u2.ID, OpenContentProviderID: kiwix.ID, ContentID: recoveryLib.ID, OpenContentUrlID: 1, RequestTS: day(10, 0), StopTS: day(10, 20)}, + // U3: video once + {FacilityID: facility.ID, UserID: u3.ID, OpenContentProviderID: youtube.ID, ContentID: weldingVideo.ID, OpenContentUrlID: 1, RequestTS: day(11, 0), StopTS: day(11, 30)}, + // PRIOR window (01-14): career x1, video x3 -> prior total 4 (recovery has none) + {FacilityID: facility.ID, UserID: u1.ID, OpenContentProviderID: kiwix.ID, ContentID: careerLib.ID, OpenContentUrlID: 1, RequestTS: priorDay(9, 0), StopTS: priorDay(9, 5)}, + {FacilityID: facility.ID, UserID: u1.ID, OpenContentProviderID: youtube.ID, ContentID: weldingVideo.ID, OpenContentUrlID: 1, RequestTS: priorDay(9, 0), StopTS: priorDay(9, 5)}, + {FacilityID: facility.ID, UserID: u2.ID, OpenContentProviderID: youtube.ID, ContentID: weldingVideo.ID, OpenContentUrlID: 1, RequestTS: priorDay(10, 0), StopTS: priorDay(10, 5)}, + {FacilityID: facility.ID, UserID: u3.ID, OpenContentProviderID: youtube.ID, ContentID: weldingVideo.ID, OpenContentUrlID: 1, RequestTS: priorDay(11, 0), StopTS: priorDay(11, 5)}, + } + for i := range activities { + require.NoError(t, env.DB.Create(&activities[i]).Error) + } + + metrics := NewRequest[models.KnowledgeCenterMetrics](env.Client, t, http.MethodGet, + "/api/department-metrics/knowledge-center?facility="+strconv.Itoa(int(facility.ID))+ + "&start_date=2026-01-15&end_date=2026-01-16", nil). + WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}). + Do(). + ExpectStatus(http.StatusOK). + GetData() + + require.Equal(t, int64(5), metrics.TotalInteractions) + // prior window total = 4 -> change = (5-4)/4 = 25% + require.Equal(t, 25, metrics.TotalInteractionsChange) + require.Equal(t, int64(3), metrics.UniqueResidents) + // durations (min): 10, 5, 10, 20, 30 -> avg 15 + require.InDelta(t, 15.0, metrics.AvgSessionMinutes, 0.01) + // interaction counts per resident: u1=3 (2-4), u2=1 (once), u3=1 (once) + require.Equal(t, models.RepeatEngagement{Once: 2, TwoToFour: 1, FivePlus: 0}, metrics.RepeatEngagement) + require.Equal(t, []models.CategoryViews{ + {Category: "Vocational", Views: 2}, + {Category: "Life Skills", Views: 1}, + }, metrics.LibraryViewsByCategory) + // career prior 1 -> +100%; recovery prior 0 -> 0% + require.Equal(t, []models.KCContentRow{ + {Title: "Career Library", Visits: 2, Change: 100}, + {Title: "Recovery Library", Visits: 1, Change: 0}, + }, metrics.TopLibraries) + // welding prior 3 -> (2-3)/3 = -33% + require.Equal(t, []models.KCContentRow{ + {Title: "Welding Basics", Visits: 2, Change: -33}, + }, metrics.TopVideos) +} diff --git a/frontend/src/pages/insights/KCContentTable.tsx b/frontend/src/pages/insights/KCContentTable.tsx new file mode 100644 index 000000000..09eb47b80 --- /dev/null +++ b/frontend/src/pages/insights/KCContentTable.tsx @@ -0,0 +1,76 @@ +import { KCContentRow } from '@/types'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; + +interface KCContentTableProps { + title: string; + nameLabel: string; + valueLabel: string; + rows: KCContentRow[]; +} + +export function KCContentTable({ + title, + nameLabel, + valueLabel, + rows +}: KCContentTableProps) { + return ( +
+
+

+ {title} +

+ + {valueLabel} + +
+ + + + {nameLabel} + + {valueLabel} + + Δ + + + + {rows.length === 0 ? ( + + + No activity in this range + + + ) : ( + rows.map((row) => ( + + + {row.title} + + + {row.visits.toLocaleString()} + + = 0 ? 'text-brand' : 'text-red-500'}`} + > + {row.change >= 0 ? '+' : ''} + {row.change}% + + + )) + )} + +
+
+ ); +} diff --git a/frontend/src/pages/insights/KnowledgeCenterTab.tsx b/frontend/src/pages/insights/KnowledgeCenterTab.tsx index 9bddfa405..dfb7c6a23 100644 --- a/frontend/src/pages/insights/KnowledgeCenterTab.tsx +++ b/frontend/src/pages/insights/KnowledgeCenterTab.tsx @@ -1,12 +1,238 @@ -import { BookOpenIcon } from '@heroicons/react/24/outline'; -import { EmptyState } from '@/components/shared'; +import useSWR from 'swr'; +import { + BookOpenIcon, + UsersIcon, + ClockIcon, + ArrowPathIcon +} from '@heroicons/react/24/outline'; +import { KnowledgeCenterMetrics, ServerResponseOne } from '@/types'; +import { Skeleton } from '@/components/ui/skeleton'; +import { InfoTooltip } from '@/components/shared'; +import { MetricCard } from './MetricCard'; +import { KCContentTable } from './KCContentTable'; +import { InsightsDateParams } from './insightsRange'; + +interface KnowledgeCenterTabProps { + dateParams: InsightsDateParams; + selectedFacility: string; + rangeLabel: string; +} + +function pct(part: number, whole: number): number { + return whole > 0 ? Math.round((part / whole) * 100) : 0; +} + +function deltaLabel(change: number): string { + return `${change >= 0 ? '+' : ''}${change}% vs prior period`; +} + +function formatSessionLength(minutes: number): string { + const totalSeconds = Math.round(minutes * 60); + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return `${mins}m ${secs}s`; +} + +export default function KnowledgeCenterTab({ + dateParams, + selectedFacility, + rangeLabel +}: KnowledgeCenterTabProps) { + const query = `facility=${selectedFacility}&start_date=${dateParams.start_date}&end_date=${dateParams.end_date}`; + const { data, isLoading } = useSWR< + ServerResponseOne + >(`/api/department-metrics/knowledge-center?${query}`); + + if (isLoading) { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ + +
+ ); + } + + const metrics = data?.data; + if (!metrics) { + return ( +
+ No Knowledge Center data available for this selection. +
+ ); + } + + const { once, two_to_four, five_plus } = metrics.repeat_engagement; + const repeatTotal = once + two_to_four + five_plus; + const maxCategoryViews = Math.max( + 1, + ...metrics.library_views_by_category.map((c) => c.views) + ); + + const segments = [ + { label: 'Once', count: once, className: 'bg-brand-dark' }, + { label: '2-4 visits', count: two_to_four, className: 'bg-brand' }, + { label: '5+ visits', count: five_plus, className: 'bg-brand-light' } + ]; -export default function KnowledgeCenterTab() { return ( - } - title="Knowledge Center insights coming soon" - description="Library and video engagement metrics will appear here in an upcoming release." - /> +
+
+

+ At a glance +

+

+ {rangeLabel} +

+
+ + + + +
+
+ +
+
+

+ Repeat Engagement +

+ + Unique users by visit count + +
+

+ How many times did residents return to the Knowledge Center? +

+ {repeatTotal === 0 ? ( +

+ No Knowledge Center visits in this range. +

+ ) : ( + <> +
+ {segments.map( + (segment) => + segment.count > 0 && ( +
+ {segment.label} ·{' '} + {pct(segment.count, repeatTotal)}% +
+ ) + )} +
+
+ {segments.map((segment) => ( + + {segment.label} ( + {segment.count.toLocaleString()} users) + + ))} +
+ + )} +
+ +
+
+

+ Library Views by Category +

+ + Total views · libraries only + +
+
+ + Total library views across categories for the selected + range. Video content is a separate resource type and is + not counted here. + +

+ Library views grouped by category tag. +

+
+ {metrics.library_views_by_category.length === 0 ? ( +

+ No categorized library views in this range. +

+ ) : ( +
+ {metrics.library_views_by_category.map( + (category, i) => ( +
+ + {category.category} + +
+
+
+ + {category.views.toLocaleString()} + +
+ ) + )} +
+ )} +
+ +
+ + +
+
); } diff --git a/frontend/src/pages/insights/OperationalInsights.tsx b/frontend/src/pages/insights/OperationalInsights.tsx index e8c2fbe03..c09a546c5 100644 --- a/frontend/src/pages/insights/OperationalInsights.tsx +++ b/frontend/src/pages/insights/OperationalInsights.tsx @@ -138,7 +138,7 @@ export default function OperationalInsightsPage() {
- + - + diff --git a/frontend/src/pages/insights/OverviewTab.tsx b/frontend/src/pages/insights/OverviewTab.tsx index cb1e6889e..1673a4743 100644 --- a/frontend/src/pages/insights/OverviewTab.tsx +++ b/frontend/src/pages/insights/OverviewTab.tsx @@ -27,7 +27,7 @@ import { } from '@/components/ui/table'; import LoginTrendChart from '@/components/charts/LoginTrendChart'; import { MetricCard } from './MetricCard'; -import { InsightsDateParams } from './insightsRange'; +import { InsightsDateParams, priorParams } from './insightsRange'; interface OverviewTabProps { dateParams: InsightsDateParams; @@ -57,6 +57,14 @@ function ratio(part: number, whole: number): number { return whole > 0 ? Math.round((part / whole) * 10) / 10 : 0; } +function changePct(current: number, prior: number): number { + return prior > 0 ? Math.round(((current - prior) / prior) * 100) : 0; +} + +function deltaLabel(change: number): string { + return `${change >= 0 ? '+' : ''}${change}% vs prior period`; +} + export default function OverviewTab({ dateParams, selectedFacility, @@ -81,6 +89,13 @@ export default function OverviewTab({ ServerResponseOne >(`/api/department-metrics/login-trend?${query}`); + const prior = priorParams(dateParams); + const { data: priorMetricsResp, mutate: mutatePriorMetrics } = useSWR< + ServerResponseOne + >( + `/api/department-metrics?facility=${selectedFacility}&start_date=${prior.start_date}&end_date=${prior.end_date}` + ); + const { data: comparisonResp, mutate: mutateComparison } = useSWR< ServerResponseOne >( @@ -121,7 +136,8 @@ export default function OverviewTab({ await Promise.all([ mutateMetrics(), mutateTrend(), - mutateComparison() + mutateComparison(), + mutatePriorMetrics() ]); } finally { setIsRefreshing(false); @@ -155,6 +171,10 @@ export default function OverviewTab({ ? new Date(metricsResp.data.last_cache).toLocaleString() : ''; + const newUsersChange = changePct( + metrics.new_residents_added, + priorMetricsResp?.data.data.new_residents_added ?? 0 + ); return (
@@ -221,7 +241,7 @@ export default function OverviewTab({ icon={UserPlusIcon} value={metrics.new_residents_added.toLocaleString()} label="New Residents Added" - sub="in selected range" + sub={deltaLabel(newUsersChange)} tooltip={ <> Residents whose account was first created{' '} diff --git a/frontend/src/pages/insights/insightsRange.ts b/frontend/src/pages/insights/insightsRange.ts index 7cd28506a..2b37995e7 100644 --- a/frontend/src/pages/insights/insightsRange.ts +++ b/frontend/src/pages/insights/insightsRange.ts @@ -34,6 +34,21 @@ function daysAgo(end: Date, count: number): Date { return start; } +export function priorParams(params: InsightsDateParams): InsightsDateParams { + const start = new Date(`${params.start_date}T00:00:00`); + const end = new Date(`${params.end_date}T00:00:00`); + const durationDays = + Math.round((end.getTime() - start.getTime()) / 86400000) + 1; + const priorEnd = new Date(start); + priorEnd.setDate(start.getDate() - 1); + const priorStart = new Date(start); + priorStart.setDate(start.getDate() - durationDays); + return { + start_date: formatDate(priorStart), + end_date: formatDate(priorEnd) + }; +} + export function rangeToParams( range: InsightsRangeKey, customFrom: string, diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 986233d92..c490d2e97 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -41,6 +41,7 @@ --sidebar-ring: #b3b3b3; --brand: #556830; --brand-dark: #203622; + --brand-light: #8fb55e; --brand-gold: #f1b51c; --brand-gold-dark: #d9a419; --surface-hover: #e2e7ea; @@ -121,6 +122,7 @@ --color-sidebar-ring: var(--sidebar-ring); --color-brand: var(--brand); --color-brand-dark: var(--brand-dark); + --color-brand-light: var(--brand-light); --color-brand-gold: var(--brand-gold); --color-brand-gold-dark: var(--brand-gold-dark); --color-surface-hover: var(--surface-hover); diff --git a/frontend/src/types/insights.ts b/frontend/src/types/insights.ts index 8fbb570ff..9ef99bcdb 100644 --- a/frontend/src/types/insights.ts +++ b/frontend/src/types/insights.ts @@ -213,3 +213,31 @@ export interface FacilityEngagement { } export type InsightsRangeKey = '7D' | '30D' | '90D' | 'YTD' | 'Custom'; + +export interface RepeatEngagement { + once: number; + two_to_four: number; + five_plus: number; +} + +export interface CategoryViews { + category: string; + views: number; +} + +export interface KCContentRow { + title: string; + visits: number; + change: number; +} + +export interface KnowledgeCenterMetrics { + total_interactions: number; + total_interactions_change: number; + unique_residents: number; + avg_session_minutes: number; + repeat_engagement: RepeatEngagement; + library_views_by_category: CategoryViews[]; + top_libraries: KCContentRow[]; + top_videos: KCContentRow[]; +} From f9be917c6a2859dbfac3d00abdedf50935e0b0fa Mon Sep 17 00:00:00 2001 From: carddev81 Date: Tue, 9 Jun 2026 22:37:52 -0500 Subject: [PATCH 2/2] fix: add timezone logic to fix data not showing up --- backend/src/handlers/dashboard.go | 55 ++++++++++++++++--- .../src/pages/insights/KnowledgeCenterTab.tsx | 4 +- frontend/src/pages/insights/OverviewTab.tsx | 8 +-- frontend/src/pages/insights/insightsRange.ts | 16 +++++- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/backend/src/handlers/dashboard.go b/backend/src/handlers/dashboard.go index 8cf1f1771..1c43f2b1d 100644 --- a/backend/src/handlers/dashboard.go +++ b/backend/src/handlers/dashboard.go @@ -125,19 +125,56 @@ func (srv *Server) handleAdminLayer2(w http.ResponseWriter, r *http.Request, log return writeJsonResponse(w, http.StatusOK, cachedData) } +const metricsDateFormat = "2006-01-02" + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +// resolvePresetRange resolves a preset range key (e.g. "30d") against the +// server clock, returning the inclusive start day and an exclusive end bound +// (midnight after today, so all of "today" is included). Resolving presets +// server-side keeps the window anchored to the same clock that timestamps the +// data, so a client in a different timezone can't request a window that ends +// before data recorded "now". +func resolvePresetRange(key string) (time.Time, time.Time, bool) { + now := time.Now() + today := startOfDay(now) + endExclusive := today.AddDate(0, 0, 1) + switch key { + case "7d": + return today.AddDate(0, 0, -6), endExclusive, true + case "30d": + return today.AddDate(0, 0, -29), endExclusive, true + case "90d": + return today.AddDate(0, 0, -89), endExclusive, true + case "ytd": + return time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()), endExclusive, true + default: + return time.Time{}, time.Time{}, false + } +} + func parseDateRangeRequest(r *http.Request) (*time.Time, *time.Time, string, error) { q := r.URL.Query() if q.Get("all_time") == "true" { return nil, nil, "all", nil } - metricsDateFormat := "2006-01-02" + // Preset ranges are resolved against the server clock. Any start_date/end_date + // the client also sends are ignored in favor of the preset. + if rangeKey := strings.ToLower(q.Get("range")); rangeKey != "" && rangeKey != "custom" { + start, end, ok := resolvePresetRange(rangeKey) + if !ok { + return nil, nil, "", newInvalidQueryParamServiceError(fmt.Errorf("unknown range %q", rangeKey), "range") + } + return &start, &end, fmt.Sprintf("%s_%s", start.Format(metricsDateFormat), end.Format(metricsDateFormat)), nil + } startStr := q.Get("start_date") endStr := q.Get("end_date") if startStr == "" && endStr == "" { - end := time.Now() - start := end.AddDate(0, 0, -30) - startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) - endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location()).AddDate(0, 0, 1) + now := time.Now() + startDay := startOfDay(now.AddDate(0, 0, -30)) + endDay := startOfDay(now).AddDate(0, 0, 1) return &startDay, &endDay, fmt.Sprintf("%s_%s", startDay.Format(metricsDateFormat), endDay.Format(metricsDateFormat)), nil } if startStr == "" || endStr == "" { @@ -154,10 +191,12 @@ func parseDateRangeRequest(r *http.Request) (*time.Time, *time.Time, string, err if end.Before(start) { return nil, nil, "", newBadRequestServiceError(fmt.Errorf("end date must be on or after start date"), "date range") } - today := time.Now() - todayMidnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) + // Clamp a future end date to the server's current day rather than rejecting + // it: a client whose clock or timezone runs ahead of the server should still + // get data instead of a 400. + todayMidnight := startOfDay(time.Now()) if end.After(todayMidnight) { - return nil, nil, "", newBadRequestServiceError(fmt.Errorf("end date cannot be in the future"), "date range") + end = todayMidnight } endExclusive := end.AddDate(0, 0, 1) return &start, &endExclusive, fmt.Sprintf("%s_%s", start.Format(metricsDateFormat), end.Format(metricsDateFormat)), nil diff --git a/frontend/src/pages/insights/KnowledgeCenterTab.tsx b/frontend/src/pages/insights/KnowledgeCenterTab.tsx index dfb7c6a23..ea8e8fae0 100644 --- a/frontend/src/pages/insights/KnowledgeCenterTab.tsx +++ b/frontend/src/pages/insights/KnowledgeCenterTab.tsx @@ -10,7 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { InfoTooltip } from '@/components/shared'; import { MetricCard } from './MetricCard'; import { KCContentTable } from './KCContentTable'; -import { InsightsDateParams } from './insightsRange'; +import { InsightsDateParams, dateQuery } from './insightsRange'; interface KnowledgeCenterTabProps { dateParams: InsightsDateParams; @@ -38,7 +38,7 @@ export default function KnowledgeCenterTab({ selectedFacility, rangeLabel }: KnowledgeCenterTabProps) { - const query = `facility=${selectedFacility}&start_date=${dateParams.start_date}&end_date=${dateParams.end_date}`; + const query = `facility=${selectedFacility}&${dateQuery(dateParams)}`; const { data, isLoading } = useSWR< ServerResponseOne >(`/api/department-metrics/knowledge-center?${query}`); diff --git a/frontend/src/pages/insights/OverviewTab.tsx b/frontend/src/pages/insights/OverviewTab.tsx index 1673a4743..013f38e35 100644 --- a/frontend/src/pages/insights/OverviewTab.tsx +++ b/frontend/src/pages/insights/OverviewTab.tsx @@ -27,7 +27,7 @@ import { } from '@/components/ui/table'; import LoginTrendChart from '@/components/charts/LoginTrendChart'; import { MetricCard } from './MetricCard'; -import { InsightsDateParams, priorParams } from './insightsRange'; +import { InsightsDateParams, priorParams, dateQuery } from './insightsRange'; interface OverviewTabProps { dateParams: InsightsDateParams; @@ -75,7 +75,7 @@ export default function OverviewTab({ 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 query = `facility=${selectedFacility}&${dateQuery(dateParams)}`; const { data: metricsResp, @@ -93,14 +93,14 @@ export default function OverviewTab({ const { data: priorMetricsResp, mutate: mutatePriorMetrics } = useSWR< ServerResponseOne >( - `/api/department-metrics?facility=${selectedFacility}&start_date=${prior.start_date}&end_date=${prior.end_date}` + `/api/department-metrics?facility=${selectedFacility}&${dateQuery(prior)}` ); const { data: comparisonResp, mutate: mutateComparison } = useSWR< ServerResponseOne >( canSwitch - ? `/api/department-metrics/facility-comparison?start_date=${dateParams.start_date}&end_date=${dateParams.end_date}` + ? `/api/department-metrics/facility-comparison?${dateQuery(dateParams)}` : null ); diff --git a/frontend/src/pages/insights/insightsRange.ts b/frontend/src/pages/insights/insightsRange.ts index 2b37995e7..fec3b42b2 100644 --- a/frontend/src/pages/insights/insightsRange.ts +++ b/frontend/src/pages/insights/insightsRange.ts @@ -3,6 +3,16 @@ import { InsightsRangeKey } from '@/types'; export interface InsightsDateParams { start_date: string; end_date: string; + // Preset key (e.g. "30d") sent for non-custom ranges so the backend resolves + // the window against the server clock. Omitted for custom ranges. + range?: string; +} + +// Builds the date portion of a metrics query string. Presets include the range +// key (backend resolves the window); custom ranges send explicit dates. +export function dateQuery(params: InsightsDateParams): string { + const base = `start_date=${params.start_date}&end_date=${params.end_date}`; + return params.range ? `${base}&range=${params.range}` : base; } export const RANGE_OPTIONS: InsightsRangeKey[] = [ @@ -74,5 +84,9 @@ export function rangeToParams( start = daysAgo(end, 29); break; } - return { start_date: formatDate(start), end_date: formatDate(end) }; + return { + start_date: formatDate(start), + end_date: formatDate(end), + range: range.toLowerCase() + }; }