11'use client' ;
22
33import { useState , useEffect , useCallback , useMemo } from 'react' ;
4+ import { motion } from 'framer-motion' ;
45import { createClient } from '@/lib/supabase/client' ;
56import { calculateStreak } from '@/lib/streaks' ;
67import MonthlyCalendar from '@/components/MonthlyCalendar' ;
78import EmptyState from '@/components/EmptyState' ;
89import { SkeletonCard , SkeletonText } from '@/components/Skeleton' ;
9- import { Fire } from '@phosphor-icons/react' ;
10+ import { Flame } from '@phosphor-icons/react' ;
11+ import { fadeInUp } from '@/lib/animations' ;
1012import styles from './StatsPage.module.css' ;
1113
1214export default function StatsPageClient ( { user } ) {
@@ -25,6 +27,9 @@ export default function StatsPageClient({ user }) {
2527 weekAgo . setDate ( weekAgo . getDate ( ) - 7 ) ;
2628 const monthAgo = new Date ( now ) ;
2729 monthAgo . setMonth ( monthAgo . getMonth ( ) - 1 ) ;
30+ const sevenDaysAgo = new Date ( ) ;
31+ sevenDaysAgo . setDate ( sevenDaysAgo . getDate ( ) - 7 ) ;
32+ sevenDaysAgo . setHours ( 0 , 0 , 0 , 0 ) ;
2833
2934 // Detect the user's IANA timezone so streak calculations bucket
3035 // activity into their local day (matches DashboardLayout persistence).
@@ -35,84 +40,91 @@ export default function StatsPageClient({ user }) {
3540 // Intl API unavailable — fall back to UTC
3641 }
3742
38- // Fetch streak data using the shared calculation from lib/streaks
39- const streak = await calculateStreak ( supabase , user . id , timezone ) ;
40- setStreakData ( streak ) ;
43+ const uid = user . id ;
44+
45+ // Run streak + all count/lightweight queries in parallel. Count
46+ // queries (head: true) return only a count, not rows — a massive
47+ // win over the previous "fetch 5000 rows then filter in JS" pattern.
48+ const [
49+ streak ,
50+ totalPactsRes ,
51+ completedPactsRes ,
52+ missedPactsRes ,
53+ activePactsRes ,
54+ thisWeekCompletedRes ,
55+ thisMonthCompletedRes ,
56+ focusTotalsRes ,
57+ thisWeekFocusRes ,
58+ thisMonthFocusRes ,
59+ firstFocusRes ,
60+ recentSessionsRes ,
61+ ] = await Promise . all ( [
62+ calculateStreak ( supabase , uid , timezone ) ,
63+ // Pact counts
64+ supabase . from ( 'pacts' ) . select ( '*' , { count : 'exact' , head : true } ) . eq ( 'user_id' , uid ) ,
65+ supabase . from ( 'pacts' ) . select ( '*' , { count : 'exact' , head : true } ) . eq ( 'user_id' , uid ) . eq ( 'status' , 'completed' ) ,
66+ supabase . from ( 'pacts' ) . select ( '*' , { count : 'exact' , head : true } ) . eq ( 'user_id' , uid ) . eq ( 'status' , 'missed' ) ,
67+ supabase . from ( 'pacts' ) . select ( '*' , { count : 'exact' , head : true } ) . eq ( 'user_id' , uid ) . eq ( 'status' , 'active' ) ,
68+ supabase . from ( 'pacts' ) . select ( '*' , { count : 'exact' , head : true } )
69+ . eq ( 'user_id' , uid ) . eq ( 'status' , 'completed' ) . gte ( 'completed_at' , weekAgo . toISOString ( ) ) ,
70+ supabase . from ( 'pacts' ) . select ( '*' , { count : 'exact' , head : true } )
71+ . eq ( 'user_id' , uid ) . eq ( 'status' , 'completed' ) . gte ( 'completed_at' , monthAgo . toISOString ( ) ) ,
72+ // Focus totals — only duration_minutes column, needed for lifetime sum/avg
73+ supabase . from ( 'focus_sessions' ) . select ( 'duration_minutes' ) . eq ( 'user_id' , uid ) ,
74+ supabase . from ( 'focus_sessions' ) . select ( '*' , { count : 'exact' , head : true } )
75+ . eq ( 'user_id' , uid ) . gte ( 'started_at' , weekAgo . toISOString ( ) ) ,
76+ supabase . from ( 'focus_sessions' ) . select ( '*' , { count : 'exact' , head : true } )
77+ . eq ( 'user_id' , uid ) . gte ( 'started_at' , monthAgo . toISOString ( ) ) ,
78+ // Earliest session for avg-per-day denominator
79+ supabase . from ( 'focus_sessions' ) . select ( 'started_at' ) . eq ( 'user_id' , uid )
80+ . order ( 'started_at' , { ascending : true } ) . limit ( 1 ) ,
81+ // Recent 7 days of sessions for the "Recent Focus Sessions" list
82+ supabase . from ( 'focus_sessions' ) . select ( 'id, started_at, duration_minutes, ended_at' )
83+ . eq ( 'user_id' , uid ) . gte ( 'started_at' , sevenDaysAgo . toISOString ( ) )
84+ . order ( 'started_at' , { ascending : false } ) . limit ( 20 ) ,
85+ ] ) ;
86+
87+ if ( totalPactsRes . error ) throw totalPactsRes . error ;
88+ if ( focusTotalsRes . error ) throw focusTotalsRes . error ;
89+ if ( recentSessionsRes . error ) throw recentSessionsRes . error ;
4190
42- // Fetch pact stats
43- const { data : pacts , error : pactsError } = await supabase
44- . from ( 'pacts' )
45- . select ( 'status, completed_at, created_at' )
46- . eq ( 'user_id' , user . id )
47- . order ( 'created_at' , { ascending : false } )
48- . limit ( 5000 ) ;
49-
50- if ( pactsError ) throw pactsError ;
91+ setStreakData ( streak ) ;
5192
52- const completedCount = ( pacts || [ ] ) . filter ( p => p . status === 'completed' ) . length ;
53- const missedCount = ( pacts || [ ] ) . filter ( p => p . status === 'missed' ) . length ;
54- const activeCount = ( pacts || [ ] ) . filter ( p => p . status === 'active' ) . length ;
55- const thisWeekCompleted = ( pacts || [ ] ) . filter ( p =>
56- p . status === 'completed' && p . completed_at && new Date ( p . completed_at ) >= weekAgo
57- ) . length ;
58- const thisMonthCompleted = ( pacts || [ ] ) . filter ( p =>
59- p . status === 'completed' && p . completed_at && new Date ( p . completed_at ) >= monthAgo
60- ) . length ;
93+ const completedCount = completedPactsRes . count || 0 ;
94+ const missedCount = missedPactsRes . count || 0 ;
95+ const activeCount = activePactsRes . count || 0 ;
6196
6297 setPactStats ( {
63- total : ( pacts || [ ] ) . length ,
98+ total : totalPactsRes . count || 0 ,
6499 completed : completedCount ,
65100 missed : missedCount ,
66101 active : activeCount ,
67- thisWeek : thisWeekCompleted ,
68- thisMonth : thisMonthCompleted ,
102+ thisWeek : thisWeekCompletedRes . count || 0 ,
103+ thisMonth : thisMonthCompletedRes . count || 0 ,
69104 completionRate : completedCount + missedCount > 0
70105 ? Math . round ( ( completedCount / ( completedCount + missedCount ) ) * 100 )
71- : 0
106+ : 0 ,
72107 } ) ;
73108
74- // Fetch focus stats
75- const { data : sessions , error : focusError } = await supabase
76- . from ( 'focus_sessions' )
77- . select ( 'id, started_at, duration_minutes, ended_at' )
78- . eq ( 'user_id' , user . id )
79- . order ( 'started_at' , { ascending : false } )
80- . limit ( 5000 ) ;
81-
82- if ( focusError ) throw focusError ;
83-
84- const totalMinutes = ( sessions || [ ] ) . reduce ( ( acc , s ) => acc + ( s . duration_minutes || 0 ) , 0 ) ;
85- const sessionsCount = ( sessions || [ ] ) . length ;
86- const thisWeekSessions = ( sessions || [ ] ) . filter ( s =>
87- s . started_at && new Date ( s . started_at ) >= weekAgo
88- ) . length ;
89- const thisMonthSessions = ( sessions || [ ] ) . filter ( s =>
90- s . started_at && new Date ( s . started_at ) >= monthAgo
91- ) . length ;
92- const daysSinceFirst = sessionsCount > 0
93- ? Math . max ( 1 , Math . ceil ( ( now - new Date ( ( sessions || [ ] ) [ ( sessions || [ ] ) . length - 1 ] . started_at ) ) / ( 1000 * 60 * 60 * 24 ) ) )
109+ const focusSessions = focusTotalsRes . data || [ ] ;
110+ const totalMinutes = focusSessions . reduce ( ( acc , s ) => acc + ( s . duration_minutes || 0 ) , 0 ) ;
111+ const sessionsCount = focusSessions . length ;
112+ const firstStartedAt = firstFocusRes . data ?. [ 0 ] ?. started_at ;
113+ const daysSinceFirst = firstStartedAt
114+ ? Math . max ( 1 , Math . ceil ( ( now - new Date ( firstStartedAt ) ) / ( 1000 * 60 * 60 * 24 ) ) )
94115 : 1 ;
95116 const avgPerDay = sessionsCount > 0 ? Math . round ( totalMinutes / daysSinceFirst ) : 0 ;
96117
97118 setFocusStats ( {
98119 totalMinutes,
99120 sessionsCount,
100121 avgDuration : sessionsCount > 0 ? Math . round ( totalMinutes / sessionsCount ) : 0 ,
101- thisWeekSessions,
102- thisMonthSessions,
103- avgPerDay
122+ thisWeekSessions : thisWeekFocusRes . count || 0 ,
123+ thisMonthSessions : thisMonthFocusRes . count || 0 ,
124+ avgPerDay,
104125 } ) ;
105126
106- // Get recent sessions (last 7 days, grouped by day)
107- const sevenDaysAgo = new Date ( ) ;
108- sevenDaysAgo . setDate ( sevenDaysAgo . getDate ( ) - 7 ) ;
109- sevenDaysAgo . setHours ( 0 , 0 , 0 , 0 ) ;
110-
111- const recentSessionsData = ( sessions || [ ] )
112- . filter ( s => new Date ( s . started_at ) >= sevenDaysAgo )
113- . slice ( 0 , 20 ) ;
114-
115- setRecentSessions ( recentSessionsData ) ;
127+ setRecentSessions ( recentSessionsRes . data || [ ] ) ;
116128
117129 } catch ( err ) {
118130 console . error ( 'Error fetching stats:' , err ) ;
@@ -203,12 +215,17 @@ export default function StatsPageClient({ user }) {
203215
204216 return (
205217 < div className = { styles . container } >
206- < header className = { styles . header } >
218+ < motion . header
219+ className = { styles . header }
220+ variants = { fadeInUp }
221+ initial = "initial"
222+ animate = "animate"
223+ >
207224 < div >
208225 < h1 > Your Stats</ h1 >
209226 < p className = { styles . subtitle } > Track your productivity and progress</ p >
210227 </ div >
211- </ header >
228+ </ motion . header >
212229
213230 < div className = { styles . content } >
214231 { /* Top-level empty state when user has zero activity */ }
@@ -233,15 +250,20 @@ export default function StatsPageClient({ user }) {
233250 />
234251 ) }
235252
236- { /* Streak Summary — compact inline */ }
237- < p className = { styles . streakInline } >
238- < Fire size = { 18 } weight = "fill" color = "var(--urgency-amber)" style = { { verticalAlign : 'text-bottom' , display : 'inline' } } /> { ' ' }
239- { streakData . currentStreak } day streak{ ' ' }
240- < span className = { styles . streakSep } > ·</ span > { ' ' }
241- Best: { streakData . longestStreak } day{ ' ' }
242- < span className = { styles . streakSep } > ·</ span > { ' ' }
243- { streakData . totalCompleted } completed
244- </ p >
253+ { /* Streak Summary — hero row */ }
254+ < div className = { styles . streakRow } >
255+ < span className = { styles . streakPrimary } >
256+ < Flame size = { 22 } weight = "fill" color = "var(--urgency-amber)" />
257+ { streakData . currentStreak }
258+ < span className = { styles . streakPrimaryLabel } > day streak</ span >
259+ </ span >
260+ < span className = { styles . streakMeta } >
261+ Best: { streakData . longestStreak } { streakData . longestStreak === 1 ? 'day' : 'days' }
262+ </ span >
263+ < span className = { styles . streakMeta } >
264+ { streakData . totalCompleted } completed
265+ </ span >
266+ </ div >
245267
246268 { /* Activity Calendar */ }
247269 < MonthlyCalendar userId = { user . id } />
0 commit comments