diff --git a/app/src/api/widgets.ts b/app/src/api/widgets.ts new file mode 100644 index 000000000..39a81bf9a --- /dev/null +++ b/app/src/api/widgets.ts @@ -0,0 +1,26 @@ +import { api } from './client'; + +export type WidgetLayout = { + widget_id: string; + position: number; + visible: boolean; + size: string; +}; + +export type AvailableWidget = { + widget_id: string; + name: string; + description: string; +}; + +export async function getLayout(): Promise { + return api('/widgets'); +} + +export async function saveLayout(layout: WidgetLayout[]): Promise { + return api('/widgets', { method: 'POST', body: layout }); +} + +export async function getAvailableWidgets(): Promise { + return api('/widgets/available'); +} diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..1fc85d8b6 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .widgets import bp as widgets_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(widgets_bp, url_prefix="/widgets") diff --git a/packages/backend/app/routes/widgets.py b/packages/backend/app/routes/widgets.py new file mode 100644 index 000000000..418ee7d87 --- /dev/null +++ b/packages/backend/app/routes/widgets.py @@ -0,0 +1,62 @@ +import json +import logging +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import AuditLog + +bp = Blueprint("widgets", __name__) +logger = logging.getLogger("finmind.widgets") + +AVAILABLE_WIDGETS = [ + {"widget_id": "expense_summary", "name": "Expense Summary", "description": "Overview of monthly expenses by category"}, + {"widget_id": "income_tracker", "name": "Income Tracker", "description": "Track income sources and totals"}, + {"widget_id": "bill_calendar", "name": "Bill Calendar", "description": "Upcoming bills and due dates"}, + {"widget_id": "savings_goal", "name": "Savings Goal", "description": "Progress toward savings targets"}, + {"widget_id": "recent_transactions", "name": "Recent Transactions", "description": "Latest expense and income entries"}, + {"widget_id": "budget_meter", "name": "Budget Meter", "description": "Budget utilization gauge"}, +] + + +@bp.get("") +@jwt_required() +def get_layout(): + uid = int(get_jwt_identity()) + log = ( + db.session.query(AuditLog) + .filter(AuditLog.user_id == uid, AuditLog.action.like("widget_layout:%")) + .order_by(AuditLog.created_at.desc()) + .first() + ) + if not log: + return jsonify([]) + try: + layout = json.loads(log.action.split(":", 1)[1]) + return jsonify(layout) + except (json.JSONDecodeError, IndexError): + return jsonify([]) + + +@bp.post("") +@jwt_required() +def save_layout(): + uid = int(get_jwt_identity()) + data = request.get_json() + if not isinstance(data, list): + return jsonify(error="expected array of widget layout items"), 400 + + for item in data: + if not isinstance(item, dict) or "widget_id" not in item: + return jsonify(error="each item must have widget_id"), 400 + + log = AuditLog(user_id=uid, action=f"widget_layout:{json.dumps(data)}") + db.session.add(log) + db.session.commit() + logger.info("Saved widget layout user=%s widgets=%s", uid, len(data)) + return jsonify(data), 201 + + +@bp.get("/available") +@jwt_required() +def available_widgets(): + return jsonify(AVAILABLE_WIDGETS) diff --git a/packages/backend/tests/test_widgets.py b/packages/backend/tests/test_widgets.py new file mode 100644 index 000000000..a176b98d9 --- /dev/null +++ b/packages/backend/tests/test_widgets.py @@ -0,0 +1,48 @@ +def test_widgets_requires_auth(client): + r = client.get("/widgets") + assert r.status_code in (401, 422) + + r = client.post("/widgets", json=[]) + assert r.status_code in (401, 422) + + r = client.get("/widgets/available") + assert r.status_code in (401, 422) + + +def test_save_layout(client, auth_header): + layout = [ + {"widget_id": "expense_summary", "position": 0, "visible": True, "size": "large"}, + {"widget_id": "bill_calendar", "position": 1, "visible": True, "size": "small"}, + ] + r = client.post("/widgets", json=layout, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert len(data) == 2 + assert data[0]["widget_id"] == "expense_summary" + + +def test_get_layout(client, auth_header): + # Initially empty + r = client.get("/widgets", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Save and retrieve + layout = [{"widget_id": "income_tracker", "position": 0, "visible": True, "size": "medium"}] + client.post("/widgets", json=layout, headers=auth_header) + + r = client.get("/widgets", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert len(data) == 1 + assert data[0]["widget_id"] == "income_tracker" + + +def test_available_widgets(client, auth_header): + r = client.get("/widgets/available", headers=auth_header) + assert r.status_code == 200 + widgets = r.get_json() + assert len(widgets) >= 3 + assert all("widget_id" in w for w in widgets) + assert all("name" in w for w in widgets) + assert all("description" in w for w in widgets)