From 1a42fa21f9d58ecc3be554e37bb4914842226429 Mon Sep 17 00:00:00 2001 From: Alexandre Trotel Date: Fri, 2 Jan 2026 16:16:55 +0100 Subject: [PATCH 1/9] feat: add inflation feature with IPC data --- .../down.sql | 2 + .../up.sql | 13 + src-core/src/inflation/inflation_model.rs | 64 +++++ .../src/inflation/inflation_repository.rs | 201 +++++++++++++ src-core/src/inflation/inflation_service.rs | 187 ++++++++++++ src-core/src/inflation/inflation_traits.rs | 42 +++ src-core/src/inflation/mod.rs | 9 + src-core/src/lib.rs | 1 + src-core/src/schema.rs | 14 + src-tauri/src/commands/inflation.rs | 139 +++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/context/providers.rs | 8 + src-tauri/src/context/registry.rs | 8 +- src-tauri/src/lib.rs | 8 + src/commands/inflation-rates.ts | 131 +++++++++ src/lib/query-keys.ts | 4 + src/lib/schemas.ts | 12 + src/lib/types.ts | 22 ++ .../inflation/components/inflation-chart.tsx | 117 ++++++++ .../insights/inflation/inflation-page.tsx | 269 ++++++++++++++++++ src/pages/insights/portfolio-insights.tsx | 11 + .../components/inflation-rate-edit-modal.tsx | 40 +++ .../components/inflation-rate-form.tsx | 193 +++++++++++++ .../components/inflation-rate-item.tsx | 69 +++++ .../inflation-rates/inflation-rates-page.tsx | 243 ++++++++++++++++ .../use-inflation-rate-mutations.ts | 120 ++++++++ src/pages/settings/settings-layout.tsx | 6 + src/routes.tsx | 2 + 28 files changed, 1935 insertions(+), 1 deletion(-) create mode 100644 src-core/migrations/2026-01-02-100000_create_inflation_rates/down.sql create mode 100644 src-core/migrations/2026-01-02-100000_create_inflation_rates/up.sql create mode 100644 src-core/src/inflation/inflation_model.rs create mode 100644 src-core/src/inflation/inflation_repository.rs create mode 100644 src-core/src/inflation/inflation_service.rs create mode 100644 src-core/src/inflation/inflation_traits.rs create mode 100644 src-core/src/inflation/mod.rs create mode 100644 src-tauri/src/commands/inflation.rs create mode 100644 src/commands/inflation-rates.ts create mode 100644 src/pages/insights/inflation/components/inflation-chart.tsx create mode 100644 src/pages/insights/inflation/inflation-page.tsx create mode 100644 src/pages/settings/inflation-rates/components/inflation-rate-edit-modal.tsx create mode 100644 src/pages/settings/inflation-rates/components/inflation-rate-form.tsx create mode 100644 src/pages/settings/inflation-rates/components/inflation-rate-item.tsx create mode 100644 src/pages/settings/inflation-rates/inflation-rates-page.tsx create mode 100644 src/pages/settings/inflation-rates/use-inflation-rate-mutations.ts diff --git a/src-core/migrations/2026-01-02-100000_create_inflation_rates/down.sql b/src-core/migrations/2026-01-02-100000_create_inflation_rates/down.sql new file mode 100644 index 000000000..037d8d2e9 --- /dev/null +++ b/src-core/migrations/2026-01-02-100000_create_inflation_rates/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_inflation_rates_country_year; +DROP TABLE IF EXISTS inflation_rates; diff --git a/src-core/migrations/2026-01-02-100000_create_inflation_rates/up.sql b/src-core/migrations/2026-01-02-100000_create_inflation_rates/up.sql new file mode 100644 index 000000000..c3c9f41e9 --- /dev/null +++ b/src-core/migrations/2026-01-02-100000_create_inflation_rates/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE inflation_rates ( + id TEXT PRIMARY KEY NOT NULL, + country_code TEXT NOT NULL, + year INTEGER NOT NULL, + rate NUMERIC NOT NULL, + reference_date TEXT, + data_source TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(country_code, year) +); + +CREATE INDEX idx_inflation_rates_country_year ON inflation_rates(country_code, year); diff --git a/src-core/src/inflation/inflation_model.rs b/src-core/src/inflation/inflation_model.rs new file mode 100644 index 000000000..51d59f659 --- /dev/null +++ b/src-core/src/inflation/inflation_model.rs @@ -0,0 +1,64 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Queryable, Insertable, Identifiable, Serialize, Deserialize, Debug, Clone)] +#[diesel(table_name = crate::schema::inflation_rates)] +#[serde(rename_all = "camelCase")] +pub struct InflationRate { + pub id: String, + pub country_code: String, + pub year: i32, + pub rate: f64, + pub reference_date: Option, + pub data_source: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Insertable, AsChangeset, Serialize, Deserialize, Debug, Clone)] +#[diesel(table_name = crate::schema::inflation_rates)] +#[serde(rename_all = "camelCase")] +pub struct NewInflationRate { + pub id: Option, + pub country_code: String, + pub year: i32, + pub rate: f64, + pub reference_date: Option, + pub data_source: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InflationAdjustedValue { + pub year: i32, + pub nominal_value: f64, + pub real_value: f64, + pub inflation_rate: Option, + pub cumulative_inflation: f64, + pub reference_date: String, +} + +// World Bank API response structures +#[derive(Deserialize, Debug)] +pub struct WorldBankResponse(pub WorldBankMeta, pub Option>); + +#[derive(Deserialize, Debug)] +pub struct WorldBankMeta { + pub page: i32, + pub pages: i32, + pub total: i32, +} + +#[derive(Deserialize, Debug)] +pub struct WorldBankDataPoint { + pub date: String, + pub value: Option, + pub country: WorldBankCountry, +} + +#[derive(Deserialize, Debug)] +pub struct WorldBankCountry { + pub id: String, + pub value: String, +} diff --git a/src-core/src/inflation/inflation_repository.rs b/src-core/src/inflation/inflation_repository.rs new file mode 100644 index 000000000..098167962 --- /dev/null +++ b/src-core/src/inflation/inflation_repository.rs @@ -0,0 +1,201 @@ +use async_trait::async_trait; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::sqlite::SqliteConnection; +use std::sync::Arc; +use uuid::Uuid; + +use super::inflation_model::{InflationRate, NewInflationRate}; +use super::inflation_traits::InflationRateRepositoryTrait; +use crate::db::{get_connection, WriteHandle}; +use crate::errors::{Error, Result}; +use crate::schema::inflation_rates; + +pub struct InflationRateRepository { + pool: Arc>>, + writer: WriteHandle, +} + +impl InflationRateRepository { + pub fn new(pool: Arc>>, writer: WriteHandle) -> Self { + InflationRateRepository { pool, writer } + } + + fn get_inflation_rate_impl(&self, id_param: &str) -> Result { + let mut conn = get_connection(&self.pool)?; + inflation_rates::table + .find(id_param) + .first(&mut conn) + .map_err(Error::from) + } + + fn get_inflation_rates_impl(&self) -> Result> { + let mut conn = get_connection(&self.pool)?; + inflation_rates::table + .order(inflation_rates::year.desc()) + .load(&mut conn) + .map_err(Error::from) + } + + fn get_inflation_rates_by_country_impl(&self, country_code: &str) -> Result> { + let mut conn = get_connection(&self.pool)?; + inflation_rates::table + .filter(inflation_rates::country_code.eq(country_code.to_uppercase())) + .order(inflation_rates::year.desc()) + .load(&mut conn) + .map_err(Error::from) + } + + fn get_inflation_rate_for_year_impl( + &self, + country_code: &str, + year: i32, + ) -> Result> { + let mut conn = get_connection(&self.pool)?; + inflation_rates::table + .filter(inflation_rates::country_code.eq(country_code.to_uppercase())) + .filter(inflation_rates::year.eq(year)) + .first(&mut conn) + .optional() + .map_err(Error::from) + } +} + +#[async_trait] +impl InflationRateRepositoryTrait for InflationRateRepository { + fn get_inflation_rate(&self, id_param: &str) -> Result { + self.get_inflation_rate_impl(id_param) + } + + fn get_inflation_rates(&self) -> Result> { + self.get_inflation_rates_impl() + } + + fn get_inflation_rates_by_country(&self, country_code: &str) -> Result> { + self.get_inflation_rates_by_country_impl(country_code) + } + + fn get_inflation_rate_for_year( + &self, + country_code: &str, + year: i32, + ) -> Result> { + self.get_inflation_rate_for_year_impl(country_code, year) + } + + async fn create_inflation_rate(&self, new_rate: NewInflationRate) -> Result { + let new_rate_owned = new_rate.clone(); + + self.writer + .exec( + move |conn: &mut SqliteConnection| -> Result { + let new_rate_record = ( + inflation_rates::id.eq(Uuid::new_v4().to_string()), + inflation_rates::country_code.eq(new_rate_owned.country_code.to_uppercase()), + inflation_rates::year.eq(new_rate_owned.year), + inflation_rates::rate.eq(new_rate_owned.rate), + inflation_rates::reference_date.eq(new_rate_owned.reference_date), + inflation_rates::data_source.eq(new_rate_owned.data_source), + inflation_rates::created_at.eq(chrono::Utc::now().naive_utc()), + inflation_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + ); + + diesel::insert_into(inflation_rates::table) + .values(new_rate_record) + .get_result(conn) + .map_err(Error::from) + }, + ) + .await + } + + async fn update_inflation_rate( + &self, + id_param: &str, + updated_rate: NewInflationRate, + ) -> Result { + let id_owned = id_param.to_string(); + let updated_rate_owned = updated_rate.clone(); + + self.writer + .exec( + move |conn: &mut SqliteConnection| -> Result { + let target = inflation_rates::table.find(id_owned); + diesel::update(target) + .set(( + inflation_rates::country_code + .eq(updated_rate_owned.country_code.to_uppercase()), + inflation_rates::year.eq(updated_rate_owned.year), + inflation_rates::rate.eq(updated_rate_owned.rate), + inflation_rates::reference_date.eq(updated_rate_owned.reference_date), + inflation_rates::data_source.eq(updated_rate_owned.data_source), + inflation_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + )) + .get_result(conn) + .map_err(Error::from) + }, + ) + .await + } + + async fn delete_inflation_rate(&self, id_param: &str) -> Result<()> { + let id_owned = id_param.to_string(); + self.writer + .exec(move |conn: &mut SqliteConnection| -> Result<()> { + diesel::delete(inflation_rates::table.find(id_owned)) + .execute(conn) + .map_err(Error::from) + .map(|_| ()) + }) + .await + } + + async fn upsert_inflation_rate(&self, rate: NewInflationRate) -> Result { + let rate_owned = rate.clone(); + let country_code_upper = rate_owned.country_code.to_uppercase(); + + self.writer + .exec( + move |conn: &mut SqliteConnection| -> Result { + // Check if exists + let existing: Option = inflation_rates::table + .filter(inflation_rates::country_code.eq(&country_code_upper)) + .filter(inflation_rates::year.eq(rate_owned.year)) + .first(conn) + .optional() + .map_err(Error::from)?; + + if let Some(existing_rate) = existing { + // Update + diesel::update(inflation_rates::table.find(&existing_rate.id)) + .set(( + inflation_rates::rate.eq(rate_owned.rate), + inflation_rates::reference_date.eq(&rate_owned.reference_date), + inflation_rates::data_source.eq(&rate_owned.data_source), + inflation_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + )) + .get_result(conn) + .map_err(Error::from) + } else { + // Insert + let new_rate_record = ( + inflation_rates::id.eq(Uuid::new_v4().to_string()), + inflation_rates::country_code.eq(&country_code_upper), + inflation_rates::year.eq(rate_owned.year), + inflation_rates::rate.eq(rate_owned.rate), + inflation_rates::reference_date.eq(&rate_owned.reference_date), + inflation_rates::data_source.eq(&rate_owned.data_source), + inflation_rates::created_at.eq(chrono::Utc::now().naive_utc()), + inflation_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + ); + + diesel::insert_into(inflation_rates::table) + .values(new_rate_record) + .get_result(conn) + .map_err(Error::from) + } + }, + ) + .await + } +} diff --git a/src-core/src/inflation/inflation_service.rs b/src-core/src/inflation/inflation_service.rs new file mode 100644 index 000000000..beb279937 --- /dev/null +++ b/src-core/src/inflation/inflation_service.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use log::{debug, warn}; +use reqwest::Client; + +use super::inflation_model::{ + InflationAdjustedValue, InflationRate, NewInflationRate, WorldBankResponse, +}; +use super::inflation_traits::{InflationRateRepositoryTrait, InflationRateServiceTrait}; +use crate::errors::{Error, Result}; + +pub struct InflationRateService { + repository: Arc, + http_client: Client, +} + +impl InflationRateService { + pub fn new(repository: Arc) -> Self { + InflationRateService { + repository, + http_client: Client::new(), + } + } +} + +#[async_trait] +impl InflationRateServiceTrait for InflationRateService { + fn get_inflation_rates(&self) -> Result> { + self.repository.get_inflation_rates() + } + + fn get_inflation_rates_by_country(&self, country_code: &str) -> Result> { + self.repository.get_inflation_rates_by_country(country_code) + } + + async fn create_inflation_rate(&self, new_rate: NewInflationRate) -> Result { + self.repository.create_inflation_rate(new_rate).await + } + + async fn update_inflation_rate( + &self, + id: &str, + updated_rate: NewInflationRate, + ) -> Result { + self.repository + .update_inflation_rate(id, updated_rate) + .await + } + + async fn delete_inflation_rate(&self, id: &str) -> Result<()> { + self.repository.delete_inflation_rate(id).await + } + + async fn fetch_from_world_bank(&self, country_code: &str) -> Result> { + debug!( + "Fetching inflation rates from World Bank for country: {}", + country_code + ); + + // World Bank API endpoint for CPI inflation (annual %) + // FP.CPI.TOTL.ZG = Inflation, consumer prices (annual %) + let url = format!( + "https://api.worldbank.org/v2/country/{}/indicator/FP.CPI.TOTL.ZG?format=json&per_page=50", + country_code.to_uppercase() + ); + + let response = self + .http_client + .get(&url) + .send() + .await + .map_err(|e| Error::Unexpected(format!("Failed to fetch from World Bank: {}", e)))?; + + if !response.status().is_success() { + return Err(Error::Unexpected(format!( + "World Bank API returned status: {}", + response.status() + ))); + } + + let data: WorldBankResponse = response + .json() + .await + .map_err(|e| Error::Unexpected(format!("Failed to parse World Bank response: {}", e)))?; + + let mut results = Vec::new(); + + if let Some(data_points) = data.1 { + for point in data_points { + if let Some(rate) = point.value { + let year: i32 = point.date.parse().unwrap_or(0); + if year > 0 { + let new_rate = NewInflationRate { + id: None, + country_code: country_code.to_uppercase(), + year, + rate, + reference_date: Some("12-31".to_string()), + data_source: "world_bank".to_string(), + }; + + match self.repository.upsert_inflation_rate(new_rate).await { + Ok(saved) => results.push(saved), + Err(e) => { + warn!("Failed to save inflation rate for year {}: {}", year, e); + } + } + } + } + } + } + + debug!( + "Fetched and saved {} inflation rates for {}", + results.len(), + country_code + ); + Ok(results) + } + + fn calculate_inflation_adjusted_values( + &self, + nominal_values: Vec<(i32, f64, String)>, + country_code: &str, + base_year: i32, + ) -> Result> { + let rates = self.repository.get_inflation_rates_by_country(country_code)?; + let rates_map: HashMap = rates.into_iter().map(|r| (r.year, r.rate)).collect(); + + let mut results = Vec::new(); + + for (year, nominal_value, reference_date) in nominal_values { + // Calculate cumulative inflation from base year to this year + let cumulative_inflation = if year == base_year { + 0.0 + } else { + let (start, end) = if year > base_year { + (base_year, year) + } else { + (year, base_year) + }; + + let mut cumulative = 1.0; + for y in start..end { + if let Some(&rate) = rates_map.get(&y) { + cumulative *= 1.0 + (rate / 100.0); + } + } + + if year > base_year { + // Years after base year: positive cumulative inflation + (cumulative - 1.0) * 100.0 + } else { + // Years before base year: negative (deflation relative to base) + (1.0 - (1.0 / cumulative)) * 100.0 + } + }; + + // Adjust value: express in base year's purchasing power + let real_value = if year == base_year { + nominal_value + } else if year > base_year { + // For years after base year, divide by inflation factor + nominal_value / (1.0 + cumulative_inflation / 100.0) + } else { + // For years before base year, multiply by inverse inflation factor + nominal_value * (1.0 + (-cumulative_inflation) / 100.0) + }; + + results.push(InflationAdjustedValue { + year, + nominal_value, + real_value, + inflation_rate: rates_map.get(&year).copied(), + cumulative_inflation, + reference_date, + }); + } + + // Sort by year + results.sort_by_key(|v| v.year); + + Ok(results) + } +} diff --git a/src-core/src/inflation/inflation_traits.rs b/src-core/src/inflation/inflation_traits.rs new file mode 100644 index 000000000..be9afe9df --- /dev/null +++ b/src-core/src/inflation/inflation_traits.rs @@ -0,0 +1,42 @@ +use super::inflation_model::{InflationAdjustedValue, InflationRate, NewInflationRate}; +use crate::errors::Result; +use async_trait::async_trait; + +/// Trait defining the contract for Inflation Rate repository operations. +#[async_trait] +pub trait InflationRateRepositoryTrait: Send + Sync { + fn get_inflation_rate(&self, id: &str) -> Result; + fn get_inflation_rates(&self) -> Result>; + fn get_inflation_rates_by_country(&self, country_code: &str) -> Result>; + fn get_inflation_rate_for_year(&self, country_code: &str, year: i32) + -> Result>; + async fn create_inflation_rate(&self, new_rate: NewInflationRate) -> Result; + async fn update_inflation_rate( + &self, + id: &str, + updated_rate: NewInflationRate, + ) -> Result; + async fn delete_inflation_rate(&self, id: &str) -> Result<()>; + async fn upsert_inflation_rate(&self, rate: NewInflationRate) -> Result; +} + +/// Trait defining the contract for Inflation Rate service operations. +#[async_trait] +pub trait InflationRateServiceTrait: Send + Sync { + fn get_inflation_rates(&self) -> Result>; + fn get_inflation_rates_by_country(&self, country_code: &str) -> Result>; + async fn create_inflation_rate(&self, new_rate: NewInflationRate) -> Result; + async fn update_inflation_rate( + &self, + id: &str, + updated_rate: NewInflationRate, + ) -> Result; + async fn delete_inflation_rate(&self, id: &str) -> Result<()>; + async fn fetch_from_world_bank(&self, country_code: &str) -> Result>; + fn calculate_inflation_adjusted_values( + &self, + nominal_values: Vec<(i32, f64, String)>, + country_code: &str, + base_year: i32, + ) -> Result>; +} diff --git a/src-core/src/inflation/mod.rs b/src-core/src/inflation/mod.rs new file mode 100644 index 000000000..14f2c2ca6 --- /dev/null +++ b/src-core/src/inflation/mod.rs @@ -0,0 +1,9 @@ +mod inflation_model; +mod inflation_repository; +mod inflation_service; +mod inflation_traits; + +pub use inflation_model::{InflationAdjustedValue, InflationRate, NewInflationRate}; +pub use inflation_repository::InflationRateRepository; +pub use inflation_service::InflationRateService; +pub use inflation_traits::{InflationRateRepositoryTrait, InflationRateServiceTrait}; diff --git a/src-core/src/lib.rs b/src-core/src/lib.rs index 14db4380b..4f8ace206 100644 --- a/src-core/src/lib.rs +++ b/src-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod db; pub mod errors; pub mod fx; pub mod goals; +pub mod inflation; pub mod limits; pub mod market_data; pub mod portfolio; diff --git a/src-core/src/schema.rs b/src-core/src/schema.rs index e9d056637..d15c9e1e3 100644 --- a/src-core/src/schema.rs +++ b/src-core/src/schema.rs @@ -142,6 +142,19 @@ diesel::table! { } } +diesel::table! { + inflation_rates (id) { + id -> Text, + country_code -> Text, + year -> Integer, + rate -> Double, + reference_date -> Nullable, + data_source -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + diesel::table! { market_data_providers (id) { id -> Text, @@ -198,6 +211,7 @@ diesel::allow_tables_to_appear_in_same_query!( goals, goals_allocation, holdings_snapshots, + inflation_rates, market_data_providers, platforms, quotes, diff --git a/src-tauri/src/commands/inflation.rs b/src-tauri/src/commands/inflation.rs new file mode 100644 index 000000000..5a159aa97 --- /dev/null +++ b/src-tauri/src/commands/inflation.rs @@ -0,0 +1,139 @@ +use std::sync::Arc; + +use crate::{ + context::ServiceContext, + events::{emit_resource_changed, ResourceEventPayload}, +}; +use log::debug; +use serde_json::json; +use tauri::{AppHandle, State}; +use wealthfolio_core::inflation::{InflationAdjustedValue, InflationRate, NewInflationRate}; + +#[tauri::command] +pub async fn get_inflation_rates( + state: State<'_, Arc>, +) -> Result, String> { + debug!("Fetching inflation rates..."); + state + .inflation_service() + .get_inflation_rates() + .map_err(|e| format!("Failed to load inflation rates: {}", e)) +} + +#[tauri::command] +pub async fn get_inflation_rates_by_country( + country_code: String, + state: State<'_, Arc>, +) -> Result, String> { + debug!("Fetching inflation rates for country: {}", country_code); + state + .inflation_service() + .get_inflation_rates_by_country(&country_code) + .map_err(|e| format!("Failed to load inflation rates: {}", e)) +} + +#[tauri::command] +pub async fn create_inflation_rate( + new_rate: NewInflationRate, + state: State<'_, Arc>, + handle: AppHandle, +) -> Result { + debug!("Creating new inflation rate..."); + let rate = state + .inflation_service() + .create_inflation_rate(new_rate) + .await + .map_err(|e| format!("Failed to create inflation rate: {}", e))?; + + emit_resource_changed( + &handle, + ResourceEventPayload::new("inflation_rate", "created", json!({ "rate_id": rate.id })), + ); + + Ok(rate) +} + +#[tauri::command] +pub async fn update_inflation_rate( + id: String, + updated_rate: NewInflationRate, + state: State<'_, Arc>, + handle: AppHandle, +) -> Result { + debug!("Updating inflation rate..."); + let rate = state + .inflation_service() + .update_inflation_rate(&id, updated_rate) + .await + .map_err(|e| format!("Failed to update inflation rate: {}", e))?; + + emit_resource_changed( + &handle, + ResourceEventPayload::new("inflation_rate", "updated", json!({ "rate_id": id })), + ); + + Ok(rate) +} + +#[tauri::command] +pub async fn delete_inflation_rate( + id: String, + state: State<'_, Arc>, + handle: AppHandle, +) -> Result<(), String> { + debug!("Deleting inflation rate..."); + state + .inflation_service() + .delete_inflation_rate(&id) + .await + .map_err(|e| format!("Failed to delete inflation rate: {}", e))?; + + emit_resource_changed( + &handle, + ResourceEventPayload::new("inflation_rate", "deleted", json!({ "rate_id": id })), + ); + + Ok(()) +} + +#[tauri::command] +pub async fn fetch_inflation_rates_from_world_bank( + country_code: String, + state: State<'_, Arc>, + handle: AppHandle, +) -> Result, String> { + debug!( + "Fetching inflation rates from World Bank for: {}", + country_code + ); + let rates = state + .inflation_service() + .fetch_from_world_bank(&country_code) + .await + .map_err(|e| format!("Failed to fetch from World Bank: {}", e))?; + + emit_resource_changed( + &handle, + ResourceEventPayload::new( + "inflation_rate", + "synced", + json!({ "country_code": country_code }), + ), + ); + + Ok(rates) +} + +#[tauri::command] +pub async fn calculate_inflation_adjusted_portfolio( + nominal_values: Vec<(i32, f64, String)>, + country_code: String, + base_year: i32, + state: State<'_, Arc>, +) -> Result, String> { + debug!("Calculating inflation-adjusted portfolio values..."); + state + .inflation_service() + .calculate_inflation_adjusted_values(nominal_values, &country_code, base_year) + .map_err(|e| format!("Failed to calculate adjusted values: {}", e)) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f1874740b..28595a9be 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod addon; pub mod asset; pub mod error; pub mod goal; +pub mod inflation; pub mod limits; pub mod market_data; pub mod platform; diff --git a/src-tauri/src/context/providers.rs b/src-tauri/src/context/providers.rs index 41e7ded3a..36748c9a3 100644 --- a/src-tauri/src/context/providers.rs +++ b/src-tauri/src/context/providers.rs @@ -7,6 +7,7 @@ use wealthfolio_core::{ db::{self, write_actor}, fx::{FxRepository, FxService, FxServiceTrait}, goals::{GoalRepository, GoalService}, + inflation::{InflationRateRepository, InflationRateService}, limits::{ContributionLimitRepository, ContributionLimitService}, market_data::{MarketDataRepository, MarketDataService, MarketDataServiceTrait}, portfolio::{ @@ -43,6 +44,10 @@ pub async fn initialize_context( pool.clone(), writer.clone(), )); + let inflation_repository = Arc::new(InflationRateRepository::new( + pool.clone(), + writer.clone(), + )); let fx_repository = Arc::new(FxRepository::new(pool.clone(), writer.clone())); let snapshot_repository = Arc::new(SnapshotRepository::new(pool.clone(), writer.clone())); let valuation_repository = Arc::new(ValuationRepository::new(pool.clone(), writer.clone())); @@ -95,6 +100,8 @@ pub async fn initialize_context( activity_repository.clone(), )); + let inflation_service = Arc::new(InflationRateService::new(inflation_repository.clone())); + let income_service = Arc::new(IncomeService::new( fx_service.clone(), activity_repository.clone(), @@ -144,6 +151,7 @@ pub async fn initialize_context( goal_service, market_data_service, limits_service, + inflation_service, fx_service, performance_service, income_service, diff --git a/src-tauri/src/context/registry.rs b/src-tauri/src/context/registry.rs index ddae5faa7..4ddba591c 100644 --- a/src-tauri/src/context/registry.rs +++ b/src-tauri/src/context/registry.rs @@ -1,6 +1,7 @@ use std::sync::{Arc, RwLock}; use wealthfolio_core::{ - self, accounts, activities, assets, fx, goals, limits, market_data, portfolio, settings, + self, accounts, activities, assets, fx, goals, inflation, limits, market_data, portfolio, + settings, }; pub struct ServiceContext { pub base_currency: Arc>, @@ -14,6 +15,7 @@ pub struct ServiceContext { pub asset_service: Arc, pub market_data_service: Arc, pub limits_service: Arc, + pub inflation_service: Arc, pub fx_service: Arc, pub performance_service: Arc, pub income_service: Arc, @@ -59,6 +61,10 @@ impl ServiceContext { Arc::clone(&self.limits_service) } + pub fn inflation_service(&self) -> Arc { + Arc::clone(&self.inflation_service) + } + pub fn fx_service(&self) -> Arc { Arc::clone(&self.fx_service) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b102f8543..8e670cdc6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,14 @@ pub fn run() { commands::limits::update_contribution_limit, commands::limits::delete_contribution_limit, commands::limits::calculate_deposits_for_contribution_limit, + // Inflation rate commands + commands::inflation::get_inflation_rates, + commands::inflation::get_inflation_rates_by_country, + commands::inflation::create_inflation_rate, + commands::inflation::update_inflation_rate, + commands::inflation::delete_inflation_rate, + commands::inflation::fetch_inflation_rates_from_world_bank, + commands::inflation::calculate_inflation_adjusted_portfolio, // Utility commands commands::utilities::get_app_info, commands::utilities::check_for_updates, diff --git a/src/commands/inflation-rates.ts b/src/commands/inflation-rates.ts new file mode 100644 index 000000000..89f8d0b59 --- /dev/null +++ b/src/commands/inflation-rates.ts @@ -0,0 +1,131 @@ +import { InflationRate, NewInflationRate, InflationAdjustedValue } from "@/lib/types"; +import { getRunEnv, RUN_ENV, invokeTauri, invokeWeb, logger } from "@/adapters"; + +export const getInflationRates = async (): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("get_inflation_rates"); + case RUN_ENV.WEB: + return invokeWeb("get_inflation_rates"); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error fetching inflation rates."); + throw error; + } +}; + +export const getInflationRatesByCountry = async (countryCode: string): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("get_inflation_rates_by_country", { countryCode }); + case RUN_ENV.WEB: + return invokeWeb("get_inflation_rates_by_country", { countryCode }); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error fetching inflation rates by country."); + throw error; + } +}; + +export const createInflationRate = async (newRate: NewInflationRate): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("create_inflation_rate", { newRate }); + case RUN_ENV.WEB: + return invokeWeb("create_inflation_rate", { newRate }); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error creating inflation rate."); + throw error; + } +}; + +export const updateInflationRate = async ( + id: string, + updatedRate: NewInflationRate, +): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("update_inflation_rate", { id, updatedRate }); + case RUN_ENV.WEB: + return invokeWeb("update_inflation_rate", { id, updatedRate }); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error updating inflation rate."); + throw error; + } +}; + +export const deleteInflationRate = async (id: string): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("delete_inflation_rate", { id }); + case RUN_ENV.WEB: + return invokeWeb("delete_inflation_rate", { id }); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error deleting inflation rate."); + throw error; + } +}; + +export const fetchInflationRatesFromWorldBank = async ( + countryCode: string, +): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("fetch_inflation_rates_from_world_bank", { countryCode }); + case RUN_ENV.WEB: + return invokeWeb("fetch_inflation_rates_from_world_bank", { countryCode }); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error fetching from World Bank."); + throw error; + } +}; + +export const calculateInflationAdjustedPortfolio = async ( + nominalValues: [number, number, string][], + countryCode: string, + baseYear: number, +): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri("calculate_inflation_adjusted_portfolio", { + nominalValues, + countryCode, + baseYear, + }); + case RUN_ENV.WEB: + return invokeWeb("calculate_inflation_adjusted_portfolio", { + nominalValues, + countryCode, + baseYear, + }); + default: + throw new Error("Unsupported"); + } + } catch (error) { + logger.error("Error calculating inflation-adjusted values."); + throw error; + } +}; diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 22d57b264..2955bf4ee 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -29,6 +29,10 @@ export const QueryKeys = { CONTRIBUTION_LIMITS: "contributionLimits", CONTRIBUTION_LIMIT_PROGRESS: "contributionLimitProgress", + INFLATION_RATES: "inflationRates", + INFLATION_RATES_BY_COUNTRY: "inflationRatesByCountry", + INFLATION_ADJUSTED_PORTFOLIO: "inflationAdjustedPortfolio", + ASSET_DATA: "asset_data", ASSETS: "assets", LATEST_QUOTES: "latest_quotes", diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 0252822a9..16e7ad81c 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -196,3 +196,15 @@ export const newContributionLimitSchema = z.object({ startDate: z.union([z.date(), z.string().datetime(), z.null()]).optional(), endDate: z.union([z.date(), z.string().datetime(), z.null()]).optional(), }); + +export const newInflationRateSchema = z.object({ + id: z.string().optional(), + countryCode: z.string().min(2, "Country code is required").max(3), + year: z.number().int().min(1900, "Invalid year").max(2100), + rate: z.coerce.number({ + required_error: "Please enter a valid inflation rate.", + invalid_type_error: "Rate must be a number.", + }), + referenceDate: z.string().nullable().optional(), + dataSource: z.string(), +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index 5b283a046..c580c5c42 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -476,6 +476,28 @@ export interface ContributionLimit { export type NewContributionLimit = Omit; +export interface InflationRate { + id: string; + countryCode: string; + year: number; + rate: number; + referenceDate?: string | null; + dataSource: string; + createdAt?: string; + updatedAt?: string; +} + +export type NewInflationRate = Omit; + +export interface InflationAdjustedValue { + year: number; + nominalValue: number; + realValue: number; + inflationRate: number | null; + cumulativeInflation: number; + referenceDate: string; +} + export interface AccountDeposit { amount: number; currency: string; diff --git a/src/pages/insights/inflation/components/inflation-chart.tsx b/src/pages/insights/inflation/components/inflation-chart.tsx new file mode 100644 index 000000000..d9b4f7f26 --- /dev/null +++ b/src/pages/insights/inflation/components/inflation-chart.tsx @@ -0,0 +1,117 @@ +import { + ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { useBalancePrivacy } from "@wealthfolio/ui"; +import { formatAmount, Skeleton } from "@wealthfolio/ui"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +interface InflationChartData { + year: string; + nominal: number; + real: number; + inflationRate: number | null; + cumulativeInflation: number; +} + +interface InflationChartProps { + data: InflationChartData[]; + isLoading?: boolean; + baseYear: number; + currency: string; +} + +export function InflationChart({ data, isLoading, baseYear, currency }: InflationChartProps) { + const { isBalanceHidden } = useBalancePrivacy(); + + const chartConfig = { + nominal: { + label: "Nominal Value", + color: "hsl(var(--chart-1))", + }, + real: { + label: `Real Value (${baseYear})`, + color: "hsl(var(--chart-2))", + }, + } satisfies ChartConfig; + + if (isLoading) { + return ; + } + + if (data.length === 0) { + return ( +
+

