@@ -15,6 +15,7 @@ use futures::future;
1515use crate :: calendar:: CalendarGenerator ;
1616use crate :: time_config:: TimeConfig ;
1717use std:: collections:: { BTreeMap , HashMap } ;
18+ use chrono:: Datelike ;
1819
1920pub struct SiteGenerator {
2021 output_dir : PathBuf ,
@@ -863,13 +864,140 @@ impl SiteGenerator {
863864 // Latest year href for sidebar
864865 let latest_href = format ! ( "/recap/{}/" , years[ 0 ] ) ;
865866
867+ // ------------------------------------------------------------------
868+ // Calculate Yearly Summary Stats
869+ // ------------------------------------------------------------------
870+ // 1. Basic sums from monthly data
871+ let mut total_books = 0 ;
872+ let mut total_time_seconds = 0 ;
873+
874+ for m in & monthly_vec {
875+ total_books += m. books_finished ;
876+ total_time_seconds += m. hours_read_seconds ;
877+ }
878+
879+ // 2. Session stats from page_stats (filtered by year)
880+ // Filter page_stats for the current year
881+ let year_page_stats: Vec < crate :: models:: PageStat > = stats_data. page_stats . iter ( )
882+ . filter ( |ps| {
883+ if let Some ( dt) = chrono:: DateTime :: from_timestamp ( ps. start_time , 0 ) {
884+ dt. year ( ) == * year
885+ } else {
886+ false
887+ }
888+ } )
889+ . cloned ( )
890+ . collect ( ) ;
891+
892+ // Use session_calculator to aggregate stats into valid sessions (handling gaps)
893+ let all_sessions = crate :: session_calculator:: aggregate_session_durations ( & year_page_stats) ;
894+
895+ let session_count = all_sessions. len ( ) as i64 ;
896+ let longest_session_duration = all_sessions. iter ( ) . max ( ) . copied ( ) . unwrap_or ( 0 ) ;
897+ let average_session_duration = if session_count > 0 {
898+ all_sessions. iter ( ) . sum :: < i64 > ( ) / session_count
899+ } else {
900+ 0
901+ } ;
902+
903+ // 3. Calculate active reading days for this year
904+ let year_str = format ! ( "{}" , year) ;
905+ let active_days: usize = reading_stats. daily_activity . iter ( )
906+ . filter ( |day| day. date . starts_with ( & year_str) && day. read_time > 0 )
907+ . count ( ) ;
908+
909+ // Calculate percentage based on days in the year (365 or 366 for leap years)
910+ let days_in_year = if chrono:: NaiveDate :: from_ymd_opt ( * year, 12 , 31 ) . map_or ( false , |d| d. ordinal ( ) == 366 ) {
911+ 366.0
912+ } else {
913+ 365.0
914+ } ;
915+ let active_days_percentage = ( active_days as f64 / days_in_year * 100.0 ) . round ( ) ;
916+
917+ // 4. Calculate longest streak within this year
918+ let mut year_reading_dates: Vec < chrono:: NaiveDate > = reading_stats. daily_activity . iter ( )
919+ . filter ( |day| day. date . starts_with ( & year_str) && day. read_time > 0 )
920+ . filter_map ( |day| chrono:: NaiveDate :: parse_from_str ( & day. date , "%Y-%m-%d" ) . ok ( ) )
921+ . collect ( ) ;
922+ year_reading_dates. sort ( ) ;
923+ year_reading_dates. dedup ( ) ;
924+
925+ let longest_streak = if year_reading_dates. is_empty ( ) {
926+ 0
927+ } else {
928+ let mut max_streak = 1i64 ;
929+ let mut current_streak = 1i64 ;
930+ for i in 1 ..year_reading_dates. len ( ) {
931+ let diff = ( year_reading_dates[ i] - year_reading_dates[ i - 1 ] ) . num_days ( ) ;
932+ if diff == 1 {
933+ current_streak += 1 ;
934+ max_streak = max_streak. max ( current_streak) ;
935+ } else {
936+ current_streak = 1 ;
937+ }
938+ }
939+ max_streak
940+ } ;
941+
942+ // 5. Find best month (highest reading time) in this year
943+ let best_month: Option < ( String , i64 ) > = month_hours. iter ( )
944+ . filter ( |( ym, _) | ym. starts_with ( & year_str) )
945+ . max_by_key ( |( _, secs) | * secs)
946+ . map ( |( ym, secs) | ( ym. clone ( ) , * secs) ) ;
947+
948+ let ( best_month_name, best_month_time_display) = if let Some ( ( ym, secs) ) = best_month {
949+ if secs > 0 {
950+ let month_name = if let Ok ( date) = chrono:: NaiveDate :: parse_from_str ( & format ! ( "{}-01" , ym) , "%Y-%m-%d" ) {
951+ Some ( date. format ( "%B" ) . to_string ( ) )
952+ } else {
953+ None
954+ } ;
955+ ( month_name, Some ( format_duration ( secs) ) )
956+ } else {
957+ ( None , None )
958+ }
959+ } else {
960+ ( None , None )
961+ } ;
962+
963+ // Calculate days and hours from total time (drop minutes)
964+ let total_minutes = total_time_seconds / 60 ;
965+ let total_time_days = total_minutes / ( 24 * 60 ) ;
966+ let total_time_hours = ( total_minutes % ( 24 * 60 ) ) / 60 ;
967+
968+ // Calculate hours and minutes for session stats
969+ let longest_session_total_mins = longest_session_duration / 60 ;
970+ let longest_session_hours = longest_session_total_mins / 60 ;
971+ let longest_session_minutes = longest_session_total_mins % 60 ;
972+
973+ let avg_session_total_mins = average_session_duration / 60 ;
974+ let average_session_hours = avg_session_total_mins / 60 ;
975+ let average_session_minutes = avg_session_total_mins % 60 ;
976+
977+ let summary = crate :: models:: YearlySummary {
978+ total_books,
979+ total_time_seconds,
980+ total_time_days,
981+ total_time_hours,
982+ longest_session_hours,
983+ longest_session_minutes,
984+ average_session_hours,
985+ average_session_minutes,
986+ active_days,
987+ active_days_percentage,
988+ longest_streak,
989+ best_month_name,
990+ best_month_time_display,
991+ } ;
992+
866993 let template = crate :: templates:: RecapTemplate {
867994 site_title : self . site_title . clone ( ) ,
868995 year : * year,
869996 available_years : years. clone ( ) ,
870997 prev_year,
871998 next_year,
872999 monthly : monthly_vec,
1000+ summary,
8731001 version : self . get_version ( ) ,
8741002 last_updated : self . get_last_updated ( ) ,
8751003 navbar_items : self . create_navbar_items_with_recap ( "recap" , Some ( latest_href. as_str ( ) ) ) ,
0 commit comments