@@ -9,6 +9,7 @@ mod cctalk;
99mod config;
1010mod diag_logger;
1111mod donation;
12+ mod donation_log;
1213mod error;
1314mod funds;
1415mod home_assistant;
@@ -69,6 +70,7 @@ pub fn main() {
6970 donation_handler:: init ( & main_window, & config, cashcode_tx, cctalk_tx) ;
7071 home_assistant_handler:: init ( & main_window, & config) ;
7172 game_handler:: init ( & main_window, & config) ;
73+ logs_handler:: init ( & main_window, & config) ;
7274
7375 main_window. run ( ) . unwrap ( ) ;
7476}
@@ -599,6 +601,7 @@ mod donation_handler {
599601 cashcode_tx : Sender < bill_acceptor:: CashCodeCommand > ,
600602 token : Option < String > ,
601603 photos_dir : String ,
604+ stats_db_path : String ,
602605 ) -> slint:: Timer {
603606 let timer = slint:: Timer :: default ( ) ;
604607 timer. start (
@@ -638,18 +641,32 @@ mod donation_handler {
638641 if let Some ( ref tok) = token {
639642 let username = window. get_session_username ( ) . to_string ( ) ;
640643 let fund_id = window. get_session_fund_id ( ) ;
644+ let fund_name = window. get_session_fund_name ( ) . to_string ( ) ;
641645 let tok = tok. clone ( ) ;
642646 let photos_dir = photos_dir. clone ( ) ;
647+ let stats_db_path = stats_db_path. clone ( ) ;
643648 slint:: spawn_local ( async move {
644649 match donation:: send_donation ( & tok, fund_id, & username, amount)
645650 . await
646651 {
647652 Ok ( _) => {
648653 sound:: play_yippee ( ) ;
649654 info ! ( "✅ Auto-approved donation sent successfully!" ) ;
655+ let timestamp = donation_log:: now_timestamp ( ) ;
650656 if username != "anon" {
651- camera:: capture_donation_photo ( & photos_dir, & username) ;
657+ camera:: capture_donation_photo (
658+ & photos_dir,
659+ & username,
660+ timestamp,
661+ ) ;
652662 }
663+ donation_log:: record (
664+ & stats_db_path,
665+ timestamp,
666+ & username,
667+ amount,
668+ & fund_name,
669+ ) ;
653670 }
654671 Err ( e) => {
655672 error ! ( "❌ Auto-approve: failed to send donation: {}" , e)
@@ -687,6 +704,8 @@ mod donation_handler {
687704 let cctalk_tx = cctalk_tx. clone ( ) ;
688705 let token = config. token . clone ( ) ;
689706 let photos_dir = config. photos_dir . clone ( ) ;
707+ let stats_db_path = config. stats_db_path . clone ( ) ;
708+ let weak = app. as_weak ( ) ;
690709 move |username, fund_id, amount| {
691710 info ! (
692711 "💰 Processing donation: {} AMD from {} to fund {}" ,
@@ -711,15 +730,32 @@ mod donation_handler {
711730 let token = token. clone ( ) ;
712731 let username_str = username. to_string ( ) ;
713732 let photos_dir = photos_dir. clone ( ) ;
733+ let stats_db_path = stats_db_path. clone ( ) ;
734+ let fund_name = weak
735+ . upgrade ( )
736+ . map ( |w| w. get_session_fund_name ( ) . to_string ( ) )
737+ . unwrap_or_default ( ) ;
714738 slint:: spawn_local ( async move {
715739 match donation:: send_donation ( & token, fund_id, & username_str, amount) . await
716740 {
717741 Ok ( _) => {
718742 sound:: play_yippee ( ) ;
719743 info ! ( "✅ Donation sent successfully!" ) ;
744+ let timestamp = donation_log:: now_timestamp ( ) ;
720745 if username_str != "anon" {
721- camera:: capture_donation_photo ( & photos_dir, & username_str) ;
746+ camera:: capture_donation_photo (
747+ & photos_dir,
748+ & username_str,
749+ timestamp,
750+ ) ;
722751 }
752+ donation_log:: record (
753+ & stats_db_path,
754+ timestamp,
755+ & username_str,
756+ amount,
757+ & fund_name,
758+ ) ;
723759 }
724760 Err ( e) => error ! ( "❌ Failed to send donation: {}" , e) ,
725761 }
@@ -736,6 +772,7 @@ mod donation_handler {
736772 let cashcode_tx_enter = cashcode_tx. clone ( ) ;
737773 let token_enter = config. token . clone ( ) ;
738774 let photos_dir_enter = config. photos_dir . clone ( ) ;
775+ let stats_db_path_enter = config. stats_db_path . clone ( ) ;
739776 let timer_enter = inactivity_timer. clone ( ) ;
740777 let ticker_enter = countdown_ticker. clone ( ) ;
741778 app. on_enter_insert_money ( move || {
@@ -753,6 +790,7 @@ mod donation_handler {
753790 cashcode_tx_enter. clone ( ) ,
754791 token_enter. clone ( ) ,
755792 photos_dir_enter. clone ( ) ,
793+ stats_db_path_enter. clone ( ) ,
756794 ) ;
757795 * timer_enter. borrow_mut ( ) = Some ( timer) ;
758796 // Countdown ticker (1-second decrement)
@@ -778,6 +816,7 @@ mod donation_handler {
778816 let cashcode_tx_activity = cashcode_tx. clone ( ) ;
779817 let token_activity = config. token . clone ( ) ;
780818 let photos_dir_activity = config. photos_dir . clone ( ) ;
819+ let stats_db_path_activity = config. stats_db_path . clone ( ) ;
781820 let timer_activity = inactivity_timer. clone ( ) ;
782821 let ticker_activity = countdown_ticker. clone ( ) ;
783822 app. on_activity_on_insert_money ( move || {
@@ -792,6 +831,7 @@ mod donation_handler {
792831 cashcode_tx_activity. clone ( ) ,
793832 token_activity. clone ( ) ,
794833 photos_dir_activity. clone ( ) ,
834+ stats_db_path_activity. clone ( ) ,
795835 ) ;
796836 * timer_activity. borrow_mut ( ) = Some ( timer) ;
797837 // Replace countdown ticker
@@ -867,6 +907,82 @@ mod donation_handler {
867907 }
868908}
869909
910+ mod logs_handler {
911+ use super :: * ;
912+ use slint:: { Image , ModelRc , VecModel } ;
913+
914+ /// How many past donations to show on the wall. Each entry with a photo
915+ /// loads that photo into memory at full resolution, so this is kept modest.
916+ const LOG_LIMIT : i64 = 24 ;
917+
918+ fn format_relative_time ( timestamp : u64 ) -> String {
919+ let diff = donation_log:: now_timestamp ( ) . saturating_sub ( timestamp) ;
920+ if diff < 60 {
921+ "just now" . to_string ( )
922+ } else if diff < 3600 {
923+ format ! ( "{}m ago" , diff / 60 )
924+ } else if diff < 86400 {
925+ format ! ( "{}h ago" , diff / 3600 )
926+ } else {
927+ format ! ( "{}d ago" , diff / 86400 )
928+ }
929+ }
930+
931+ pub fn init ( app : & MainWindow , config : & Config ) {
932+ let stats_db_path = config. stats_db_path . clone ( ) ;
933+ let photos_dir = config. photos_dir . clone ( ) ;
934+ let weak = app. as_weak ( ) ;
935+
936+ app. on_fetch_logs ( move || {
937+ let stats_db_path = stats_db_path. clone ( ) ;
938+ let photos_dir = photos_dir. clone ( ) ;
939+ let weak = weak. clone ( ) ;
940+
941+ thread:: spawn ( move || {
942+ // The DB read is the slow part, so it happens off the UI thread.
943+ // `slint::Image` isn't `Send`, though, so it can't be built here —
944+ // loading each photo has to happen after we hop back to the UI thread.
945+ let entries = match donation_log:: fetch_recent ( & stats_db_path, LOG_LIMIT ) {
946+ Ok ( entries) => entries,
947+ Err ( e) => {
948+ error ! ( "Failed to fetch donation log: {}" , e) ;
949+ Vec :: new ( )
950+ }
951+ } ;
952+
953+ let _ = slint:: invoke_from_event_loop ( move || {
954+ let Some ( window) = weak. upgrade ( ) else {
955+ return ;
956+ } ;
957+ let items: Vec < DonationLogItem > = entries
958+ . into_iter ( )
959+ . map ( |entry| {
960+ let is_anon = entry. username == "anon" ;
961+ let photo = if is_anon {
962+ None
963+ } else {
964+ let path = std:: path:: Path :: new ( & photos_dir)
965+ . join ( camera:: photo_filename ( entry. timestamp , & entry. username ) ) ;
966+ Image :: load_from_path ( & path) . ok ( )
967+ } ;
968+ DonationLogItem {
969+ username : entry. username . into ( ) ,
970+ amount : entry. amount ,
971+ fund_name : entry. fund_name . into ( ) ,
972+ when : format_relative_time ( entry. timestamp ) . into ( ) ,
973+ is_anon,
974+ has_photo : photo. is_some ( ) ,
975+ photo : photo. unwrap_or_default ( ) ,
976+ }
977+ } )
978+ . collect ( ) ;
979+ window. set_donation_logs ( ModelRc :: new ( VecModel :: from ( items) ) ) ;
980+ } ) ;
981+ } ) ;
982+ } ) ;
983+ }
984+ }
985+
870986mod diagnostics_handler {
871987 use super :: * ;
872988 use slint:: { ModelRc , Timer , TimerMode , VecModel } ;
0 commit comments