diff --git a/TODO.md b/TODO.md index 93cc02e3..940fd428 100644 --- a/TODO.md +++ b/TODO.md @@ -296,6 +296,19 @@ and [docs/plan/04-desktop-ui.md](docs/plan/04-desktop-ui.md). - [x] Overview screen now uses `@shadcnblocks/dashboard5` as the first dashboard screen, keeping Export -> Reports, Add connection modal, and Show all transactions wiring +- [x] Add the first configurable page workspace foundation to Overview: + draggable/resizable page widgets, snap-grid constraints, collision handling, + z-order focus, reset-to-default, a widget palette with real and placeholder + modules, and local per-book/page persistence +- [ ] Prove configurable workspaces with one concrete workflow before expanding + beyond Overview. Candidate proof points: transaction cleanup workspace with + table/detail/filter/review-queue modules, or tax review workspace with + blockers, journal freshness, E 1kv preview, and export status. Keep the + long-term UX bar explicit: workflow presets, small page-specific widget + catalogs, reset/restore safety, no controls outside edit mode, no tiny or + overlapping widgets, keyboard/tile controls, and consistent behavior across + pages. If the only gain is movable dashboard cards, do not expand the + pattern. - [x] Transactions screen now uses `@shadcnblocks/dashboard2` as the transaction dashboard, with ordered period controls, enlarged search copy, and privacy visibility toggle in the header diff --git a/docs/plan/04-desktop-ui.md b/docs/plan/04-desktop-ui.md index d6d23c77..8dfdc9d4 100644 --- a/docs/plan/04-desktop-ui.md +++ b/docs/plan/04-desktop-ui.md @@ -75,6 +75,54 @@ malicious page could appear there. browser, Claude in Chrome, Claude Preview MCP, and any future AI-driven browser tool. See section 2.7 below for the concrete setup. +### Main pages can become configurable workspaces + +The desktop shell should keep excellent defaults while letting serious users +shape each main page into the workstation they need. The first implementation +scope is per-page and additive: Overview exposes its current dashboard sections +as page-level widgets with snap-grid movement, edge/corner resizing, +collision handling, z-order focus, reset-to-default, and a widget palette that +can also show planned modules. Layouts are local UI preferences keyed by the +current book identity plus page id, so a tax-review book can have a different +Overview layout than a wallet-monitoring book without changing accounting data +or daemon contracts. + +Do not make card internals draggable. The configurable boundary is the +page-level module frame; reports, tables, charts, and review controls inside a +module should remain normal application UI. New configurable pages should reuse +the shared `PageWorkspace` layer instead of creating one-off dashboard canvases. + +Configurable workspaces are only worth keeping if they increase task +throughput. Normal mode must stay quiet and static: no grid, handles, palette, +or motion outside an explicit edit-layout mode. Snapping should be shown as a +preview outline while dragging or resizing, then committed on release, so data +cards do not jump under the pointer. + +The long-term UX bar is workflow composition, not movable cards for their own +sake: + +- Ship opinionated presets for real jobs such as tax review, transaction + cleanup, reporting/export readiness, source-of-funds review, and wallet + monitoring. +- Keep each page's widget catalog small and page-specific. Add modules only + when they expose real state or actions for that workflow. +- Provide reset-to-default, restore preset, and eventually duplicate/save as + preset affordances so experimentation is reversible. +- Keep strong constraints: no overlapping modules, no unusably small widgets, + no empty decorative dashboards, and no layout controls outside edit mode. +- Add precise non-pointer controls before expanding the pattern broadly: + keyboard nudging, size presets, and simple tile commands such as half-width, + full-width, and move-to-top. +- Preserve cross-page consistency. If Transactions, Reports, or Source Funds + become configurable, they should use the same edit mode, persistence model, + reset behavior, and accessibility rules. + +Do not expand configurable workspaces to another main page until one concrete +workflow proves the added control is useful. A good next proof is a transaction +cleanup workstation with table, detail, filters, and review queue modules, or a +tax review workstation with blockers, journal freshness, E 1kv preview, and +export status. + ### Animations honor motion preference and the display's refresh rate The Tauri webview (WKWebView on macOS, WebView2 on Windows, webkit2gtk on diff --git a/docs/reference/desktop.md b/docs/reference/desktop.md index dbd86325..db3e18cd 100644 --- a/docs/reference/desktop.md +++ b/docs/reference/desktop.md @@ -52,6 +52,26 @@ prerelease and development troubleshooting: request logs include argument keys, not argument values, while terminal daemon errors keep their structured message, hint, and redacted details when the daemon exposes them. +Overview is the first configurable page workspace. Normal mode stays static: +there is no background grid, drag handle, resize control, remove button, or +widget palette until the user enters Edit layout. In edit mode, page-level +modules can be moved, resized from edges or the lower-right corner, focused +above other modules, removed, re-added from the widget palette, and reset to the +default layout. Drag and resize interactions show a snapped outline preview +while the underlying module stays visually stable, then commit the layout on +release. The layout is a local UI preference keyed by current book identity and +page id; it is not persisted in the accounting database and does not change +daemon data. The widget palette contains the existing Overview modules plus +planned placeholders so later report, source-of-funds, and wallet-monitoring +modules can reuse the same surface. + +Configurable workspaces should remain a workflow feature, not a dashboard toy. +Future expansion needs opinionated presets for concrete jobs, a small +page-specific widget catalog, reversible reset/restore behavior, strong minimum +size and collision constraints, precise keyboard/tile controls, and the same +edit-mode rules across pages. If a page cannot name the workflow it improves, +it should keep a fixed layout. + Settings -> AI providers displays each provider's API-key presence plus storage location/state. Saving provider metadata does not include the raw key in the create/update request; when the API-key field is filled, the form sends the diff --git a/ui-tauri/src/components/dashboard5.tsx b/ui-tauri/src/components/dashboard5.tsx index dbd090b4..12f61225 100644 --- a/ui-tauri/src/components/dashboard5.tsx +++ b/ui-tauri/src/components/dashboard5.tsx @@ -43,6 +43,10 @@ import { import { Button } from "@/components/ui/button"; import { AddConnectionDialog } from "@/components/kb/AddConnectionDialog"; +import { + PageWorkspace, + type PageWorkspaceWidget, +} from "@/components/kb/PageWorkspace"; import { Checkbox } from "@/components/ui/checkbox"; import { CurrencyToggleText } from "@/components/kb/CurrencyToggleText"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; @@ -73,6 +77,7 @@ import { formatBtc, useCurrency, type Currency } from "@/lib/currency"; import { screenShellClassName } from "@/lib/screen-layout"; import { cn } from "@/lib/utils"; import { useUiStore } from "@/store/ui"; +import type { WorkspaceLayoutItem } from "@/lib/workspaceLayout"; import { MOCK_OVERVIEW, type OverviewSnapshot, @@ -4747,6 +4752,81 @@ const BooksHealthPanel = ({ ); }; +const overviewWorkspaceDefaultItems: WorkspaceLayoutItem[] = [ + { + id: "overview-readiness", + widgetId: "overview-readiness", + x: 0, + y: 0, + w: 12, + h: 2, + z: 1, + }, + { + id: "overview-stats", + widgetId: "overview-stats", + x: 0, + y: 2, + w: 12, + h: 2, + z: 1, + }, + { + id: "treasury-chart", + widgetId: "treasury-chart", + x: 0, + y: 4, + w: 8, + h: 6, + z: 1, + }, + { + id: "side-charts", + widgetId: "side-charts", + x: 8, + y: 4, + w: 4, + h: 5, + z: 1, + }, + { + id: "recent-transactions", + widgetId: "recent-transactions", + x: 0, + y: 10, + w: 8, + h: 5, + z: 1, + }, + { + id: "books-health", + widgetId: "books-health", + x: 8, + y: 9, + w: 4, + h: 4, + z: 1, + }, +]; + +function WorkspacePlaceholder({ + title, + detail, +}: { + title: string; + detail: string; +}) { + return ( +
+
+

