From e7a207a60b24ee8ef7f2b79b6d456948b2cc4e7f Mon Sep 17 00:00:00 2001 From: "R. Mostalac" <4228996+Moztoo@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:13:11 -0600 Subject: [PATCH 1/6] feat: allow assigning colors to budget categories --- backend/src/db/mod.rs | 5 + backend/src/handlers/budget.rs | 16 ++- backend/src/handlers/months.rs | 9 +- backend/src/handlers/stats.rs | 7 +- backend/src/models/mod.rs | 4 + frontend/src/api/client.ts | 8 +- frontend/src/components/BudgetSection.tsx | 166 +++++++++++++++------- frontend/src/components/ItemsSection.tsx | 9 +- frontend/src/components/Stats.tsx | 12 +- 9 files changed, 167 insertions(+), 69 deletions(-) diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index adc55ee..a7f0235 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -65,6 +65,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { user_id INTEGER NOT NULL, label TEXT NOT NULL, default_amount REAL NOT NULL, + color TEXT NOT NULL DEFAULT '#71717a', FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) "#, @@ -72,6 +73,10 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + let _ = sqlx::query("ALTER TABLE budget_categories ADD COLUMN color TEXT NOT NULL DEFAULT '#71717a'") + .execute(pool) + .await; + sqlx::query( r#" CREATE TABLE IF NOT EXISTS months ( diff --git a/backend/src/handlers/budget.rs b/backend/src/handlers/budget.rs index fb450c8..3045891 100644 --- a/backend/src/handlers/budget.rs +++ b/backend/src/handlers/budget.rs @@ -18,6 +18,7 @@ pub struct CreateCategory { pub label: String, #[validate(range(min = 0.0))] pub default_amount: f64, + pub color: Option, } #[derive(Deserialize, ToSchema, Validate)] @@ -26,6 +27,7 @@ pub struct UpdateCategory { pub label: Option, #[validate(range(min = 0.0))] pub default_amount: Option, + pub color: Option, } #[derive(Deserialize, ToSchema, Validate)] @@ -50,7 +52,7 @@ pub async fn list_categories( axum::Extension(claims): axum::Extension, ) -> Result>, PaymeError> { let categories: Vec = sqlx::query_as( - "SELECT id, user_id, label, default_amount FROM budget_categories WHERE user_id = ?", + "SELECT id, user_id, label, default_amount, color FROM budget_categories WHERE user_id = ?", ) .bind(claims.sub) .fetch_all(&pool) @@ -77,12 +79,14 @@ pub async fn create_category( Json(payload): Json, ) -> Result, PaymeError> { payload.validate()?; + let color = payload.color.unwrap_or_else(|| "#71717a".to_string()); let id: i64 = sqlx::query_scalar( - "INSERT INTO budget_categories (user_id, label, default_amount) VALUES (?, ?, ?) RETURNING id", + "INSERT INTO budget_categories (user_id, label, default_amount, color) VALUES (?, ?, ?, ?) RETURNING id", ) .bind(claims.sub) .bind(&payload.label) .bind(payload.default_amount) + .bind(&color) .fetch_one(&pool) .await?; @@ -109,6 +113,7 @@ pub async fn create_category( user_id: claims.sub, label: payload.label, default_amount: payload.default_amount, + color, })) } @@ -133,7 +138,7 @@ pub async fn update_category( ) -> Result, PaymeError> { payload.validate()?; let existing: BudgetCategory = sqlx::query_as( - "SELECT id, user_id, label, default_amount FROM budget_categories WHERE id = ? AND user_id = ?", + "SELECT id, user_id, label, default_amount, color FROM budget_categories WHERE id = ? AND user_id = ?", ) .bind(category_id) .bind(claims.sub) @@ -143,10 +148,12 @@ pub async fn update_category( let label = payload.label.unwrap_or(existing.label); let default_amount = payload.default_amount.unwrap_or(existing.default_amount); + let color = payload.color.unwrap_or(existing.color); - sqlx::query("UPDATE budget_categories SET label = ?, default_amount = ? WHERE id = ?") + sqlx::query("UPDATE budget_categories SET label = ?, default_amount = ?, color = ? WHERE id = ?") .bind(&label) .bind(default_amount) + .bind(&color) .bind(category_id) .execute(&pool) .await?; @@ -156,6 +163,7 @@ pub async fn update_category( user_id: claims.sub, label, default_amount, + color, })) } diff --git a/backend/src/handlers/months.rs b/backend/src/handlers/months.rs index 68e5eee..798d353 100644 --- a/backend/src/handlers/months.rs +++ b/backend/src/handlers/months.rs @@ -334,9 +334,9 @@ async fn get_month_summary( .await?; let budgets: Vec = - sqlx::query_as::<_, (i64, i64, i64, String, f64)>( + sqlx::query_as::<_, (i64, i64, i64, String, String, f64)>( r#" - SELECT mb.id, mb.month_id, mb.category_id, bc.label, mb.allocated_amount + SELECT mb.id, mb.month_id, mb.category_id, bc.label, bc.color, mb.allocated_amount FROM monthly_budgets mb JOIN budget_categories bc ON mb.category_id = bc.id WHERE mb.month_id = ? @@ -347,12 +347,13 @@ async fn get_month_summary( .await? .into_iter() .map( - |(id, month_id, category_id, category_label, allocated_amount)| { + |(id, month_id, category_id, category_label, category_color, allocated_amount)| { MonthlyBudgetWithCategory { id, month_id, category_id, category_label, + category_color, allocated_amount, spent_amount: 0.0, } @@ -362,7 +363,7 @@ async fn get_month_summary( let items: Vec = sqlx::query_as( r#" - SELECT i.id, i.month_id, i.category_id, bc.label as category_label, i.description, i.amount, i.spent_on, i.savings_destination + SELECT i.id, i.month_id, i.category_id, bc.label as category_label, bc.color as category_color, i.description, i.amount, i.spent_on, i.savings_destination FROM items i JOIN budget_categories bc ON i.category_id = bc.id WHERE i.month_id = ? diff --git a/backend/src/handlers/stats.rs b/backend/src/handlers/stats.rs index 6f09dbd..3100854 100644 --- a/backend/src/handlers/stats.rs +++ b/backend/src/handlers/stats.rs @@ -92,13 +92,13 @@ pub async fn get_stats( let current_month_id = months[0].0; let previous_month_id = months.get(1).map(|m| m.0); - let categories: Vec<(i64, String)> = - sqlx::query_as("SELECT id, label FROM budget_categories WHERE user_id = ?") + let categories: Vec<(i64, String, String)> = + sqlx::query_as("SELECT id, label, color FROM budget_categories WHERE user_id = ?") .bind(claims.sub) .fetch_all(&pool) .await?; - for (cat_id, cat_label) in categories { + for (cat_id, cat_label, cat_color) in categories { let current_spent: (f64,) = sqlx::query_as( "SELECT COALESCE(SUM(amount), 0.0) FROM items WHERE month_id = ? AND category_id = ? AND savings_destination = 'none'", ) @@ -130,6 +130,7 @@ pub async fn get_stats( category_comparisons.push(CategoryStats { category_id: cat_id, category_label: cat_label, + category_color: cat_color, current_month_spent: current_spent.0, previous_month_spent: previous_spent, change_amount, diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index d2ece65..3b7e20b 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -33,6 +33,7 @@ pub struct BudgetCategory { pub user_id: i64, pub label: String, pub default_amount: f64, + pub color: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)] @@ -78,6 +79,7 @@ pub struct MonthlyBudgetWithCategory { pub month_id: i64, pub category_id: i64, pub category_label: String, + pub category_color: String, pub allocated_amount: f64, pub spent_amount: f64, } @@ -103,6 +105,7 @@ pub struct ItemWithCategory { pub month_id: i64, pub category_id: i64, pub category_label: String, + pub category_color: String, pub description: String, pub amount: f64, pub spent_on: NaiveDate, @@ -113,6 +116,7 @@ pub struct ItemWithCategory { pub struct CategoryStats { pub category_id: i64, pub category_label: String, + pub category_color: String, pub current_month_spent: f64, pub previous_month_spent: f64, pub change_amount: f64, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aa9cf86..055f437 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -92,12 +92,12 @@ export const api = { categories: { list: () => request("/categories"), - create: (data: { label: string; default_amount: number }) => + create: (data: { label: string; default_amount: number; color?: string }) => request("/categories", { method: "POST", body: JSON.stringify(data), }), - update: (id: number, data: { label?: string; default_amount?: number }) => + update: (id: number, data: { label?: string; default_amount?: number; color?: string }) => request(`/categories/${id}`, { method: "PUT", body: JSON.stringify(data), @@ -282,6 +282,7 @@ export interface BudgetCategory { user_id: number; label: string; default_amount: number; + color: string; } export interface MonthlyBudget { @@ -296,6 +297,7 @@ export interface MonthlyBudgetWithCategory { month_id: number; category_id: number; category_label: string; + category_color: string; allocated_amount: number; spent_amount: number; } @@ -319,6 +321,7 @@ export interface Item { export interface ItemWithCategory extends Item { category_label: string; + category_color: string; } export interface MonthlySavings { @@ -346,6 +349,7 @@ export interface MonthSummary { export interface CategoryStats { category_id: number; category_label: string; + category_color: string; current_month_spent: number; previous_month_spent: number; change_amount: number; diff --git a/frontend/src/components/BudgetSection.tsx b/frontend/src/components/BudgetSection.tsx index 8596ab6..4dbe633 100644 --- a/frontend/src/components/BudgetSection.tsx +++ b/frontend/src/components/BudgetSection.tsx @@ -30,22 +30,38 @@ export function BudgetSection({ const [editingBudgetId, setEditingBudgetId] = useState(null); const [label, setLabel] = useState(""); const [amount, setAmount] = useState(""); + const [color, setColor] = useState("#71717a"); + + const PRESET_COLORS = [ + "#71717a", // Zinc + "#ef4444", // Red + "#f97316", // Orange + "#f59e0b", // Amber + "#10b981", // Emerald + "#06b6d4", // Cyan + "#3b82f6", // Blue + "#6366f1", // Indigo + "#8b5cf6", // Violet + "#d946ef", // Fuchsia + ]; const handleAddCategory = async () => { if (!label || !amount) return; - await api.categories.create({ label, default_amount: parseFloat(amount) }); + await api.categories.create({ label, default_amount: parseFloat(amount), color }); setLabel(""); setAmount(""); + setColor("#71717a"); setIsAddingCategory(false); await onUpdate(); }; const handleUpdateCategory = async (id: number) => { if (!label || !amount) return; - await api.categories.update(id, { label, default_amount: parseFloat(amount) }); + await api.categories.update(id, { label, default_amount: parseFloat(amount), color }); setEditingCategoryId(null); setLabel(""); setAmount(""); + setColor("#71717a"); await onUpdate(); }; @@ -66,6 +82,7 @@ export function BudgetSection({ setEditingCategoryId(cat.id); setLabel(cat.label); setAmount(cat.default_amount.toString()); + setColor(cat.color); }; const startEditBudget = (budget: MonthlyBudgetWithCategory) => { @@ -78,6 +95,7 @@ export function BudgetSection({ setEditingBudgetId(null); setLabel(""); setAmount(""); + setColor("#71717a"); setIsAddingCategory(false); }; @@ -128,9 +146,15 @@ export function BudgetSection({ ) : (
- - {budget.category_label} - +
+
+ + {budget.category_label} + +
{formatCurrency(budget.spent_amount)} / {formatCurrency(budget.allocated_amount)} @@ -166,38 +190,60 @@ export function BudgetSection({ {categories.map((cat) => (
{editingCategoryId === cat.id ? ( -
-
- setLabel(e.target.value)} - /> +
+
+
+ setLabel(e.target.value)} + /> +
+
+ setAmount(e.target.value)} + /> +
-
- setAmount(e.target.value)} - /> +
+ {PRESET_COLORS.map((c) => ( +
+
+ +
- -
) : (
- {cat.label} +
+
+ {cat.label} +
{formatCurrency(cat.default_amount)} @@ -221,28 +267,44 @@ export function BudgetSection({ ))} {isAddingCategory ? ( -
-
- setLabel(e.target.value)} - /> +
+
+
+ setLabel(e.target.value)} + /> +
+
+ setAmount(e.target.value)} + /> +
+
+
+ {PRESET_COLORS.map((c) => ( +
-
- setAmount(e.target.value)} - /> +
+ +
- -
) : (