- Implement subscriptions view with bidirectional recurring transaction sync - Add cascade delete/pause/resume between subscriptions and recurring - Fix foreign key constraints when deleting recurring transactions - Add cross-view instant refresh via callback pattern - Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation - Smooth budget sparklines using shared monotone_subdivide function - Add vertical spacing to budget rows - Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage - Add calendar, credit cards, forecast, goals, insights, and wishlist views - Add date picker, numpad, quick-add, category combo, and edit dialog components - Add import/export for CSV, JSON, OFX, QIF formats - Add NLP transaction parsing, OCR receipt scanning, expression evaluator - Add notification support, Sankey chart, tray icon - Add demo data seeder with full DB wipe - Expand database schema with subscriptions, goals, credit cards, and more
169 lines
5.8 KiB
Rust
169 lines
5.8 KiB
Rust
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)
|
|
}
|
|
}
|