+ No data available. Add inflation rates in Settings to see real values. +

+
+ ); + } + + return ( + + + + + { + if (isBalanceHidden) return "****"; + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(0)}k`; + return value.toString(); + }} + /> + { + const formattedValue = isBalanceHidden + ? "****" + : formatAmount(Number(value), currency); + + const extra = + name === "real" && entry.payload.cumulativeInflation !== 0 + ? ` (${entry.payload.cumulativeInflation > 0 ? "-" : "+"}${Math.abs(entry.payload.cumulativeInflation).toFixed(1)}%)` + : ""; + + return ( + <> +
+
+ + {name === "nominal" ? "Nominal" : `Real (${baseYear})`} + + + {formattedValue} + {extra} + +
+ + ); + }} + labelFormatter={(label) => `Year ${label}`} + /> + } + /> + } /> + + + + + ); +} diff --git a/src/pages/insights/inflation/inflation-page.tsx b/src/pages/insights/inflation/inflation-page.tsx new file mode 100644 index 000000000..af4029400 --- /dev/null +++ b/src/pages/insights/inflation/inflation-page.tsx @@ -0,0 +1,269 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useValuationHistory } from "@/hooks/use-valuation-history"; +import { useSettings } from "@/hooks/use-settings"; +import { + useInflationRatesByCountry, + useInflationAdjustedPortfolio, +} from "@/pages/settings/inflation-rates/use-inflation-rate-mutations"; +import { useEffect, useMemo, useState } from "react"; +import { InflationChart } from "./components/inflation-chart"; +import { parseISO } from "date-fns"; +import { Button, EmptyPlaceholder, Icons, Skeleton } from "@wealthfolio/ui"; +import { Link } from "react-router-dom"; + +const CURRENCY_TO_COUNTRY: Record = { + USD: "US", + EUR: "FR", + GBP: "GB", + CAD: "CA", + AUD: "AU", + JPY: "JP", + CHF: "CH", + CNY: "CN", + INR: "IN", + BRL: "BR", + MXN: "MX", + SEK: "SE", + NOK: "NO", + DKK: "DK", + PLN: "PL", + CZK: "CZ", + HUF: "HU", + NZD: "NZ", + SGD: "SG", + HKD: "HK", +}; + +const COUNTRIES = [ + { code: "US", name: "United States" }, + { code: "GB", name: "United Kingdom" }, + { code: "DE", name: "Germany" }, + { code: "FR", name: "France" }, + { code: "CA", name: "Canada" }, + { code: "JP", name: "Japan" }, + { code: "AU", name: "Australia" }, + { code: "CH", name: "Switzerland" }, +]; + +export default function InflationPage() { + const currentYear = new Date().getFullYear(); + const { data: settings, isLoading: isSettingsLoading } = useSettings(); + + // Auto-detect country from base currency + const defaultCountry = settings?.baseCurrency + ? CURRENCY_TO_COUNTRY[settings.baseCurrency] || "US" + : "US"; + + const [baseYear, setBaseYear] = useState(null); + const [countryCode, setCountryCode] = useState(undefined); + const [referenceMonth] = useState(12); // December + const [referenceDay] = useState(31); + + // Use detected country if not manually set + const effectiveCountryCode = countryCode || defaultCountry; + + // Fetch all valuation history + const { valuationHistory, isLoading: isValuationLoading } = useValuationHistory(undefined); + + // Fetch inflation rates + const { data: inflationRates, isLoading: isRatesLoading } = + useInflationRatesByCountry(effectiveCountryCode); + + // Extract year-end values from valuation history + const yearEndValues = useMemo(() => { + if (!valuationHistory || valuationHistory.length === 0) return []; + + const yearValues: Map = new Map(); + + valuationHistory.forEach((v) => { + const date = parseISO(v.valuationDate); + const year = date.getFullYear(); + + const referenceDate = `${year}-${String(referenceMonth).padStart(2, "0")}-${String(referenceDay).padStart(2, "0")}`; + + if (!yearValues.has(year) || v.valuationDate <= referenceDate) { + if (v.valuationDate <= referenceDate) { + yearValues.set(year, { value: v.totalValue, date: v.valuationDate }); + } + } + }); + + return Array.from(yearValues.entries()) + .map(([year, { value, date }]) => [year, value, date] as [number, number, string]) + .sort((a, b) => a[0] - b[0]); + }, [valuationHistory, referenceMonth, referenceDay]); + + // Set default base year to first year of portfolio data (only once when data loads) + useEffect(() => { + if (baseYear === null && yearEndValues.length > 0) { + setBaseYear(yearEndValues[0][0]); // First year of portfolio data + } + }, [yearEndValues, baseYear]); + + // Effective base year (fallback to current year if not set) + const effectiveBaseYear = baseYear ?? currentYear; + + // Calculate inflation-adjusted values + const { data: adjustedValues, isLoading: isAdjustedLoading } = useInflationAdjustedPortfolio( + yearEndValues, + effectiveCountryCode, + effectiveBaseYear, + ); + + // Prepare chart data + const chartData = useMemo(() => { + if (!adjustedValues) return []; + + return adjustedValues.map((v) => ({ + year: v.year.toString(), + nominal: v.nominalValue, + real: v.realValue, + inflationRate: v.inflationRate, + cumulativeInflation: v.cumulativeInflation, + })); + }, [adjustedValues]); + + // Generate year options from available data + const yearOptions = useMemo(() => { + const years = yearEndValues.map(([year]) => year); + return years.length > 0 ? years : [currentYear]; + }, [yearEndValues, currentYear]); + + const isLoading = isValuationLoading || isRatesLoading || isAdjustedLoading || isSettingsLoading; + const hasNoInflationData = !inflationRates || inflationRates.length === 0; + const hasNoPortfolioData = !valuationHistory || valuationHistory.length === 0; + + if (isSettingsLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Settings Card */} + + + Inflation Settings + Configure how inflation adjustments are calculated + + +
+ + +
+ +
+ + +
+ +
+ +
+ December 31st +
+
+
+
+ + {/* Chart Card */} + + + Portfolio Value: Nominal vs Real + + Comparing nominal portfolio value with inflation-adjusted value (base year: {effectiveBaseYear}) + + + + {hasNoPortfolioData ? ( + + + No Portfolio Data + + Start tracking your portfolio to see inflation-adjusted values. + + + ) : hasNoInflationData ? ( + + + No Inflation Data + + Add inflation rates for {effectiveCountryCode} to see real values. + + + + + + ) : ( + + )} + + + + {/* Info Card */} + {!hasNoInflationData && !hasNoPortfolioData && ( + + +
+

+ How it works: The real value shows what your portfolio would be + worth in {effectiveBaseYear} purchasing power, adjusted for cumulative inflation. +

+

+ Using {inflationRates?.length || 0} inflation rate records for{" "} + {effectiveCountryCode}.{" "} + + Manage rates + +

+
+
+
+ )} +
+ ); +} diff --git a/src/pages/insights/portfolio-insights.tsx b/src/pages/insights/portfolio-insights.tsx index c787fb0f4..9c9c52bdc 100644 --- a/src/pages/insights/portfolio-insights.tsx +++ b/src/pages/insights/portfolio-insights.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import IncomePage from "@/pages/income/income-page"; import PerformancePage from "@/pages/performance/performance-page"; +import InflationPage from "@/pages/insights/inflation/inflation-page"; import { Icons } from "@wealthfolio/ui"; import { Suspense, useMemo } from "react"; import HoldingsInsightsPage from "../holdings/holdings-insights-page"; @@ -64,6 +65,16 @@ export default function PortfolioInsightsPage() { ), }, + { + value: "inflation", + label: "Inflation", + icon: Icons.TrendingDown, + content: ( + }> + + + ), + }, ], [], ); diff --git a/src/pages/settings/inflation-rates/components/inflation-rate-edit-modal.tsx b/src/pages/settings/inflation-rates/components/inflation-rate-edit-modal.tsx new file mode 100644 index 000000000..0a0938b1b --- /dev/null +++ b/src/pages/settings/inflation-rates/components/inflation-rate-edit-modal.tsx @@ -0,0 +1,40 @@ +import { Dialog, DialogContent } from "@wealthfolio/ui"; +import { InflationRateForm } from "./inflation-rate-form"; +import type { InflationRate } from "@/lib/types"; + +interface InflationRateEditModalProps { + rate: InflationRate | null; + defaultCountryCode?: string; + open: boolean; + onClose: () => void; +} + +export function InflationRateEditModal({ + rate, + defaultCountryCode, + open, + onClose, +}: InflationRateEditModalProps) { + return ( + + + + + + ); +} diff --git a/src/pages/settings/inflation-rates/components/inflation-rate-form.tsx b/src/pages/settings/inflation-rates/components/inflation-rate-form.tsx new file mode 100644 index 000000000..86d7cc2de --- /dev/null +++ b/src/pages/settings/inflation-rates/components/inflation-rate-form.tsx @@ -0,0 +1,193 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { Button } from "@/components/ui/button"; +import { Icons } from "@/components/ui/icons"; +import { Card, CardContent } from "@/components/ui/card"; + +import { + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, + Input, +} from "@wealthfolio/ui"; + +import { newInflationRateSchema } from "@/lib/schemas"; +import { useInflationRateMutations } from "../use-inflation-rate-mutations"; + +type NewInflationRate = z.infer; + +interface InflationRateFormProps { + defaultValues?: NewInflationRate; + defaultCountryCode?: string; + onSuccess?: () => void; +} + +export function InflationRateForm({ + defaultValues, + defaultCountryCode = "US", + onSuccess = () => {}, +}: InflationRateFormProps) { + const { addInflationRateMutation, updateInflationRateMutation } = useInflationRateMutations(); + + const form = useForm({ + resolver: zodResolver(newInflationRateSchema), + defaultValues: { + countryCode: defaultCountryCode, + year: new Date().getFullYear(), + rate: 0, + referenceDate: "12-31", + dataSource: "manual", + ...defaultValues, + }, + }); + + function onSubmit(data: NewInflationRate) { + const { id, ...rest } = data; + + if (id) { + return updateInflationRateMutation.mutate({ id, updatedRate: rest }, { onSuccess }); + } + return addInflationRateMutation.mutate(rest, { onSuccess }); + } + + return ( +
+ + + + {defaultValues?.id ? "Update Inflation Rate" : "Add Inflation Rate"} + + + {defaultValues?.id + ? "Update inflation rate information" + : "Add a new inflation rate manually."} + + + +
+ + +
+
+ ( + + Country Code + + + + + ISO 3166-1 alpha-2 country code + + + + )} + /> + + ( + + Year + + { + const numValue = + e.target.value === "" ? undefined : Number(e.target.value); + field.onChange(numValue); + }} + className="h-11 text-base" + /> + + + + )} + /> +
+ + ( + + Inflation Rate (%) + + { + const numValue = + e.target.value === "" ? undefined : Number(e.target.value); + field.onChange(numValue); + }} + className="h-11 text-base" + /> + + + Annual consumer price inflation rate as percentage + + + + )} + /> +
+
+
+
+ + +
+ + + + +
+
+
+ + ); +} diff --git a/src/pages/settings/inflation-rates/components/inflation-rate-item.tsx b/src/pages/settings/inflation-rates/components/inflation-rate-item.tsx new file mode 100644 index 000000000..8b14a280d --- /dev/null +++ b/src/pages/settings/inflation-rates/components/inflation-rate-item.tsx @@ -0,0 +1,69 @@ +import { InflationRate } from "@/lib/types"; +import { + Button, + Card, + CardContent, + Icons, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Badge, +} from "@wealthfolio/ui"; + +interface InflationRateItemProps { + rate: InflationRate; + onEdit: (rate: InflationRate) => void; + onDelete: (rate: InflationRate) => void; +} + +export function InflationRateItem({ rate, onEdit, onDelete }: InflationRateItemProps) { + const isPositive = rate.rate > 0; + const isNegative = rate.rate < 0; + + return ( + + +
+
+ {rate.year} + {rate.countryCode} +
+
+ + {rate.rate > 0 ? "+" : ""} + {rate.rate.toFixed(2)}% + + + {rate.dataSource === "world_bank" ? "World Bank" : "Manual"} + +
+
+ + + + + + + onEdit(rate)}> + + Edit + + onDelete(rate)} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+
+ ); +} diff --git a/src/pages/settings/inflation-rates/inflation-rates-page.tsx b/src/pages/settings/inflation-rates/inflation-rates-page.tsx new file mode 100644 index 000000000..d09210ff9 --- /dev/null +++ b/src/pages/settings/inflation-rates/inflation-rates-page.tsx @@ -0,0 +1,243 @@ +import { getInflationRates } from "@/commands/inflation-rates"; +import { QueryKeys } from "@/lib/query-keys"; +import type { InflationRate } from "@/lib/types"; +import { useQuery } from "@tanstack/react-query"; +import { + Button, + EmptyPlaceholder, + Icons, + Separator, + Skeleton, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@wealthfolio/ui"; +import { useState, useMemo } from "react"; +import { SettingsHeader } from "../settings-header"; +import { InflationRateEditModal } from "./components/inflation-rate-edit-modal"; +import { InflationRateItem } from "./components/inflation-rate-item"; +import { useInflationRateMutations } from "./use-inflation-rate-mutations"; + +const COUNTRIES = [ + { code: "US", name: "United States" }, + { code: "GB", name: "United Kingdom" }, + { code: "DE", name: "Germany" }, + { code: "FR", name: "France" }, + { code: "CA", name: "Canada" }, + { code: "JP", name: "Japan" }, + { code: "AU", name: "Australia" }, + { code: "CH", name: "Switzerland" }, + { code: "IT", name: "Italy" }, + { code: "ES", name: "Spain" }, + { code: "NL", name: "Netherlands" }, + { code: "BE", name: "Belgium" }, + { code: "AT", name: "Austria" }, + { code: "SE", name: "Sweden" }, + { code: "NO", name: "Norway" }, + { code: "DK", name: "Denmark" }, + { code: "FI", name: "Finland" }, + { code: "IE", name: "Ireland" }, + { code: "PT", name: "Portugal" }, + { code: "PL", name: "Poland" }, +]; + +const SettingsInflationRatesPage = () => { + const [visibleModal, setVisibleModal] = useState(false); + const [selectedRate, setSelectedRate] = useState(null); + const [countryCode, setCountryCode] = useState("US"); + const [showPreviousYears, setShowPreviousYears] = useState(false); + + const { data: allRates, isLoading } = useQuery({ + queryKey: [QueryKeys.INFLATION_RATES], + queryFn: getInflationRates, + }); + + const { deleteInflationRateMutation, fetchFromWorldBankMutation } = useInflationRateMutations(); + + const rates = useMemo(() => { + if (!allRates) return []; + return allRates.filter((r) => r.countryCode.toUpperCase() === countryCode.toUpperCase()); + }, [allRates, countryCode]); + + const handleAddRate = () => { + setSelectedRate(null); + setVisibleModal(true); + }; + + const handleEditRate = (rate: InflationRate) => { + setSelectedRate(rate); + setVisibleModal(true); + }; + + const handleDeleteRate = (rate: InflationRate) => { + deleteInflationRateMutation.mutate(rate.id); + }; + + const handleFetchFromWorldBank = () => { + fetchFromWorldBankMutation.mutate(countryCode); + }; + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + const currentYear = new Date().getFullYear(); + const currentYearRates = rates.filter((rate) => rate.year === currentYear); + const previousYearsRates = rates + .filter((rate) => rate.year < currentYear) + .sort((a, b) => b.year - a.year); + + return ( + <> +
+ +
+ + + +
+
+ + +
+
+ Country: + +
+ +
+ +
+

+ Current Year ({currentYear}) +

+ {currentYearRates.length ? ( +
+ {currentYearRates.map((rate) => ( + + ))} +
+ ) : ( + + + + No inflation rate for {currentYear} ({countryCode}) + + + Add an inflation rate manually or fetch historical data from World Bank. + +
+ + +
+
+ )} + + {previousYearsRates.length > 0 && ( +
+
+ + + +
+ + {showPreviousYears && ( +
+

Previous Years

+
+ {previousYearsRates.map((rate) => ( + + ))} +
+
+ )} +
+ )} +
+
+ setVisibleModal(false)} + /> + + ); +}; + +export default SettingsInflationRatesPage; diff --git a/src/pages/settings/inflation-rates/use-inflation-rate-mutations.ts b/src/pages/settings/inflation-rates/use-inflation-rate-mutations.ts new file mode 100644 index 000000000..6bbcb3df5 --- /dev/null +++ b/src/pages/settings/inflation-rates/use-inflation-rate-mutations.ts @@ -0,0 +1,120 @@ +import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; +import { + getInflationRates, + getInflationRatesByCountry, + createInflationRate, + updateInflationRate, + deleteInflationRate, + fetchInflationRatesFromWorldBank, + calculateInflationAdjustedPortfolio, +} from "@/commands/inflation-rates"; +import { QueryKeys } from "@/lib/query-keys"; +import { toast } from "@/components/ui/use-toast"; +import { InflationRate, NewInflationRate, InflationAdjustedValue } from "@/lib/types"; +import { logger } from "@/adapters"; + +export const useInflationRates = () => { + return useQuery({ + queryKey: [QueryKeys.INFLATION_RATES], + queryFn: getInflationRates, + }); +}; + +export const useInflationRatesByCountry = (countryCode: string) => { + return useQuery({ + queryKey: [QueryKeys.INFLATION_RATES_BY_COUNTRY, countryCode], + queryFn: () => getInflationRatesByCountry(countryCode), + enabled: !!countryCode, + }); +}; + +export const useInflationRateMutations = () => { + const queryClient = useQueryClient(); + + const handleSuccess = (message: string) => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.INFLATION_RATES] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.INFLATION_RATES_BY_COUNTRY] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.INFLATION_ADJUSTED_PORTFOLIO] }); + toast({ + description: message, + variant: "success", + }); + }; + + const handleError = (action: string) => { + toast({ + title: "Uh oh! Something went wrong.", + description: `There was a problem ${action}.`, + variant: "destructive", + }); + }; + + const addInflationRateMutation = useMutation({ + mutationFn: createInflationRate, + onSuccess: () => handleSuccess("Inflation rate added successfully."), + onError: (e) => { + logger.error(`Error adding inflation rate: ${String(e)}`); + handleError("adding this inflation rate"); + }, + }); + + const updateInflationRateMutation = useMutation({ + mutationFn: (params: { id: string; updatedRate: NewInflationRate }) => + updateInflationRate(params.id, params.updatedRate), + onSuccess: () => handleSuccess("Inflation rate updated successfully."), + onError: (e) => { + logger.error(`Error updating inflation rate: ${String(e)}`); + handleError("updating this inflation rate"); + }, + }); + + const deleteInflationRateMutation = useMutation({ + mutationFn: deleteInflationRate, + onSuccess: () => handleSuccess("Inflation rate deleted successfully."), + onError: (e) => { + logger.error(`Error deleting inflation rate: ${String(e)}`); + handleError("deleting this inflation rate"); + }, + }); + + const fetchFromWorldBankMutation = useMutation({ + mutationFn: fetchInflationRatesFromWorldBank, + onSuccess: (rates) => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.INFLATION_RATES] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.INFLATION_RATES_BY_COUNTRY] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.INFLATION_ADJUSTED_PORTFOLIO] }); + toast({ + description: `Fetched ${rates.length} inflation rates from World Bank.`, + variant: "success", + }); + }, + onError: (e) => { + logger.error(`Error fetching from World Bank: ${String(e)}`); + handleError("fetching data from World Bank"); + }, + }); + + return { + addInflationRateMutation, + updateInflationRateMutation, + deleteInflationRateMutation, + fetchFromWorldBankMutation, + }; +}; + +export const useInflationAdjustedPortfolio = ( + nominalValues: [number, number, string][], + countryCode: string, + baseYear: number, +) => { + return useQuery({ + queryKey: [ + QueryKeys.INFLATION_ADJUSTED_PORTFOLIO, + countryCode, + baseYear, + JSON.stringify(nominalValues), + ], + queryFn: () => calculateInflationAdjustedPortfolio(nominalValues, countryCode, baseYear), + enabled: nominalValues.length > 0 && !!countryCode, + }); +}; diff --git a/src/pages/settings/settings-layout.tsx b/src/pages/settings/settings-layout.tsx index 3f3d68b31..bf928ba32 100644 --- a/src/pages/settings/settings-layout.tsx +++ b/src/pages/settings/settings-layout.tsx @@ -29,6 +29,12 @@ const sidebarNavItems = [ subtitle: "Contribution limits and rules", icon: , }, + { + title: "Inflation", + href: "inflation-rates", + subtitle: "Manage inflation rates (IPC)", + icon: , + }, { title: "Goals", href: "goals", diff --git a/src/routes.tsx b/src/routes.tsx index 83ac1bd4d..5291ae24c 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -21,6 +21,7 @@ import AssetProfilePage from "./pages/asset/asset-profile-page"; import OnboardingPage from "./pages/onboarding/onboarding-page"; import AddonSettingsPage from "./pages/settings/addons/addon-settings"; import ContributionLimitPage from "./pages/settings/contribution-limits/contribution-limits-page"; +import InflationRatesPage from "./pages/settings/inflation-rates/inflation-rates-page"; import ExportSettingsPage from "./pages/settings/exports/exports-page"; import GeneralSettingsPage from "./pages/settings/general/general-page"; import SettingsGoalsPage from "./pages/settings/goals/goals-page"; @@ -104,6 +105,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> From e3a9828fb45264aea600ead10b5aa383ece65d4e Mon Sep 17 00:00:00 2001 From: Alexandre Trotel Date: Fri, 2 Jan 2026 16:52:23 +0100 Subject: [PATCH 2/9] fix: take closest date to december 31st --- src/pages/insights/inflation/inflation-page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/insights/inflation/inflation-page.tsx b/src/pages/insights/inflation/inflation-page.tsx index af4029400..25fdf1529 100644 --- a/src/pages/insights/inflation/inflation-page.tsx +++ b/src/pages/insights/inflation/inflation-page.tsx @@ -77,6 +77,7 @@ export default function InflationPage() { useInflationRatesByCountry(effectiveCountryCode); // Extract year-end values from valuation history + // For each year, find the valuation closest to (but not after) December 31st const yearEndValues = useMemo(() => { if (!valuationHistory || valuationHistory.length === 0) return []; @@ -88,8 +89,11 @@ export default function InflationPage() { const referenceDate = `${year}-${String(referenceMonth).padStart(2, "0")}-${String(referenceDay).padStart(2, "0")}`; - if (!yearValues.has(year) || v.valuationDate <= referenceDate) { - if (v.valuationDate <= referenceDate) { + // Only consider dates on or before the reference date (Dec 31) + if (v.valuationDate <= referenceDate) { + const existing = yearValues.get(year); + // Keep the latest date (closest to Dec 31) + if (!existing || v.valuationDate > existing.date) { yearValues.set(year, { value: v.totalValue, date: v.valuationDate }); } } From effea23c2735a6777959dec1aa06230ed041a0a8 Mon Sep 17 00:00:00 2001 From: Alexandre Trotel Date: Fri, 2 Jan 2026 21:04:07 +0100 Subject: [PATCH 3/9] fix: review inflation features to improve charts and UI/UX --- src-core/src/inflation/inflation_model.rs | 5 + .../inflation/components/inflation-chart.tsx | 12 +- .../insights/inflation/inflation-page.tsx | 128 ++++++++++++++---- .../inflation-rates/inflation-rates-page.tsx | 73 +++------- 4 files changed, 131 insertions(+), 87 deletions(-) diff --git a/src-core/src/inflation/inflation_model.rs b/src-core/src/inflation/inflation_model.rs index 51d59f659..e973acb78 100644 --- a/src-core/src/inflation/inflation_model.rs +++ b/src-core/src/inflation/inflation_model.rs @@ -40,10 +40,13 @@ pub struct InflationAdjustedValue { } // World Bank API response structures +// These fields are used for JSON deserialization even though they're not directly accessed #[derive(Deserialize, Debug)] +#[allow(dead_code)] pub struct WorldBankResponse(pub WorldBankMeta, pub Option>); #[derive(Deserialize, Debug)] +#[allow(dead_code)] pub struct WorldBankMeta { pub page: i32, pub pages: i32, @@ -51,6 +54,7 @@ pub struct WorldBankMeta { } #[derive(Deserialize, Debug)] +#[allow(dead_code)] pub struct WorldBankDataPoint { pub date: String, pub value: Option, @@ -58,6 +62,7 @@ pub struct WorldBankDataPoint { } #[derive(Deserialize, Debug)] +#[allow(dead_code)] pub struct WorldBankCountry { pub id: String, pub value: String, diff --git a/src/pages/insights/inflation/components/inflation-chart.tsx b/src/pages/insights/inflation/components/inflation-chart.tsx index d9b4f7f26..acc27bef5 100644 --- a/src/pages/insights/inflation/components/inflation-chart.tsx +++ b/src/pages/insights/inflation/components/inflation-chart.tsx @@ -30,12 +30,12 @@ export function InflationChart({ data, isLoading, baseYear, currency }: Inflatio const chartConfig = { nominal: { - label: "Nominal Value", - color: "hsl(var(--chart-1))", + label: "Nominal", + color: "hsl(221 83% 53%)", // Blue }, real: { - label: `Real Value (${baseYear})`, - color: "hsl(var(--chart-2))", + label: `Real (${baseYear})`, + color: "hsl(142 71% 45%)", // Green }, } satisfies ChartConfig; @@ -88,8 +88,8 @@ export function InflationChart({ data, isLoading, baseYear, currency }: Inflatio style={{ backgroundColor: name === "nominal" - ? "hsl(var(--chart-1))" - : "hsl(var(--chart-2))", + ? "hsl(221 83% 53%)" + : "hsl(142 71% 45%)", }} />
diff --git a/src/pages/insights/inflation/inflation-page.tsx b/src/pages/insights/inflation/inflation-page.tsx index 25fdf1529..f92031bf7 100644 --- a/src/pages/insights/inflation/inflation-page.tsx +++ b/src/pages/insights/inflation/inflation-page.tsx @@ -15,9 +15,29 @@ import { import { useEffect, useMemo, useState } from "react"; import { InflationChart } from "./components/inflation-chart"; import { parseISO } from "date-fns"; -import { Button, EmptyPlaceholder, Icons, Skeleton } from "@wealthfolio/ui"; +import { EmptyPlaceholder, Skeleton } from "@wealthfolio/ui"; import { Link } from "react-router-dom"; +const MONTHS = [ + { value: 1, label: "January" }, + { value: 2, label: "February" }, + { value: 3, label: "March" }, + { value: 4, label: "April" }, + { value: 5, label: "May" }, + { value: 6, label: "June" }, + { value: 7, label: "July" }, + { value: 8, label: "August" }, + { value: 9, label: "September" }, + { value: 10, label: "October" }, + { value: 11, label: "November" }, + { value: 12, label: "December" }, +]; + +const getDaysInMonth = (month: number): number[] => { + const days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return Array.from({ length: days[month - 1] }, (_, i) => i + 1); +}; + const CURRENCY_TO_COUNTRY: Record = { USD: "US", EUR: "FR", @@ -63,8 +83,8 @@ export default function InflationPage() { const [baseYear, setBaseYear] = useState(null); const [countryCode, setCountryCode] = useState(undefined); - const [referenceMonth] = useState(12); // December - const [referenceDay] = useState(31); + const [referenceMonth, setReferenceMonth] = useState(12); // December + const [referenceDay, setReferenceDay] = useState(31); // Use detected country if not manually set const effectiveCountryCode = countryCode || defaultCountry; @@ -81,7 +101,7 @@ export default function InflationPage() { const yearEndValues = useMemo(() => { if (!valuationHistory || valuationHistory.length === 0) return []; - const yearValues: Map = new Map(); + const yearValues = new Map(); valuationHistory.forEach((v) => { const date = parseISO(v.valuationDate); @@ -104,12 +124,19 @@ export default function InflationPage() { .sort((a, b) => a[0] - b[0]); }, [valuationHistory, referenceMonth, referenceDay]); - // Set default base year to first year of portfolio data (only once when data loads) + // Get available years from inflation rates (IPC data) + const ipcYearOptions = useMemo(() => { + if (!inflationRates || inflationRates.length === 0) return []; + const years = [...new Set(inflationRates.map((r) => r.year))].sort((a, b) => b - a); + return years; + }, [inflationRates]); + + // Set default base year to latest IPC year (only once when data loads) useEffect(() => { - if (baseYear === null && yearEndValues.length > 0) { - setBaseYear(yearEndValues[0][0]); // First year of portfolio data + if (baseYear === null && ipcYearOptions.length > 0) { + setBaseYear(ipcYearOptions[0]); // Latest year with IPC data } - }, [yearEndValues, baseYear]); + }, [ipcYearOptions, baseYear]); // Effective base year (fallback to current year if not set) const effectiveBaseYear = baseYear ?? currentYear; @@ -134,11 +161,6 @@ export default function InflationPage() { })); }, [adjustedValues]); - // Generate year options from available data - const yearOptions = useMemo(() => { - const years = yearEndValues.map(([year]) => year); - return years.length > 0 ? years : [currentYear]; - }, [yearEndValues, currentYear]); const isLoading = isValuationLoading || isRatesLoading || isAdjustedLoading || isSettingsLoading; const hasNoInflationData = !inflationRates || inflationRates.length === 0; @@ -180,24 +202,69 @@ export default function InflationPage() {
- setBaseYear(parseInt(v))} + disabled={ipcYearOptions.length === 0} + > - + - {yearOptions.map((year) => ( - - {year} - - ))} + {ipcYearOptions.length > 0 ? ( + ipcYearOptions.map((year) => ( + + {year} + + )) + ) : ( + {currentYear} + )}
-
- December 31st +
+ +
@@ -206,7 +273,7 @@ export default function InflationPage() { {/* Chart Card */} - Portfolio Value: Nominal vs Real + Nominal vs Real Comparing nominal portfolio value with inflation-adjusted value (base year: {effectiveBaseYear}) @@ -225,14 +292,15 @@ export default function InflationPage() { No Inflation Data - Add inflation rates for {effectiveCountryCode} to see real values. + Add inflation rates for {effectiveCountryCode} in{" "} + + Settings + {" "} + to see real values. - - - ) : ( { -
- - - -
-
+ />
- Country: + +
-
@@ -182,18 +162,9 @@ const SettingsInflationRatesPage = () => { No inflation rate for {currentYear} ({countryCode}) - Add an inflation rate manually or fetch historical data from World Bank. + Use the buttons above to add an inflation rate manually or fetch historical data + from World Bank. -
- - -
)} From 18c1e7e9c842d49d30d9e3be2e5ba81e6e560111 Mon Sep 17 00:00:00 2001 From: Alexandre Trotel <113524174+alexandretrotel@users.noreply.github.com> Date: Sat, 3 Jan 2026 08:36:50 +0100 Subject: [PATCH 4/9] Update settings-layout.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/settings/settings-layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/settings-layout.tsx b/src/pages/settings/settings-layout.tsx index bf928ba32..5382e68f4 100644 --- a/src/pages/settings/settings-layout.tsx +++ b/src/pages/settings/settings-layout.tsx @@ -32,7 +32,7 @@ const sidebarNavItems = [ { title: "Inflation", href: "inflation-rates", - subtitle: "Manage inflation rates (IPC)", + subtitle: "Manage inflation rates (CPI)", icon: , }, { From bb424c953cd443e76aa4e10b7438e33de2f7a7cd Mon Sep 17 00:00:00 2001 From: Alexandre Trotel <113524174+alexandretrotel@users.noreply.github.com> Date: Sat, 3 Jan 2026 08:37:05 +0100 Subject: [PATCH 5/9] Update inflation-page.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/insights/inflation/inflation-page.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/insights/inflation/inflation-page.tsx b/src/pages/insights/inflation/inflation-page.tsx index f92031bf7..ada5da15d 100644 --- a/src/pages/insights/inflation/inflation-page.tsx +++ b/src/pages/insights/inflation/inflation-page.tsx @@ -124,19 +124,19 @@ export default function InflationPage() { .sort((a, b) => a[0] - b[0]); }, [valuationHistory, referenceMonth, referenceDay]); - // Get available years from inflation rates (IPC data) - const ipcYearOptions = useMemo(() => { + // Get available years from inflation rates (CPI data) + const cpiYearOptions = useMemo(() => { if (!inflationRates || inflationRates.length === 0) return []; const years = [...new Set(inflationRates.map((r) => r.year))].sort((a, b) => b - a); return years; }, [inflationRates]); - // Set default base year to latest IPC year (only once when data loads) + // Set default base year to latest CPI year (only once when data loads) useEffect(() => { - if (baseYear === null && ipcYearOptions.length > 0) { - setBaseYear(ipcYearOptions[0]); // Latest year with IPC data + if (baseYear === null && cpiYearOptions.length > 0) { + setBaseYear(cpiYearOptions[0]); // Latest year with CPI data } - }, [ipcYearOptions, baseYear]); + }, [cpiYearOptions, baseYear]); // Effective base year (fallback to current year if not set) const effectiveBaseYear = baseYear ?? currentYear; From 2ffe5e01fc4b3d00edb8c988efda5078f1955346 Mon Sep 17 00:00:00 2001 From: Alexandre Trotel Date: Sat, 3 Jan 2026 09:19:55 +0100 Subject: [PATCH 6/9] fix: reorder imports and update year selection logic in inflation page --- src/pages/insights/inflation/inflation-page.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/insights/inflation/inflation-page.tsx b/src/pages/insights/inflation/inflation-page.tsx index ada5da15d..8b91e4f67 100644 --- a/src/pages/insights/inflation/inflation-page.tsx +++ b/src/pages/insights/inflation/inflation-page.tsx @@ -6,17 +6,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useValuationHistory } from "@/hooks/use-valuation-history"; import { useSettings } from "@/hooks/use-settings"; +import { useValuationHistory } from "@/hooks/use-valuation-history"; import { - useInflationRatesByCountry, useInflationAdjustedPortfolio, + useInflationRatesByCountry, } from "@/pages/settings/inflation-rates/use-inflation-rate-mutations"; -import { useEffect, useMemo, useState } from "react"; -import { InflationChart } from "./components/inflation-chart"; -import { parseISO } from "date-fns"; import { EmptyPlaceholder, Skeleton } from "@wealthfolio/ui"; +import { parseISO } from "date-fns"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; +import { InflationChart } from "./components/inflation-chart"; const MONTHS = [ { value: 1, label: "January" }, @@ -205,14 +205,14 @@ export default function InflationPage() {