- 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>
59 lines
2.0 KiB
Python
59 lines
2.0 KiB
Python
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: 3–6 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)
|