{title}

+

{detail}

+
+

Planned module

+
+ ); +} + const Dashboard5 = ({ className, snapshot = MOCK_OVERVIEW, @@ -4772,35 +4852,68 @@ const Dashboard5 = ({ syncAll({ onTrustedSuccess: runJournalProcessing }); }, [isProcessingJournals, isSyncing, runJournalProcessing, syncAll]); const isRefreshingOverview = isSyncing || isProcessingJournals; - - return ( -
- setAddConnectionOpen(true)} - /> - - -
-
+ const workspaceWidgets = React.useMemo( + () => [ + { + id: "overview-readiness", + title: "Readiness", + description: "Sync, journal, and review status for this book.", + minW: 6, + minH: 1, + defaultW: 12, + defaultH: 2, + render: () => ( + setAddConnectionOpen(true)} + /> + ), + }, + { + id: "overview-stats", + title: "Key Metrics", + description: "Portfolio, review, and activity counters.", + minW: 6, + minH: 1, + defaultW: 12, + defaultH: 2, + render: () => ( + + ), + }, + { + id: "treasury-chart", + title: "Treasury Timeline", + description: "Balance, cost basis, price, and activity markers.", + minW: 6, + minH: 3, + defaultW: 8, + defaultH: 6, + render: () => ( + ), + }, + { + id: "recent-transactions", + title: "Recent Transactions", + description: "Latest transactions with review and flow filters.", + minW: 5, + minH: 3, + defaultW: 8, + defaultH: 5, + render: () => ( -
-
+ ), + }, + { + id: "side-charts", + title: "Holdings Breakdown", + description: "Source concentration and balance movement drivers.", + minW: 3, + minH: 2, + defaultW: 4, + defaultH: 5, + render: () => ( + ), + }, + { + id: "books-health", + title: "Book Health", + description: "Actionable sync, journal, and report readiness checks.", + minW: 3, + minH: 2, + defaultW: 4, + defaultH: 4, + render: () => ( -
-
+ ), + }, + { + id: "report-deadlines", + title: "Report Deadlines", + description: "Planned tax-year filing and export reminders.", + minW: 3, + minH: 2, + defaultW: 4, + defaultH: 3, + optional: true, + placeholder: true, + render: () => ( + + ), + }, + { + id: "source-funds-review", + title: "Source Funds Review", + description: "Planned source-of-funds queue and evidence status.", + minW: 3, + minH: 2, + defaultW: 4, + defaultH: 3, + optional: true, + placeholder: true, + render: () => ( + + ), + }, + { + id: "wallet-monitor", + title: "Wallet Monitor", + description: "Planned wallet sync, balances, and backend health module.", + minW: 3, + minH: 2, + defaultW: 4, + defaultH: 3, + optional: true, + placeholder: true, + render: () => ( + + ), + }, + ], + [ + currency, + hideSensitive, + isProcessingJournals, + isRefreshingOverview, + refreshOverviewState, + runJournalProcessing, + snapshot, + transactions, + ], + ); + + return ( +
+ +
); }; diff --git a/ui-tauri/src/components/kb/PageWorkspace.tsx b/ui-tauri/src/components/kb/PageWorkspace.tsx new file mode 100644 index 00000000..3c8de730 --- /dev/null +++ b/ui-tauri/src/components/kb/PageWorkspace.tsx @@ -0,0 +1,545 @@ +import * as React from "react"; +import { + Check, + GripHorizontal, + Plus, + RotateCcw, + SlidersHorizontal, + X, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + addWorkspaceWidget, + createWorkspaceLayout, + focusWorkspaceItem, + moveWorkspaceItem, + normalizeWorkspaceLayout, + pageWorkspaceStorageKey, + removeWorkspaceItem, + resizeWorkspaceItem, + workspaceLayoutHeight, + WORKSPACE_LAYOUT_COLUMNS, + WORKSPACE_LAYOUT_ROW_HEIGHT, + type WorkspaceLayoutItem, + type WorkspacePageLayout, + type WorkspaceResizeEdge, + type WorkspaceWidgetDefinition, +} from "@/lib/workspaceLayout"; +import { useUiStore } from "@/store/ui"; + +export type PageWorkspaceWidget = WorkspaceWidgetDefinition & { + render: () => React.ReactNode; +}; + +interface PageWorkspaceProps { + pageId: string; + title: string; + widgets: PageWorkspaceWidget[]; + defaultItems: WorkspaceLayoutItem[]; + className?: string; +} + +type WorkspacePointerOperation = + | { + type: "move"; + pointerId: number; + itemId: string; + startClientX: number; + startClientY: number; + startItem: WorkspaceLayoutItem; + startLayout: WorkspacePageLayout; + cellWidth: number; + rowHeight: number; + } + | { + type: "resize"; + pointerId: number; + itemId: string; + edge: WorkspaceResizeEdge; + startClientX: number; + startClientY: number; + startLayout: WorkspacePageLayout; + cellWidth: number; + rowHeight: number; + }; + +interface WorkspacePreview { + item: WorkspaceLayoutItem; + layout: WorkspacePageLayout; +} + +export function PageWorkspace({ + pageId, + title, + widgets, + defaultItems, + className, +}: PageWorkspaceProps) { + const identity = useUiStore((state) => state.identity); + const layoutKey = React.useMemo( + () => pageWorkspaceStorageKey(pageId, identity), + [identity, pageId], + ); + const storedLayout = useUiStore( + (state) => state.pageWorkspaceLayouts[layoutKey], + ); + const setPageWorkspaceLayout = useUiStore( + (state) => state.setPageWorkspaceLayout, + ); + const clearPageWorkspaceLayout = useUiStore( + (state) => state.clearPageWorkspaceLayout, + ); + const [editing, setEditing] = React.useState(false); + const [preview, setPreview] = React.useState(null); + const containerRef = React.useRef(null); + const operationRef = React.useRef(null); + const widgetById = React.useMemo( + () => new Map(widgets.map((widget) => [widget.id, widget])), + [widgets], + ); + const defaultLayout = React.useMemo( + () => createWorkspaceLayout(defaultItems), + [defaultItems], + ); + const layout = React.useMemo( + () => normalizeWorkspaceLayout(storedLayout ?? defaultLayout, widgets), + [defaultLayout, storedLayout, widgets], + ); + const previewLayoutRef = React.useRef(null); + const activeWidgetIds = new Set(layout.items.map((item) => item.widgetId)); + + const persistLayout = React.useCallback( + (next: WorkspacePageLayout) => { + setPageWorkspaceLayout(layoutKey, normalizeWorkspaceLayout(next, widgets)); + }, + [layoutKey, setPageWorkspaceLayout, widgets], + ); + + const measureGrid = React.useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect(); + return { + cellWidth: rect ? rect.width / WORKSPACE_LAYOUT_COLUMNS : 96, + rowHeight: WORKSPACE_LAYOUT_ROW_HEIGHT, + }; + }, []); + + const updatePreview = React.useCallback( + (next: WorkspacePageLayout, itemId: string) => { + const item = next.items.find((candidate) => candidate.id === itemId); + if (!item) return; + previewLayoutRef.current = next; + setPreview({ item, layout: next }); + }, + [], + ); + + const startMove = ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + ) => { + if (!editing || event.button !== 0) return; + const { cellWidth, rowHeight } = measureGrid(); + const startLayout = focusWorkspaceItem(layout, item.id); + operationRef.current = { + type: "move", + pointerId: event.pointerId, + itemId: item.id, + startClientX: event.clientX, + startClientY: event.clientY, + startItem: item, + startLayout, + cellWidth, + rowHeight, + }; + previewLayoutRef.current = startLayout; + updatePreview(startLayout, item.id); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const startResize = ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + edge: WorkspaceResizeEdge, + ) => { + if (!editing || event.button !== 0) return; + event.stopPropagation(); + const { cellWidth, rowHeight } = measureGrid(); + const startLayout = focusWorkspaceItem(layout, item.id); + operationRef.current = { + type: "resize", + pointerId: event.pointerId, + itemId: item.id, + edge, + startClientX: event.clientX, + startClientY: event.clientY, + startLayout, + cellWidth, + rowHeight, + }; + previewLayoutRef.current = startLayout; + updatePreview(startLayout, item.id); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const updatePointerOperation = (event: React.PointerEvent) => { + const operation = operationRef.current; + if (!operation || operation.pointerId !== event.pointerId) return; + const deltaX = Math.round( + (event.clientX - operation.startClientX) / operation.cellWidth, + ); + const deltaY = Math.round( + (event.clientY - operation.startClientY) / operation.rowHeight, + ); + const next = + operation.type === "move" + ? moveWorkspaceItem( + operation.startLayout, + operation.itemId, + operation.startItem.x + deltaX, + operation.startItem.y + deltaY, + widgets, + ) + : resizeWorkspaceItem( + operation.startLayout, + operation.itemId, + operation.edge, + deltaX, + deltaY, + widgets, + ); + updatePreview(next, operation.itemId); + }; + + const finishPointerOperation = (event: React.PointerEvent) => { + const operation = operationRef.current; + if (!operation || operation.pointerId !== event.pointerId) return; + operationRef.current = null; + if (previewLayoutRef.current) { + persistLayout(previewLayoutRef.current); + } + previewLayoutRef.current = null; + setPreview(null); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const addWidget = (widget: PageWorkspaceWidget) => { + persistLayout(addWorkspaceWidget(layout, widget)); + }; + + const removeWidget = (itemId: string) => { + persistLayout(removeWorkspaceItem(layout, itemId)); + }; + + const resetLayout = () => { + clearPageWorkspaceLayout(layoutKey); + setPreview(null); + previewLayoutRef.current = null; + }; + + const rows = Math.max( + workspaceLayoutHeight(layout), + preview ? workspaceLayoutHeight(preview.layout) : 0, + ); + const availableWidgets = widgets.filter((widget) => !activeWidgetIds.has(widget.id)); + + return ( +
+
+
+ {title} + {editing && Editing layout} +
+
+ {editing && ( + <> + + + + + + Widget palette + + {availableWidgets.length ? ( + availableWidgets.map((widget) => ( + addWidget(widget)} + > + {widget.title} + + {widget.description} + + + )) + ) : ( + + All widgets are on the page + + )} + + + + + )} + +
+
+
+ {layout.items.map((item) => { + const widget = widgetById.get(item.widgetId); + if (!widget) return null; + return ( + + {widget.render()} + + ); + })} + {editing && preview && } +
+
+ ); +} + +function WorkspaceItemFrame({ + editing, + item, + title, + placeholder, + children, + onMoveStart, + onPointerMove, + onPointerUp, + onResizeStart, + onRemove, +}: { + editing: boolean; + item: WorkspaceLayoutItem; + title: string; + placeholder?: boolean; + children: React.ReactNode; + onMoveStart: ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + ) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onResizeStart: ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + edge: WorkspaceResizeEdge, + ) => void; + onRemove: (itemId: string) => void; +}) { + return ( +
+
+
+ {children} +
+ {editing && ( + <> +
onMoveStart(event, item)} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > +
+ + + + + + + + )} +
+
+ ); +} + +function ResizeHandle({ + edge, + title, + item, + onStart, + onMove, + onEnd, +}: { + edge: WorkspaceResizeEdge; + title: string; + item: WorkspaceLayoutItem; + onStart: ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + edge: WorkspaceResizeEdge, + ) => void; + onMove: (event: React.PointerEvent) => void; + onEnd: (event: React.PointerEvent) => void; +}) { + const classes: Record = { + n: "left-4 right-4 top-0 h-2 cursor-ns-resize", + e: "bottom-4 right-0 top-4 w-2 cursor-ew-resize", + s: "bottom-0 left-4 right-4 h-2 cursor-ns-resize", + w: "bottom-4 left-0 top-4 w-2 cursor-ew-resize", + ne: "right-0 top-0 size-4 cursor-nesw-resize", + nw: "left-0 top-0 size-4 cursor-nwse-resize", + se: "bottom-0 right-0 size-5 cursor-nwse-resize", + sw: "bottom-0 left-0 size-4 cursor-nesw-resize", + }; + return ( +