feat(UX-001): add full-width two-pane dashboard layout

This commit is contained in:
Bu5hm4nn
2026-03-25 23:19:09 +01:00
parent 960e1e9215
commit a60c5fb1f2
10 changed files with 473 additions and 212 deletions

View File

@@ -7,7 +7,7 @@ from nicegui import ui
from app.domain.backtesting_math import asset_quantity_from_workspace_config
from app.models.workspace import get_workspace_repository
from app.pages.common import dashboard_page, render_workspace_recovery
from app.pages.common import dashboard_page, split_page_panes
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
@@ -79,9 +79,14 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
"backtests",
workspace_id=workspace_id,
):
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
left_pane, right_pane = split_page_panes(
left_testid="backtests-left-pane",
right_testid="backtests-right-pane",
)
with left_pane:
with ui.card().classes(
"w-full max-w-xl rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Scenario Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
@@ -115,6 +120,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300")
run_button = ui.button("Run backtest").props("color=primary")
seeded_summary = ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
with right_pane:
result_panel = ui.column().classes("w-full gap-6")
def parse_iso_date(raw: object, field_name: str) -> date:
@@ -123,16 +133,62 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
except ValueError as exc:
raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc
def render_seeded_summary(*, entry_spot: float | None = None) -> None:
seeded_summary.clear()
resolved_entry_spot = entry_spot
if resolved_entry_spot is None:
try:
resolved_entry_spot = service.derive_entry_spot(
str(symbol_input.value or "GLD"),
parse_iso_date(start_input.value, "Start date"),
parse_iso_date(end_input.value, "End date"),
)
except (ValueError, KeyError):
resolved_entry_spot = None
with seeded_summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
cards = [
("Template", select_options.get(str(template_select.value), str(template_select.value or ""))),
("Underlying units", f"{float(units_input.value or 0.0):,.0f}"),
("Loan amount", f"${float(loan_input.value or 0.0):,.0f}"),
("Margin call LTV", f"{float(ltv_input.value or 0.0):.1%}"),
(
"Date range",
f"{str(start_input.value or '')}{str(end_input.value or '')}",
),
(
"Entry spot",
f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Pending",
),
]
for label, value in cards:
with ui.card().classes(
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100")
def render_result_validation(message: str) -> None:
result_panel.clear()
with result_panel:
with ui.card().classes(
"w-full rounded-2xl border border-amber-200 bg-amber-50 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30"
):
ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(message).classes("text-sm text-amber-800 dark:text-amber-200")
def render_result(result: BacktestPageRunResult) -> None:
result_panel.clear()
template_result = result.run_result.template_results[0]
summary = template_result.summary_metrics
entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.entry_spot:,.2f}")
render_seeded_summary(entry_spot=result.entry_spot)
with result_panel:
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Template: {template_result.template_name}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
@@ -216,9 +272,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
parse_iso_date(end_input.value, "End date"),
)
except (ValueError, KeyError):
render_seeded_summary(entry_spot=None)
return
units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD")
entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}")
render_seeded_summary(entry_spot=entry_spot)
def run_backtest() -> None:
validation_label.set_text("")
@@ -233,17 +291,25 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
margin_call_ltv=float(ltv_input.value or 0.0),
)
except (ValueError, KeyError) as exc:
result_panel.clear()
render_seeded_summary(entry_spot=None)
validation_label.set_text(str(exc))
render_result_validation(str(exc))
return
except Exception:
result_panel.clear()
validation_label.set_text("Backtest failed. Please verify the scenario inputs and try again.")
render_seeded_summary(entry_spot=None)
message = "Backtest failed. Please verify the scenario inputs and try again."
validation_label.set_text(message)
render_result_validation(message)
return
render_result(result)
if workspace_id:
start_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
end_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
template_select.on_value_change(lambda _event: render_seeded_summary())
units_input.on_value_change(lambda _event: render_seeded_summary())
loan_input.on_value_change(lambda _event: render_seeded_summary())
ltv_input.on_value_change(lambda _event: render_seeded_summary())
run_button.on_click(lambda: run_backtest())
render_seeded_summary(entry_spot=default_entry_spot)
run_backtest()