use gtk::prelude::*; use chrono::{Datelike, NaiveDate}; use outlay_core::db::Database; /// Create a spending heatmap calendar for a given month. pub fn calendar_heatmap(db: &Database, year: i32, month: u32) -> gtk::Box { let container = gtk::Box::new(gtk::Orientation::Vertical, 8); let raw_daily = db.get_daily_totals(year, month).unwrap_or_default(); // Extract (day_number, expense_amount) let daily: Vec<(u32, f64)> = raw_daily.iter() .map(|(date, _income, expense)| (date.day(), *expense)) .collect(); let max_val = daily.iter().map(|(_, v)| *v).fold(0.0_f64, f64::max); // Day labels let header = gtk::Box::new(gtk::Orientation::Horizontal, 2); header.set_halign(gtk::Align::Center); for day_name in &["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] { let label = gtk::Label::new(Some(day_name)); label.set_width_chars(5); label.add_css_class("caption"); label.add_css_class("dim-label"); header.append(&label); } container.append(&header); // Build daily amount map let mut amounts = std::collections::HashMap::new(); for (day, amount) in &daily { amounts.insert(*day, *amount); } // Determine first day of month and number of days let first_day = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); let days_in_month = if month == 12 { NaiveDate::from_ymd_opt(year + 1, 1, 1) } else { NaiveDate::from_ymd_opt(year, month + 1, 1) }.and_then(|d| d.pred_opt()).map(|d| d.day()).unwrap_or(30); // Monday = 0, Sunday = 6 let first_weekday = first_day.weekday().num_days_from_monday(); let grid = gtk::Grid::new(); grid.set_row_spacing(2); grid.set_column_spacing(2); grid.set_halign(gtk::Align::Center); let mut day = 1_u32; let mut row = 0; let mut col = first_weekday as i32; while day <= days_in_month { let amount = amounts.get(&day).copied().unwrap_or(0.0); let cell = make_cell(day, amount, max_val); grid.attach(&cell, col, row, 1, 1); col += 1; if col > 6 { col = 0; row += 1; } day += 1; } container.append(&grid); // Legend let legend = gtk::Box::new(gtk::Orientation::Horizontal, 4); legend.set_halign(gtk::Align::Center); legend.set_margin_top(4); let less_label = gtk::Label::new(Some("Less")); less_label.add_css_class("caption"); less_label.add_css_class("dim-label"); legend.append(&less_label); for level in 0..5 { let cell = gtk::DrawingArea::new(); cell.set_size_request(16, 16); let intensity = level as f64 / 4.0; cell.set_draw_func(move |_area, ctx, w, h| { let (r, g, b, a) = intensity_color(intensity); ctx.set_source_rgba(r, g, b, a); let radius = 3.0; let w = w as f64; let h = h as f64; ctx.new_sub_path(); ctx.arc(w - radius, radius, radius, -std::f64::consts::FRAC_PI_2, 0.0); ctx.arc(w - radius, h - radius, radius, 0.0, std::f64::consts::FRAC_PI_2); ctx.arc(radius, h - radius, radius, std::f64::consts::FRAC_PI_2, std::f64::consts::PI); ctx.arc(radius, radius, radius, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2); ctx.close_path(); let _ = ctx.fill(); }); legend.append(&cell); } let more_label = gtk::Label::new(Some("More")); more_label.add_css_class("caption"); more_label.add_css_class("dim-label"); legend.append(&more_label); container.append(&legend); container } fn make_cell(day: u32, amount: f64, max_val: f64) -> gtk::DrawingArea { let cell = gtk::DrawingArea::builder() .accessible_role(gtk::AccessibleRole::Img) .build(); cell.set_size_request(44, 44); let intensity = if max_val > 0.0 { amount / max_val } else { 0.0 }; let day_str = format!("{}", day); let has_spending = amount > 0.0; let tooltip = if has_spending { format!("Day {}: {:.2}", day, amount) } else { format!("Day {}: no spending", day) }; cell.set_tooltip_text(Some(&tooltip)); cell.update_property(&[gtk::accessible::Property::Label(&tooltip)]); cell.set_draw_func(move |_area, ctx, w, h| { let w = w as f64; let h = h as f64; // Background with rounded corners let radius = 4.0; let (r, g, b, a) = intensity_color(intensity); ctx.set_source_rgba(r, g, b, a); ctx.new_sub_path(); ctx.arc(w - radius, radius, radius, -std::f64::consts::FRAC_PI_2, 0.0); ctx.arc(w - radius, h - radius, radius, 0.0, std::f64::consts::FRAC_PI_2); ctx.arc(radius, h - radius, radius, std::f64::consts::FRAC_PI_2, std::f64::consts::PI); ctx.arc(radius, radius, radius, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2); ctx.close_path(); let _ = ctx.fill(); // Day number ctx.set_source_rgba(1.0, 1.0, 1.0, if has_spending { 0.9 } else { 0.5 }); ctx.select_font_face("sans-serif", gtk::cairo::FontSlant::Normal, gtk::cairo::FontWeight::Normal); ctx.set_font_size(12.0); let extents = ctx.text_extents(&day_str).unwrap(); let x = (w - extents.width()) / 2.0 - extents.x_bearing(); let y = (h - extents.height()) / 2.0 - extents.y_bearing(); ctx.move_to(x, y); let _ = ctx.show_text(&day_str); }); cell } fn intensity_color(intensity: f64) -> (f64, f64, f64, f64) { if intensity <= 0.0 { (0.5, 0.5, 0.5, 0.1) } else if intensity < 0.25 { (0.42, 0.75, 0.45, 0.4) } else if intensity < 0.5 { (0.85, 0.75, 0.35, 0.5) } else if intensity < 0.75 { (0.87, 0.55, 0.33, 0.6) } else { (0.87, 0.33, 0.36, 0.7) } }