Files
Moon-Household-Budget/sinking.py
tonym be74ca1eb7 Harden JSON storage (atomic writes + crash-safe loads) + suggestions lightbulb
- storage.py: atomic save_json (temp+fsync+os.replace) and load_json that
  survives missing/corrupt files (moves corrupt aside). Closes #11/#12.
- route all data modules + app.py helpers through storage (closes #31 core);
  fixes mutable DEFAULT_FUNDS/DEFAULT_CARS return (#23).
- gunicorn --workers 1 --threads 4 to remove the write race (#13); strip
  Dockerfile template scaffolding (#40).
- base.html: 💡 suggestions link in the shared nav (shows on every page).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:34:06 -05:00

59 lines
2.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import copy
import json
import os
from datetime import date
from storage import load_json, save_json
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
SINKING_FILE = os.path.join(DATA_DIR, "sinking_funds.json")
FUND_COLORS = {
"Travel": "#C5BBA0",
"Vet & Pets": "#A0B8C5",
"Holidays & Gifts": "#D4B8C5",
"Renovations": "#C5A8A0",
"Studio Build": "#B4A0C5",
"Emergency Fund": "#A8C5A0",
}
DEFAULT_FUNDS = [
{"name": "Travel", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""},
{"name": "Vet & Pets", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""},
{"name": "Holidays & Gifts", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""},
{"name": "Renovations", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Subcategories coming later"},
{"name": "Studio Build", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Goal amount TBD"},
{"name": "Emergency Fund", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Target: 36 months expenses"},
]
def load_funds():
funds = load_json(SINKING_FILE, None)
if funds is None:
# First run — seed with a copy of the defaults (never hand out the
# module-level list, which callers mutate in place).
funds = copy.deepcopy(DEFAULT_FUNDS)
save_funds(funds)
return funds
def save_funds(funds):
save_json(SINKING_FILE, funds)
def months_until(target_date_str):
try:
target = date.fromisoformat(target_date_str)
today = date.today()
months = (target.year - today.year) * 12 + (target.month - today.month)
return max(months, 1)
except Exception:
return None
def monthly_needed(balance, goal, target_date_str):
m = months_until(target_date_str)
if not m:
return None
remaining = max(goal - balance, 0)
return round(remaining / m, 2)