From eda2aae35924f1bcd96434abf41d99cfa9112bfd Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Mon, 9 Mar 2026 23:44:51 -0400 Subject: [PATCH 1/4] feat(db): add tables for custom savings goals and retirement breakdown Add custom_savings_goals and retirement_breakdown_items tables so both features persist in SQLite instead of being lost across browser sessions. --- backend/src/db/mod.rs | 29 +++++++++++++++++++++++++++++ backend/src/models/mod.rs | 17 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index ec04c2d..ab44cea 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -197,6 +197,35 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS custom_savings_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + current_amount REAL NOT NULL DEFAULT 0, + target_amount REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS retirement_breakdown_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + label TEXT NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + "#, + ) + .execute(pool) + .await?; + // Migration: Backfill existing months with current fixed expenses and savings // This ensures existing data is preserved when upgrading let existing_months: Vec<(i64, i64)> = sqlx::query_as( diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 3b7e20b..296b5fb 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -133,6 +133,23 @@ pub struct MonthlyStats { pub net: f64, } +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)] +pub struct CustomSavingsGoal { + pub id: i64, + pub user_id: i64, + pub name: String, + pub current_amount: f64, + pub target_amount: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)] +pub struct RetirementBreakdownItem { + pub id: i64, + pub user_id: i64, + pub label: String, + pub amount: f64, +} + #[derive(Debug, Serialize, ToSchema)] pub struct StatsResponse { pub category_comparisons: Vec, From 36cc1f7c0d9ab14de20eaaa547710a316887ea00 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Mon, 9 Mar 2026 23:44:55 -0400 Subject: [PATCH 2/4] feat(backend): add CRUD handlers and routes for savings goals and retirement breakdown Wire up GET/POST /api/savings-goals, PUT/DELETE /api/savings-goals/{id}, GET/POST /api/retirement-breakdown, and PUT/DELETE /api/retirement-breakdown/{id}, all protected behind the existing JWT auth middleware. --- backend/src/handlers/mod.rs | 2 + backend/src/handlers/retirement_breakdown.rs | 117 +++++++++++++++++ backend/src/handlers/savings_goals.rs | 129 +++++++++++++++++++ backend/src/lib.rs | 36 +++++- 4 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 backend/src/handlers/retirement_breakdown.rs create mode 100644 backend/src/handlers/savings_goals.rs diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 4635655..4dcb63f 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -7,5 +7,7 @@ pub mod income; pub mod items; pub mod monthly_data; pub mod months; +pub mod retirement_breakdown; pub mod savings; +pub mod savings_goals; pub mod stats; diff --git a/backend/src/handlers/retirement_breakdown.rs b/backend/src/handlers/retirement_breakdown.rs new file mode 100644 index 0000000..db2755a --- /dev/null +++ b/backend/src/handlers/retirement_breakdown.rs @@ -0,0 +1,117 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use sqlx::SqlitePool; +use utoipa::ToSchema; +use validator::Validate; + +use crate::error::PaymeError; +use crate::middleware::auth::Claims; +use crate::models::RetirementBreakdownItem; + +#[derive(Deserialize, ToSchema, Validate)] +pub struct CreateRetirementBreakdownItem { + #[validate(length(min = 1, max = 100))] + pub label: String, + #[validate(range(min = 0.0))] + pub amount: f64, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct UpdateRetirementBreakdownItem { + #[validate(length(min = 1, max = 100))] + pub label: Option, + #[validate(range(min = 0.0))] + pub amount: Option, +} + +pub async fn list_retirement_breakdown( + State(pool): State, + axum::Extension(claims): axum::Extension, +) -> Result>, PaymeError> { + let items: Vec = sqlx::query_as( + "SELECT id, user_id, label, amount FROM retirement_breakdown_items WHERE user_id = ? ORDER BY id ASC", + ) + .bind(claims.sub) + .fetch_all(&pool) + .await?; + + Ok(Json(items)) +} + +pub async fn create_retirement_breakdown_item( + State(pool): State, + axum::Extension(claims): axum::Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), PaymeError> { + payload.validate()?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO retirement_breakdown_items (user_id, label, amount) VALUES (?, ?, ?) RETURNING id", + ) + .bind(claims.sub) + .bind(&payload.label) + .bind(payload.amount) + .fetch_one(&pool) + .await?; + + Ok(( + StatusCode::CREATED, + Json(RetirementBreakdownItem { + id, + user_id: claims.sub, + label: payload.label, + amount: payload.amount, + }), + )) +} + +pub async fn update_retirement_breakdown_item( + State(pool): State, + axum::Extension(claims): axum::Extension, + Path(item_id): Path, + Json(payload): Json, +) -> Result, PaymeError> { + payload.validate()?; + let existing: RetirementBreakdownItem = sqlx::query_as( + "SELECT id, user_id, label, amount FROM retirement_breakdown_items WHERE id = ? AND user_id = ?", + ) + .bind(item_id) + .bind(claims.sub) + .fetch_optional(&pool) + .await? + .ok_or(PaymeError::NotFound)?; + + let label = payload.label.unwrap_or(existing.label); + let amount = payload.amount.unwrap_or(existing.amount); + + sqlx::query("UPDATE retirement_breakdown_items SET label = ?, amount = ? WHERE id = ?") + .bind(&label) + .bind(amount) + .bind(item_id) + .execute(&pool) + .await?; + + Ok(Json(RetirementBreakdownItem { + id: item_id, + user_id: claims.sub, + label, + amount, + })) +} + +pub async fn delete_retirement_breakdown_item( + State(pool): State, + axum::Extension(claims): axum::Extension, + Path(item_id): Path, +) -> Result { + sqlx::query("DELETE FROM retirement_breakdown_items WHERE id = ? AND user_id = ?") + .bind(item_id) + .bind(claims.sub) + .execute(&pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/handlers/savings_goals.rs b/backend/src/handlers/savings_goals.rs new file mode 100644 index 0000000..cde1208 --- /dev/null +++ b/backend/src/handlers/savings_goals.rs @@ -0,0 +1,129 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use sqlx::SqlitePool; +use utoipa::ToSchema; +use validator::Validate; + +use crate::error::PaymeError; +use crate::middleware::auth::Claims; +use crate::models::CustomSavingsGoal; + +#[derive(Deserialize, ToSchema, Validate)] +pub struct CreateSavingsGoal { + #[validate(length(min = 1, max = 100))] + pub name: String, + #[validate(range(min = 0.0))] + pub current_amount: Option, + #[validate(range(min = 0.01))] + pub target_amount: f64, +} + +#[derive(Deserialize, ToSchema, Validate)] +pub struct UpdateSavingsGoal { + #[validate(length(min = 1, max = 100))] + pub name: Option, + #[validate(range(min = 0.0))] + pub current_amount: Option, + #[validate(range(min = 0.01))] + pub target_amount: Option, +} + +pub async fn list_savings_goals( + State(pool): State, + axum::Extension(claims): axum::Extension, +) -> Result>, PaymeError> { + let goals: Vec = sqlx::query_as( + "SELECT id, user_id, name, current_amount, target_amount FROM custom_savings_goals WHERE user_id = ? ORDER BY id ASC", + ) + .bind(claims.sub) + .fetch_all(&pool) + .await?; + + Ok(Json(goals)) +} + +pub async fn create_savings_goal( + State(pool): State, + axum::Extension(claims): axum::Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), PaymeError> { + payload.validate()?; + let current_amount = payload.current_amount.unwrap_or(0.0); + let id: i64 = sqlx::query_scalar( + "INSERT INTO custom_savings_goals (user_id, name, current_amount, target_amount) VALUES (?, ?, ?, ?) RETURNING id", + ) + .bind(claims.sub) + .bind(&payload.name) + .bind(current_amount) + .bind(payload.target_amount) + .fetch_one(&pool) + .await?; + + Ok(( + StatusCode::CREATED, + Json(CustomSavingsGoal { + id, + user_id: claims.sub, + name: payload.name, + current_amount, + target_amount: payload.target_amount, + }), + )) +} + +pub async fn update_savings_goal( + State(pool): State, + axum::Extension(claims): axum::Extension, + Path(goal_id): Path, + Json(payload): Json, +) -> Result, PaymeError> { + payload.validate()?; + let existing: CustomSavingsGoal = sqlx::query_as( + "SELECT id, user_id, name, current_amount, target_amount FROM custom_savings_goals WHERE id = ? AND user_id = ?", + ) + .bind(goal_id) + .bind(claims.sub) + .fetch_optional(&pool) + .await? + .ok_or(PaymeError::NotFound)?; + + let name = payload.name.unwrap_or(existing.name); + let current_amount = payload.current_amount.unwrap_or(existing.current_amount); + let target_amount = payload.target_amount.unwrap_or(existing.target_amount); + + sqlx::query( + "UPDATE custom_savings_goals SET name = ?, current_amount = ?, target_amount = ? WHERE id = ?", + ) + .bind(&name) + .bind(current_amount) + .bind(target_amount) + .bind(goal_id) + .execute(&pool) + .await?; + + Ok(Json(CustomSavingsGoal { + id: goal_id, + user_id: claims.sub, + name, + current_amount, + target_amount, + })) +} + +pub async fn delete_savings_goal( + State(pool): State, + axum::Extension(claims): axum::Extension, + Path(goal_id): Path, +) -> Result { + sqlx::query("DELETE FROM custom_savings_goals WHERE id = ? AND user_id = ?") + .bind(goal_id) + .bind(claims.sub) + .execute(&pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index de13145..a04a6f0 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -16,8 +16,8 @@ use sqlx::SqlitePool; use tower_http::cors::{Any, CorsLayer}; use handlers::{ - auth, budget, export, fixed_expenses, health, income, items, monthly_data, months, savings, - stats, + auth, budget, export, fixed_expenses, health, income, items, monthly_data, months, + retirement_breakdown, savings, savings_goals, stats, }; use middleware::auth::auth_middleware; @@ -124,6 +124,38 @@ pub fn create_app(pool: SqlitePool) -> Router { ) .route("/api/export/json", get(export::export_json)) .route("/api/import/json", post(export::import_json)) + .route( + "/api/savings-goals", + get(savings_goals::list_savings_goals), + ) + .route( + "/api/savings-goals", + post(savings_goals::create_savings_goal), + ) + .route( + "/api/savings-goals/{id}", + put(savings_goals::update_savings_goal), + ) + .route( + "/api/savings-goals/{id}", + delete(savings_goals::delete_savings_goal), + ) + .route( + "/api/retirement-breakdown", + get(retirement_breakdown::list_retirement_breakdown), + ) + .route( + "/api/retirement-breakdown", + post(retirement_breakdown::create_retirement_breakdown_item), + ) + .route( + "/api/retirement-breakdown/{id}", + put(retirement_breakdown::update_retirement_breakdown_item), + ) + .route( + "/api/retirement-breakdown/{id}", + delete(retirement_breakdown::delete_retirement_breakdown_item), + ) .layer(from_fn(auth_middleware)); let cors = CorsLayer::new() From 611766c26d1bfb6cf570f914c14ddf97e9aa1547 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Mon, 9 Mar 2026 23:44:58 -0400 Subject: [PATCH 3/4] feat(frontend): persist savings goals and retirement breakdown via API Replace localStorage reads/writes in CustomSavingsGoals and RetirementBreakdownCard with API calls so data survives across browser sessions. Fixes #34. --- frontend/src/api/client.ts | 47 +++++ .../src/components/CustomSavingsGoals.tsx | 164 ++++++++++-------- .../components/RetirementBreakdownCard.tsx | 105 ++++++----- 3 files changed, 198 insertions(+), 118 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 055f437..19b7b9a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -236,6 +236,38 @@ export const api = { body: JSON.stringify({ retirement_savings }), }), }, + + savingsGoals: { + list: () => request("/savings-goals"), + create: (data: { name: string; current_amount?: number; target_amount: number }) => + request("/savings-goals", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: { name?: string; current_amount?: number; target_amount?: number }) => + request(`/savings-goals/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + delete: (id: number) => + request(`/savings-goals/${id}`, { method: "DELETE" }), + }, + + retirementBreakdown: { + list: () => request("/retirement-breakdown"), + create: (data: { label: string; amount: number }) => + request("/retirement-breakdown", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: { label?: string; amount?: number }) => + request(`/retirement-breakdown/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + delete: (id: number) => + request(`/retirement-breakdown/${id}`, { method: "DELETE" }), + }, }; export interface UserExport { @@ -372,3 +404,18 @@ export interface StatsResponse { average_monthly_income: number; } +export interface CustomSavingsGoal { + id: number; + user_id: number; + name: string; + current_amount: number; + target_amount: number; +} + +export interface RetirementBreakdownItem { + id: number; + user_id: number; + label: string; + amount: number; +} + diff --git a/frontend/src/components/CustomSavingsGoals.tsx b/frontend/src/components/CustomSavingsGoals.tsx index bcc7dd7..ba6418e 100644 --- a/frontend/src/components/CustomSavingsGoals.tsx +++ b/frontend/src/components/CustomSavingsGoals.tsx @@ -1,64 +1,58 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Target, Pencil, Check, X, Trash2, Plus } from "lucide-react"; import { Card } from "./ui/Card"; import { Input } from "./ui/Input"; import { ProgressBar } from "./ui/ProgressBar"; import { Button } from "./ui/Button"; import { useCurrency } from "../context/CurrencyContext"; - -interface SavingsGoal { - id: string; - name: string; - currentAmount: number; - targetAmount: number; -} +import { api, CustomSavingsGoal } from "../api/client"; export function CustomSavingsGoals() { const { formatCurrency, getCurrencySymbol } = useCurrency(); - - // Load goals from localStorage on mount using lazy initializer - const [goals, setGoals] = useState(() => { - const savedGoals = localStorage.getItem("customSavingsGoals"); - if (savedGoals) { - try { - return JSON.parse(savedGoals); - } catch (e) { - console.error("Failed to load savings goals:", e); - return []; - } - } - return []; - }); - + + const [goals, setGoals] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [isAddingNew, setIsAddingNew] = useState(false); const [newGoalName, setNewGoalName] = useState(""); const [newGoalTarget, setNewGoalTarget] = useState(""); - const [editingGoalId, setEditingGoalId] = useState(null); + const [editingGoalId, setEditingGoalId] = useState(null); const [editCurrentAmount, setEditCurrentAmount] = useState(""); - // Save goals to localStorage whenever they change + const loadGoals = useCallback(async () => { + try { + const data = await api.savingsGoals.list(); + setGoals(data); + } catch (e) { + console.error("Failed to load savings goals:", e); + } finally { + setIsLoading(false); + } + }, []); + useEffect(() => { - localStorage.setItem("customSavingsGoals", JSON.stringify(goals)); - }, [goals]); + loadGoals(); + }, [loadGoals]); - const addNewGoal = () => { + const addNewGoal = async () => { const target = parseFloat(newGoalTarget); if (!newGoalName.trim() || isNaN(target) || target <= 0) return; - const newGoal: SavingsGoal = { - id: Date.now().toString(), - name: newGoalName.trim(), - currentAmount: 0, - targetAmount: target, - }; - - setGoals([...goals, newGoal]); - setNewGoalName(""); - setNewGoalTarget(""); - setIsAddingNew(false); + try { + const created = await api.savingsGoals.create({ + name: newGoalName.trim(), + current_amount: 0, + target_amount: target, + }); + setGoals((prev) => [...prev, created]); + setNewGoalName(""); + setNewGoalTarget(""); + setIsAddingNew(false); + } catch (e) { + console.error("Failed to create savings goal:", e); + } }; - const startEditAmount = (goalId: string, currentAmount: number) => { + const startEditAmount = (goalId: number, currentAmount: number) => { setEditingGoalId(goalId); setEditCurrentAmount(currentAmount.toString()); }; @@ -68,20 +62,28 @@ export function CustomSavingsGoals() { setEditCurrentAmount(""); }; - const saveEditAmount = (goalId: string) => { + const saveEditAmount = async (goalId: number) => { const amount = parseFloat(editCurrentAmount); if (isNaN(amount) || amount < 0) return; - setGoals(goals.map(goal => - goal.id === goalId ? { ...goal, currentAmount: amount } : goal - )); + try { + const updated = await api.savingsGoals.update(goalId, { current_amount: amount }); + setGoals((prev) => prev.map((g) => (g.id === goalId ? updated : g))); + } catch (e) { + console.error("Failed to update savings goal:", e); + } setEditingGoalId(null); setEditCurrentAmount(""); }; - const deleteGoal = (goalId: string) => { - if (confirm("Are you sure you want to delete this savings goal?")) { - setGoals(goals.filter(goal => goal.id !== goalId)); + const deleteGoal = async (goalId: number) => { + if (!confirm("Are you sure you want to delete this savings goal?")) return; + + try { + await api.savingsGoals.delete(goalId); + setGoals((prev) => prev.filter((g) => g.id !== goalId)); + } catch (e) { + console.error("Failed to delete savings goal:", e); } }; @@ -112,30 +114,42 @@ export function CustomSavingsGoals() {
- {goals.length === 0 && !isAddingNew && ( + {isLoading && ( +

+ Loading... +

+ )} + + {!isLoading && goals.length === 0 && !isAddingNew && (

No custom savings goals yet. Click + to add one!

)} {goals.map((goal) => { - const percentage = goal.targetAmount > 0 - ? (goal.currentAmount / goal.targetAmount) * 100 - : 0; - const isComplete = goal.currentAmount >= goal.targetAmount; - const remaining = goal.targetAmount - goal.currentAmount; + const percentage = + goal.target_amount > 0 + ? (goal.current_amount / goal.target_amount) * 100 + : 0; + const isComplete = goal.current_amount >= goal.target_amount; + const remaining = goal.target_amount - goal.current_amount; return ( -
+

{goal.name}

- + {editingGoalId === goal.id ? (
- {getCurrencySymbol()} + + {getCurrencySymbol()} + - {formatCurrency(goal.currentAmount)} + {formatCurrency(goal.current_amount)} - / {formatCurrency(goal.targetAmount)} + / {formatCurrency(goal.target_amount)}
)} -
- - {isComplete ? '✓ Complete!' : `${percentage.toFixed(1)}% complete`} + + {isComplete ? "✓ Complete!" : `${percentage.toFixed(1)}% complete`} {!isComplete && ( @@ -224,7 +240,7 @@ export function CustomSavingsGoals() { autoFocus />
- +
- -