1+ import { Ionicons } from '@expo/vector-icons' ;
12import { useFocusEffect , useRouter } from 'expo-router' ;
23import { useCallback , useState } from 'react' ;
3- import { ScrollView , StyleSheet , Text , View } from 'react-native' ;
4+ import { Pressable , ScrollView , StyleSheet , Text , View } from 'react-native' ;
45import { useSafeAreaInsets } from 'react-native-safe-area-context' ;
6+ import Svg , { Circle as SvgCircle } from 'react-native-svg' ;
57import { GreenHeader } from '../../src/components/GreenHeader' ;
6- import { Body , Button , Card , Pill } from '../../src/components/UI' ;
7- import { colors , spacing } from '../../src/theme' ;
8+ import { Body , Button , Card , Caption , Pill } from '../../src/components/UI' ;
9+ import { colors , radius , shadows , spacing } from '../../src/theme' ;
810import { repo } from '../../src/data/repo' ;
911import { AttendanceRecord , ClassUnit , Session , User } from '../../src/data/types' ;
1012import { buildOverviewCsv , shareCsv } from '../../src/lib/export' ;
@@ -54,15 +56,23 @@ export default function Reports() {
5456 const unitIds = selectedUnit === 'all' ? units . map ( u => u . id ) : [ selectedUnit ] ;
5557 const relevantUnits = units . filter ( u => unitIds . includes ( u . id ) ) ;
5658 const studentIds = Array . from ( new Set ( relevantUnits . flatMap ( u => u . enrolledStudentIds ) ) ) ;
57- return studentIds . map ( sid => {
58- const su = users . find ( x => x . id === sid ) ;
59- const sessIds = filteredSessions . filter ( s => unitIds . includes ( s . unitId ) ) . map ( s => s . id ) ;
60- const attended = records . filter ( r => r . studentId === sid && sessIds . includes ( r . sessionId ) ) . length ;
61- const tot = sessIds . length ;
62- const pct = tot ? Math . round ( ( attended / tot ) * 100 ) : 100 ;
63- const risk = pct >= 75 ? 'Good' : pct >= 50 ? 'At Risk' : 'Critical' ;
64- return { id : sid , name : su ?. name ?? sid , attended, total : tot , pct, risk } ;
65- } ) ;
59+ return studentIds
60+ . map ( sid => {
61+ const su = users . find ( x => x . id === sid ) ;
62+ const sessIds = filteredSessions . filter ( s => unitIds . includes ( s . unitId ) ) . map ( s => s . id ) ;
63+ const attended = records . filter ( r => r . studentId === sid && sessIds . includes ( r . sessionId ) ) . length ;
64+ const tot = sessIds . length ;
65+ const pct = tot ? Math . round ( ( attended / tot ) * 100 ) : 0 ;
66+ const risk : 'Good' | 'At Risk' | 'Critical' | 'No data' =
67+ tot === 0 ? 'No data' : pct >= 75 ? 'Good' : pct >= 50 ? 'At Risk' : 'Critical' ;
68+ return { id : sid , name : su ?. name ?? sid , attended, total : tot , pct, risk } ;
69+ } )
70+ . sort ( ( a , b ) => {
71+ // Critical first, then At Risk, then Good, then No data; within group by lowest pct
72+ const order = { Critical : 0 , 'At Risk' : 1 , Good : 2 , 'No data' : 3 } as const ;
73+ if ( order [ a . risk ] !== order [ b . risk ] ) return order [ a . risk ] - order [ b . risk ] ;
74+ return a . pct - b . pct ;
75+ } ) ;
6676 } ) ( ) ;
6777
6878 const scrollPadBottom = spacing . lg + Math . max ( insets . bottom , 12 ) + 56 ;
@@ -71,12 +81,30 @@ export default function Reports() {
7181 < View style = { { flex : 1 , backgroundColor : colors . bgCanvas } } >
7282 < GreenHeader title = "Reports" centered />
7383 < ScrollView contentContainerStyle = { { padding : spacing . lg , paddingBottom : scrollPadBottom , gap : 14 } } >
74- < View style = { { flexDirection : 'row' , flexWrap : 'wrap' , gap : 6 } } >
75- < Chip label = "All" active = { selectedUnit === 'all' } onPress = { ( ) => setSelectedUnit ( 'all' ) } />
84+ < Caption style = { { marginBottom : - 6 } } > FILTER BY CLASS</ Caption >
85+ < ScrollView
86+ horizontal
87+ showsHorizontalScrollIndicator = { false }
88+ contentContainerStyle = { { gap : 8 , paddingVertical : 2 } }
89+ >
90+ < Chip
91+ icon = "apps"
92+ label = "All classes"
93+ sub = { `${ units . length } ${ units . length === 1 ? 'class' : 'classes' } ` }
94+ active = { selectedUnit === 'all' }
95+ onPress = { ( ) => setSelectedUnit ( 'all' ) }
96+ />
7697 { units . map ( u => (
77- < Chip key = { u . id } label = { u . code } active = { selectedUnit === u . id } onPress = { ( ) => setSelectedUnit ( u . id ) } />
98+ < Chip
99+ key = { u . id }
100+ icon = "book"
101+ label = { u . code }
102+ sub = { u . name }
103+ active = { selectedUnit === u . id }
104+ onPress = { ( ) => setSelectedUnit ( u . id ) }
105+ />
78106 ) ) }
79- </ View >
107+ </ ScrollView >
80108
81109 < Card >
82110 < Text style = { { fontWeight : '700' } } > Per-session attendance</ Text >
@@ -111,18 +139,32 @@ export default function Reports() {
111139
112140 < Card >
113141 < Text style = { { fontWeight : '700' , marginBottom : 8 } } > Students</ Text >
114- { studentStats . map ( s => (
115- < View key = { s . id } style = { styles . row } >
116- < View style = { { flex : 1 } } >
117- < Text style = { { fontWeight : '600' } } > { s . name } </ Text >
118- < Body muted > { s . id } • { s . attended } /{ s . total } • { s . pct } %</ Body >
142+ { studentStats . length === 0 ? (
143+ < Body muted > No students enrolled for this filter.</ Body >
144+ ) : (
145+ studentStats . map ( s => (
146+ < View key = { s . id } style = { styles . row } >
147+ < View style = { { flex : 1 } } >
148+ < Text style = { { fontWeight : '600' } } numberOfLines = { 1 } > { s . name } </ Text >
149+ < Body muted >
150+ { s . id } • { s . total === 0 ? 'no sessions yet' : `${ s . attended } /${ s . total } · ${ s . pct } %` }
151+ </ Body >
152+ </ View >
153+ < Pill
154+ label = { s . risk }
155+ tone = {
156+ s . risk === 'Good'
157+ ? 'success'
158+ : s . risk === 'At Risk'
159+ ? 'warn'
160+ : s . risk === 'Critical'
161+ ? 'danger'
162+ : 'neutral'
163+ }
164+ />
119165 </ View >
120- < Pill
121- label = { s . risk }
122- tone = { s . risk === 'Good' ? 'success' : s . risk === 'At Risk' ? 'warn' : 'danger' }
123- />
124- </ View >
125- ) ) }
166+ ) )
167+ ) }
126168 </ Card >
127169
128170 < Button
@@ -158,18 +200,47 @@ export default function Reports() {
158200 ) ;
159201}
160202
161- function Chip ( { label, active, onPress } : any ) {
203+ function Chip ( {
204+ label,
205+ sub,
206+ icon,
207+ active,
208+ onPress,
209+ } : {
210+ label : string ;
211+ sub ?: string ;
212+ icon ?: keyof typeof Ionicons . glyphMap ;
213+ active ?: boolean ;
214+ onPress : ( ) => void ;
215+ } ) {
162216 return (
163- < Text
217+ < Pressable
164218 onPress = { onPress }
165- style = { {
166- paddingHorizontal : 12 , paddingVertical : 6 , borderRadius : 999 ,
167- backgroundColor : active ? colors . gold : colors . bgSubtle ,
168- color : active ? '#fff' : colors . text , fontWeight : '600' , overflow : 'hidden' ,
169- } }
219+ hitSlop = { 6 }
220+ style = { ( { pressed } ) => [
221+ styles . chip ,
222+ active && styles . chipActive ,
223+ ! active && shadows . xs ,
224+ { opacity : pressed ? 0.85 : 1 , transform : [ { scale : pressed ? 0.98 : 1 } ] } ,
225+ ] }
170226 >
171- { label }
172- </ Text >
227+ { icon ? (
228+ < Ionicons
229+ name = { icon }
230+ size = { 14 }
231+ color = { active ? colors . white : colors . green }
232+ style = { { marginRight : 6 } }
233+ />
234+ ) : null }
235+ < View >
236+ < Text style = { [ styles . chipLabel , active && styles . chipLabelActive ] } > { label } </ Text >
237+ { sub ? (
238+ < Text style = { [ styles . chipSub , active && styles . chipSubActive ] } numberOfLines = { 1 } >
239+ { sub }
240+ </ Text >
241+ ) : null }
242+ </ View >
243+ </ Pressable >
173244 ) ;
174245}
175246
@@ -183,24 +254,63 @@ function LegendDot({ color, label }: any) {
183254}
184255
185256function Donut ( { percent } : { percent : number } ) {
186- const size = 100 ;
187- const r = size / 2 - 10 ;
188- const p = Math . round ( percent * 100 ) ;
257+ const size = 110 ;
258+ const stroke = 12 ;
259+ const p = Math . max ( 0 , Math . min ( 1 , percent ) ) ;
260+ const pct = Math . round ( p * 100 ) ;
261+ const r = ( size - stroke ) / 2 ;
262+ const c = 2 * Math . PI * r ;
189263 return (
190- < View style = { { width : size , height : size , borderRadius : size / 2 , borderWidth : 10 , borderColor : colors . red , alignItems : 'center' , justifyContent : 'center' , overflow : 'hidden' } } >
191- < View style = { {
192- position : 'absolute' , top : - 5 , left : - 5 , width : size , height : size , borderRadius : size / 2 ,
193- borderWidth : 10 , borderColor : colors . green ,
194- borderRightColor : percent > 0.25 ? colors . green : 'transparent' ,
195- borderBottomColor : percent > 0.5 ? colors . green : 'transparent' ,
196- borderLeftColor : percent > 0.75 ? colors . green : 'transparent' ,
197- transform : [ { rotate : `${ percent * 360 } deg` } ] ,
198- } } />
199- < Text style = { { fontSize : 20 , fontWeight : '800' , color : colors . green } } > { p } %</ Text >
264+ < View style = { { width : size , height : size , alignItems : 'center' , justifyContent : 'center' } } >
265+ < Svg width = { size } height = { size } >
266+ { /* Background ring */ }
267+ < SvgCircle
268+ cx = { size / 2 }
269+ cy = { size / 2 }
270+ r = { r }
271+ stroke = { colors . red }
272+ strokeWidth = { stroke }
273+ fill = "none"
274+ />
275+ { /* Foreground arc — rotate -90° to start at 12 o'clock */ }
276+ < SvgCircle
277+ cx = { size / 2 }
278+ cy = { size / 2 }
279+ r = { r }
280+ stroke = { colors . green }
281+ strokeWidth = { stroke }
282+ fill = "none"
283+ strokeDasharray = { `${ c * p } ${ c } ` }
284+ strokeLinecap = "round"
285+ transform = { `rotate(-90 ${ size / 2 } ${ size / 2 } )` }
286+ />
287+ </ Svg >
288+ < Text style = { { position : 'absolute' , fontSize : 20 , fontWeight : '800' , color : colors . green } } >
289+ { pct } %
290+ </ Text >
200291 </ View >
201292 ) ;
202293}
203294
204295const styles = StyleSheet . create ( {
205- row : { flexDirection : 'row' , alignItems : 'center' , paddingVertical : 10 , borderBottomWidth : 1 , borderBottomColor : colors . border } ,
296+ row : { flexDirection : 'row' , alignItems : 'center' , paddingVertical : 10 , borderBottomWidth : 1 , borderBottomColor : colors . border , gap : spacing . md } ,
297+ chip : {
298+ flexDirection : 'row' ,
299+ alignItems : 'center' ,
300+ paddingVertical : 8 ,
301+ paddingHorizontal : 12 ,
302+ borderRadius : radius . md ,
303+ backgroundColor : colors . white ,
304+ borderWidth : 1 ,
305+ borderColor : colors . border ,
306+ minWidth : 110 ,
307+ } ,
308+ chipActive : {
309+ backgroundColor : colors . green ,
310+ borderColor : colors . green ,
311+ } ,
312+ chipLabel : { fontWeight : '800' , fontSize : 13 , color : colors . text , letterSpacing : 0.2 } ,
313+ chipLabelActive : { color : colors . white } ,
314+ chipSub : { fontSize : 11 , color : colors . textMuted , marginTop : 1 , maxWidth : 160 } ,
315+ chipSubActive : { color : 'rgba(255,255,255,0.85)' } ,
206316} ) ;
0 commit comments