Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/src/api/widgets.ts
Original file line number Diff line number Diff line change
@@ -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<WidgetLayout[]> {
return api<WidgetLayout[]>('/widgets');
}

export async function saveLayout(layout: WidgetLayout[]): Promise<WidgetLayout[]> {
return api<WidgetLayout[]>('/widgets', { method: 'POST', body: layout });
}

export async function getAvailableWidgets(): Promise<AvailableWidget[]> {
return api<AvailableWidget[]>('/widgets/available');
}
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
62 changes: 62 additions & 0 deletions packages/backend/app/routes/widgets.py
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions packages/backend/tests/test_widgets.py
Original file line number Diff line number Diff line change
@@ -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)