Add feature batch 2, subscription/recurring sync, smooth charts, and app icon

- 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
This commit is contained in:
2026-03-03 21:18:37 +02:00
parent 773dae4684
commit 10a76e3003
10102 changed files with 108019 additions and 1335 deletions

View File

@@ -0,0 +1,168 @@
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)
}
}