11import Link from "next/link" ;
22import { ArrowLeft , Download , Flame } from "lucide-react" ;
33import { Prisma } from "@prisma/client" ;
4+ import { getServerSession } from "next-auth/next" ;
5+ import { redirect } from "next/navigation" ;
46import { prisma } from "@/lib/db" ;
7+ import { authOptions } from "@/lib/auth" ;
58import { computeTopicAccuracy , computeQuestionStats , accuracyLevel } from "@/lib/analytics" ;
69import { normalizeTagList } from "@/lib/problem-tags" ;
10+ import { hasPermission } from "@/lib/permissions" ;
711
812export const dynamic = "force-dynamic" ;
913
1014type AnalyticsSearchParams = Promise < {
1115 from ?: string ;
16+ group ?: string ;
1217 set ?: string ;
1318 student ?: string ;
1419 to ?: string ;
@@ -30,6 +35,10 @@ export default async function AnalyticsOverviewPage({
3035} : {
3136 searchParams ?: AnalyticsSearchParams ;
3237} ) {
38+ const session = await getServerSession ( authOptions ) ;
39+ if ( ! session ?. user ?. id ) redirect ( "/" ) ;
40+ if ( ! hasPermission ( session . user . role , "admin:analytics" ) ) redirect ( "/dashboard" ) ;
41+
3342 const params = ( await searchParams ) ?? { } ;
3443 const fromDate = parseDateParam ( params . from ) ;
3544 const toDate = parseDateParam ( params . to , true ) ;
@@ -38,15 +47,21 @@ export default async function AnalyticsOverviewPage({
3847 prisma . problemSet . findMany ( { select : { id : true , title : true , slug : true } } ) ,
3948 prisma . user . findMany ( {
4049 where : { role : "STUDENT" } ,
41- select : { id : true , name : true , email : true , displayName : true } ,
50+ select : { id : true , name : true , email : true , displayName : true , group : true } ,
4251 orderBy : { name : "asc" } ,
4352 } ) ,
4453 prisma . problem . findMany ( { select : { topicTags : true } } ) ,
4554 ] ) ;
4655
4756 const selectedSet = problemSets . find ( ( set ) => set . slug === params . set ) ?? null ;
4857 const selectedTopic = params . topic ?. trim ( ) || "" ;
58+ const selectedGroup = params . group ?. trim ( ) || "" ;
4959 const selectedStudent = students . find ( ( student ) => student . id === params . student ) ?? null ;
60+ const groupOptions = Array . from (
61+ new Set (
62+ students . map ( ( student ) => student . group ) . filter ( ( group ) : group is string => Boolean ( group ) ) ,
63+ ) ,
64+ ) . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
5065 const topicOptions = normalizeTagList ( allProblems . flatMap ( ( problem ) => problem . topicTags ) ) . sort (
5166 ( a , b ) => a . localeCompare ( b ) ,
5267 ) ;
@@ -58,10 +73,11 @@ export default async function AnalyticsOverviewPage({
5873
5974 const responseWhere : Prisma . ResponseWhereInput = {
6075 ...( Object . keys ( problemWhere ) . length > 0 ? { problem : problemWhere } : { } ) ,
61- ...( selectedStudent || fromDate || toDate
76+ ...( selectedStudent || selectedGroup || fromDate || toDate
6277 ? {
6378 attempt : {
6479 ...( selectedStudent ? { userId : selectedStudent . id } : { } ) ,
80+ ...( selectedGroup ? { user : { group : selectedGroup } } : { } ) ,
6581 ...( fromDate || toDate
6682 ? {
6783 submittedAt : {
@@ -78,6 +94,7 @@ export default async function AnalyticsOverviewPage({
7894 const attemptWhere : Prisma . AttemptWhereInput = {
7995 ...( selectedSet ? { problemSetId : selectedSet . id } : { } ) ,
8096 ...( selectedStudent ? { userId : selectedStudent . id } : { } ) ,
97+ ...( selectedGroup ? { user : { group : selectedGroup } } : { } ) ,
8198 ...( fromDate || toDate
8299 ? {
83100 submittedAt : {
@@ -186,6 +203,31 @@ export default async function AnalyticsOverviewPage({
186203 } )
187204 . filter ( ( question ) => question . reasons . length > 0 )
188205 . slice ( 0 , 6 ) ;
206+ const trendBuckets = new Map < string , { attempts : number ; completions : number } > ( ) ;
207+ const trendNow = new Date ( ) ;
208+ for ( let offset = 5 ; offset >= 0 ; offset -- ) {
209+ const start = new Date ( trendNow ) ;
210+ start . setDate ( start . getDate ( ) - offset * 7 ) ;
211+ const key = `${ start . getMonth ( ) + 1 } /${ start . getDate ( ) } ` ;
212+ trendBuckets . set ( key , { attempts : 0 , completions : 0 } ) ;
213+ }
214+ for ( const attempt of attempts ) {
215+ const ageDays = Math . floor ( ( trendNow . getTime ( ) - attempt . submittedAt . getTime ( ) ) / 86_400_000 ) ;
216+ if ( ageDays < 0 || ageDays > 41 ) continue ;
217+ const bucketStart = new Date ( trendNow ) ;
218+ bucketStart . setDate ( bucketStart . getDate ( ) - Math . floor ( ageDays / 7 ) * 7 ) ;
219+ const key = `${ bucketStart . getMonth ( ) + 1 } /${ bucketStart . getDate ( ) } ` ;
220+ const bucket = trendBuckets . get ( key ) ?? { attempts : 0 , completions : 0 } ;
221+ bucket . attempts += 1 ;
222+ if ( attempt . maxScore > 0 && attempt . score / attempt . maxScore >= 0.8 ) {
223+ bucket . completions += 1 ;
224+ }
225+ trendBuckets . set ( key , bucket ) ;
226+ }
227+ const trendRows = Array . from ( trendBuckets . entries ( ) ) . map ( ( [ label , bucket ] ) => ( {
228+ label,
229+ ...bucket ,
230+ } ) ) ;
189231
190232 return (
191233 < main className = "single-page" >
@@ -231,6 +273,14 @@ export default async function AnalyticsOverviewPage({
231273 </ option >
232274 ) ) }
233275 </ select >
276+ < select aria-label = "Filter by cohort" name = "group" defaultValue = { selectedGroup } >
277+ < option value = "" > All cohorts</ option >
278+ { groupOptions . map ( ( group ) => (
279+ < option key = { group } value = { group } >
280+ { group }
281+ </ option >
282+ ) ) }
283+ </ select >
234284 < select aria-label = "Filter by topic" name = "topic" defaultValue = { selectedTopic } >
235285 < option value = "" > All topics</ option >
236286 { topicOptions . map ( ( topic ) => (
@@ -244,7 +294,12 @@ export default async function AnalyticsOverviewPage({
244294 < button className = "secondary-action compact" type = "submit" >
245295 Filter
246296 </ button >
247- { selectedSet || selectedStudent || selectedTopic || params . from || params . to ? (
297+ { selectedSet ||
298+ selectedStudent ||
299+ selectedGroup ||
300+ selectedTopic ||
301+ params . from ||
302+ params . to ? (
248303 < Link className = "text-link" href = "/admin/analytics" >
249304 Clear
250305 </ Link >
@@ -281,6 +336,28 @@ export default async function AnalyticsOverviewPage({
281336 < strong > { recentAttemptCount } </ strong >
282337 </ article >
283338 </ section >
339+
340+ < section className = "panel table-panel" >
341+ < div className = "panel-header" >
342+ < div >
343+ < p className = "eyebrow" > Completion trends</ p >
344+ < h2 > Last 6 weeks</ h2 >
345+ </ div >
346+ </ div >
347+ < div className = "trend-strip" >
348+ { trendRows . map ( ( row ) => (
349+ < div className = "trend-bar" key = { row . label } >
350+ < span
351+ style = { {
352+ height : `${ Math . max ( 8 , Math . min ( 100 , row . attempts * 14 ) ) } %` ,
353+ } }
354+ />
355+ < strong > { row . completions } </ strong >
356+ < small > { row . label } </ small >
357+ </ div >
358+ ) ) }
359+ </ div >
360+ </ section >
284361 < section className = "heatmap-section" >
285362 < div className = "panel-header" >
286363 < div >
0 commit comments