fire-planner: UX review pass 2 — health URL, Progress in shell, Gantt
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

single-year drag, Settings highlight, Dashboard empty state

Round 2 of agent-driven UX review.

- api status badge was always "unreachable" because client called
  /api/healthz but FastAPI mounts /healthz at root (so k8s probes
  hit it without the SPA prefix). Client now calls /healthz directly.
- Progress page rendered without the shell sidebar/tabs because its
  route was sibling to ScenarioShell; moved it inside as a nested
  route (also drops the redundant "← Plan" breadcrumb since the tab
  bar handles that now).
- EventGantt: single-year (point) events no longer render edge handles
  since the bar is too narrow to distinguish "edge grab" from "middle
  grab" — for points the whole bar moves; resize via the popover.
  Bars wider than 24px keep their edge handles.
- Settings sub-nav: Milestones now points at /settings/milestones
  (consistent active highlight); /settings index redirects there.
- Dashboard "Last 12 months" chart shows an explainer when the
  history has fewer than 2 snapshots instead of an empty axis.
- Stub tabs that are currently active get a slate-300 underline +
  slate-500 text rather than the bold slate-900 — visually honest
  about what's a placeholder.

Frontend typecheck/test/build green.
This commit is contained in:
Viktor Barzin 2026-05-10 17:49:05 +00:00
parent cd1fc37f25
commit 9fd8389c26
8 changed files with 53 additions and 34 deletions

View file

@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { NavLink, Route, Routes, Link } from 'react-router-dom';
import { api } from '@/api/client';
import { Navigate } from 'react-router-dom';
import { Compare } from '@/pages/Compare';
import { Dashboard } from '@/pages/Dashboard';
import { CashflowTab } from '@/pages/CashflowTab';
@ -53,9 +55,9 @@ export function App() {
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<ScenarioNew />} />
<Route path="/scenarios/:id/edit" element={<ScenarioEdit />} />
<Route path="/scenarios/:id/progress" element={<ProgressPage />} />
<Route path="/scenarios/:id" element={<ScenarioShell />}>
<Route index element={<ScenarioDetail />} />
<Route path="progress" element={<ProgressPage />} />
<Route path="cash-flow" element={<CashflowTab />} />
<Route
path="tax-analytics"
@ -74,7 +76,7 @@ export function App() {
element={<PlaceholderTab feature="Estate planning" wave={2} />}
/>
<Route path="settings" element={<SettingsTab />}>
<Route index element={<MilestonesSettings />} />
<Route index element={<Navigate to="milestones" replace />} />
<Route path="milestones" element={<MilestonesSettings />} />
<Route path="rates" element={<RatesSettings />} />
<Route

View file

@ -39,7 +39,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
health: () => request<{ status: string; queue_depth: number }>('/healthz'),
// /healthz is mounted at root by the FastAPI app (so k8s probes hit
// it without the /api prefix). Bypass API_BASE to match.
health: () =>
fetch('/healthz')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => d as { status: string; queue_depth: number }),
recompute: (body?: Record<string, unknown>) =>
request<{ status: string; depth: number }>('/recompute', {
method: 'POST',

View file

@ -288,6 +288,12 @@ function Inner({
const fill = CATEGORY_FILL[ev.category] ?? CATEGORY_FILL.essential;
const border =
CATEGORY_BORDER[ev.category] ?? CATEGORY_BORDER.essential;
// Edge handles are only useful on multi-year ranges. For
// single-year (point) events the bar is too narrow to
// distinguish "edge" from "middle" so the whole bar
// moves and the user resizes via the popover.
const showHandles = w >= 24 && yearEnd > ev.year_start;
const handleW = Math.min(8, w / 4);
return (
<g
key={ev.id}
@ -306,28 +312,30 @@ function Inner({
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'move')}
/>
{/* Left handle */}
<rect
x={x - 1}
y={y}
width={6}
height={h}
fill="rgba(0,0,0,0.25)"
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'left')}
style={{ cursor: 'ew-resize' }}
/>
{/* Right handle */}
<rect
x={x + w - 5}
y={y}
width={6}
height={h}
fill="rgba(0,0,0,0.25)"
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'right')}
style={{ cursor: 'ew-resize' }}
/>
{showHandles && (
<>
<rect
x={x - 1}
y={y}
width={handleW}
height={h}
fill="rgba(0,0,0,0.25)"
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'left')}
style={{ cursor: 'ew-resize' }}
/>
<rect
x={x + w - handleW + 1}
y={y}
width={handleW}
height={h}
fill="rgba(0,0,0,0.25)"
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'right')}
style={{ cursor: 'ew-resize' }}
/>
</>
)}
<text
x={x + 6}
y={y + h / 2}

View file

@ -21,7 +21,7 @@ export function Sidebar({ activeScenarioId }: Props) {
<SidebarLink to="/" end>
Dashboard
</SidebarLink>
{activeScenarioId != null && (
{activeScenarioId != null && Number.isFinite(activeScenarioId) && (
<SidebarLink to={`/scenarios/${activeScenarioId}/progress`}>Progress</SidebarLink>
)}
</SidebarSection>

View file

@ -26,7 +26,9 @@ export function TabBar({ tabs }: { tabs: TabSpec[] }) {
[
'py-3 px-1 -mb-px border-b-2 whitespace-nowrap',
isActive
? 'border-slate-900 text-slate-900 font-medium'
? t.stub
? 'border-slate-300 text-slate-500 font-medium'
: 'border-slate-900 text-slate-900 font-medium'
: t.stub
? 'border-transparent text-slate-300 hover:text-slate-500'
: 'border-transparent text-slate-500 hover:text-slate-800',

View file

@ -66,6 +66,13 @@ export function Dashboard() {
<p className="text-sm text-slate-500">Loading</p>
) : history.isError || !history.data ? (
<p className="text-sm text-slate-500">History unavailable.</p>
) : history.data.points.length < 2 ? (
<p className="text-sm text-slate-500">
Only {history.data.points.length} snapshot in the window the
chart needs at least two daily points to draw a line. The
Wealthfolio CronJob writes one snapshot per day, so this fills
in over time.
</p>
) : (
<NetWorthChart history={history.data} />
)}

View file

@ -3,7 +3,7 @@
* fan, with a variance side panel.
*/
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { ProgressOverlay } from '@/components/ProgressOverlay';
@ -24,11 +24,6 @@ export function ProgressPage() {
return (
<section className="space-y-5">
<div className="text-sm">
<Link to={`/scenarios/${id}`} className="text-slate-500 hover:text-slate-900">
Plan
</Link>
</div>
<header>
<h1 className="text-2xl font-semibold tracking-tight">Progress</h1>
<p className="text-sm text-slate-500 mt-1">

View file

@ -9,7 +9,7 @@ export function SettingsTab() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const items = [
{ to: `/scenarios/${id}/settings`, label: 'Milestones', end: true },
{ to: `/scenarios/${id}/settings/milestones`, label: 'Milestones' },
{ to: `/scenarios/${id}/settings/rates`, label: 'Rates' },
{ to: `/scenarios/${id}/settings/notes`, label: 'Notes' },
{ to: `/scenarios/${id}/settings/dividends`, label: 'Dividends', stub: true },