From 2de2abe26d1bec65712e8390905a4ec4473a197b Mon Sep 17 00:00:00 2001 From: tobomobo <57799306+tobomobo@users.noreply.github.com> Date: Sat, 16 May 2026 22:17:05 +0200 Subject: [PATCH 1/4] Add modular overview workspace layout --- TODO.md | 4 + docs/plan/04-desktop-ui.md | 17 + docs/reference/desktop.md | 9 + ui-tauri/src/components/dashboard5.tsx | 265 ++++++++++-- ui-tauri/src/components/kb/PageWorkspace.tsx | 422 +++++++++++++++++++ ui-tauri/src/lib/workspaceLayout.test.ts | 105 +++++ ui-tauri/src/lib/workspaceLayout.ts | 323 ++++++++++++++ ui-tauri/src/store/ui.test.ts | 28 ++ ui-tauri/src/store/ui.ts | 22 + 9 files changed, 1167 insertions(+), 28 deletions(-) create mode 100644 ui-tauri/src/components/kb/PageWorkspace.tsx create mode 100644 ui-tauri/src/lib/workspaceLayout.test.ts create mode 100644 ui-tauri/src/lib/workspaceLayout.ts diff --git a/TODO.md b/TODO.md index 93cc02e3..28bd5926 100644 --- a/TODO.md +++ b/TODO.md @@ -296,6 +296,10 @@ 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 - [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..9d131791 100644 --- a/docs/plan/04-desktop-ui.md +++ b/docs/plan/04-desktop-ui.md @@ -75,6 +75,23 @@ 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. + ### 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..7b484631 100644 --- a/docs/reference/desktop.md +++ b/docs/reference/desktop.md @@ -52,6 +52,15 @@ 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. Its 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. 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. + 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..4c779979 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: 2, + defaultW: 12, + defaultH: 2, + render: () => ( + setAddConnectionOpen(true)} + /> + ), + }, + { + id: "overview-stats", + title: "Key Metrics", + description: "Portfolio, review, and activity counters.", + minW: 6, + minH: 2, + defaultW: 12, + defaultH: 2, + render: () => ( + + ), + }, + { + id: "treasury-chart", + title: "Treasury Timeline", + description: "Balance, cost basis, price, and activity markers.", + minW: 6, + minH: 4, + defaultW: 8, + defaultH: 6, + render: () => ( + ), + }, + { + id: "recent-transactions", + title: "Recent Transactions", + description: "Latest transactions with review and flow filters.", + minW: 5, + minH: 4, + defaultW: 8, + defaultH: 5, + render: () => ( -
-
+ ), + }, + { + id: "side-charts", + title: "Holdings Breakdown", + description: "Source concentration and balance movement drivers.", + minW: 3, + minH: 4, + defaultW: 4, + defaultH: 5, + render: () => ( + ), + }, + { + id: "books-health", + title: "Book Health", + description: "Actionable sync, journal, and report readiness checks.", + minW: 3, + minH: 3, + 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..4526f51d --- /dev/null +++ b/ui-tauri/src/components/kb/PageWorkspace.tsx @@ -0,0 +1,422 @@ +import * as React from "react"; +import { + GripHorizontal, + Plus, + RotateCcw, + SquareDashedMousePointer, + 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; + }; + +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 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 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 startMove = ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + ) => { + if (event.button !== 0) return; + const { cellWidth, rowHeight } = measureGrid(); + operationRef.current = { + type: "move", + pointerId: event.pointerId, + itemId: item.id, + startClientX: event.clientX, + startClientY: event.clientY, + startItem: item, + startLayout: focusWorkspaceItem(layout, item.id), + cellWidth, + rowHeight, + }; + event.currentTarget.setPointerCapture(event.pointerId); + persistLayout(focusWorkspaceItem(layout, item.id)); + }; + + const startResize = ( + event: React.PointerEvent, + item: WorkspaceLayoutItem, + edge: WorkspaceResizeEdge, + ) => { + if (event.button !== 0) return; + event.stopPropagation(); + const { cellWidth, rowHeight } = measureGrid(); + operationRef.current = { + type: "resize", + pointerId: event.pointerId, + itemId: item.id, + edge, + startClientX: event.clientX, + startClientY: event.clientY, + startLayout: focusWorkspaceItem(layout, item.id), + cellWidth, + rowHeight, + }; + event.currentTarget.setPointerCapture(event.pointerId); + persistLayout(focusWorkspaceItem(layout, item.id)); + }; + + 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, + ); + if (operation.type === "move") { + persistLayout( + moveWorkspaceItem( + operation.startLayout, + operation.itemId, + operation.startItem.x + deltaX, + operation.startItem.y + deltaY, + widgets, + ), + ); + return; + } + persistLayout( + resizeWorkspaceItem( + operation.startLayout, + operation.itemId, + operation.edge, + deltaX, + deltaY, + widgets, + ), + ); + }; + + const finishPointerOperation = (event: React.PointerEvent) => { + const operation = operationRef.current; + if (!operation || operation.pointerId !== event.pointerId) return; + operationRef.current = 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); + }; + + const rows = workspaceLayoutHeight(layout); + const availableWidgets = widgets.filter((widget) => !activeWidgetIds.has(widget.id)); + + return ( +
+
+
+
+
+ + + + + + 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()} + + ); + })} +
+
+ ); +} + +function WorkspaceItemFrame({ + item, + title, + placeholder, + children, + onMoveStart, + onPointerMove, + onPointerUp, + onResizeStart, + onRemove, +}: { + 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; +}) { + const edgeClassName = + "absolute z-20 bg-transparent transition-colors hover:bg-primary/10"; + return ( +
+
+
onMoveStart(event, item)} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > + + + +
+
+ {children} +
+
+
+ ); +} diff --git a/ui-tauri/src/lib/workspaceLayout.test.ts b/ui-tauri/src/lib/workspaceLayout.test.ts new file mode 100644 index 00000000..66f70bf1 --- /dev/null +++ b/ui-tauri/src/lib/workspaceLayout.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { + addWorkspaceWidget, + createWorkspaceLayout, + moveWorkspaceItem, + pageWorkspaceStorageKey, + resizeWorkspaceItem, + workspaceItemsOverlap, + type WorkspaceWidgetDefinition, +} from "./workspaceLayout"; + +const widgets: WorkspaceWidgetDefinition[] = [ + { + id: "summary", + title: "Summary", + description: "Book summary", + minW: 3, + minH: 2, + defaultW: 6, + defaultH: 2, + }, + { + id: "chart", + title: "Chart", + description: "Balance chart", + minW: 4, + minH: 3, + defaultW: 8, + defaultH: 5, + }, +]; + +describe("workspace layout", () => { + it("keys persisted layouts by book identity and page", () => { + const first = pageWorkspaceStorageKey("overview", { + workspace: "Holding Co", + profile: "Tax 2026", + taxCountry: "at", + fiatCurrency: "EUR", + }); + const second = pageWorkspaceStorageKey("transactions", { + workspace: "Holding Co", + profile: "Tax 2026", + taxCountry: "at", + fiatCurrency: "EUR", + }); + + expect(first).toBe("Holding Co:Tax 2026:at:EUR::overview"); + expect(second).toBe("Holding Co:Tax 2026:at:EUR::transactions"); + }); + + it("uses imported database path as the strongest book identity", () => { + expect( + pageWorkspaceStorageKey("overview", { + workspace: "Renamed books", + profile: "Renamed profile", + importedProject: { + database: "/Users/dev/.kassiber/data/kassiber.db", + }, + }), + ).toBe("/Users/dev/.kassiber/data/kassiber.db::overview"); + }); + + it("snaps dragged widgets to the grid and pushes collisions down", () => { + const layout = createWorkspaceLayout([ + { id: "a", widgetId: "summary", x: 0, y: 0, w: 6, h: 2, z: 1 }, + { id: "b", widgetId: "chart", x: 0, y: 3, w: 6, h: 4, z: 1 }, + ]); + + const moved = moveWorkspaceItem(layout, "b", 0, 0, widgets); + const first = moved.items.find((item) => item.id === "a"); + const second = moved.items.find((item) => item.id === "b"); + + expect(second).toMatchObject({ x: 0, y: 0, w: 6, h: 4 }); + expect(first).toMatchObject({ x: 0, y: 4, w: 6, h: 2 }); + expect(workspaceItemsOverlap(first!, second!)).toBe(false); + }); + + it("enforces minimum sizes while resizing from an edge", () => { + const layout = createWorkspaceLayout([ + { id: "a", widgetId: "chart", x: 4, y: 2, w: 6, h: 5, z: 1 }, + ]); + + const resized = resizeWorkspaceItem(layout, "a", "w", 4, 0, widgets); + + expect(resized.items[0]).toMatchObject({ x: 6, y: 2, w: 4, h: 5 }); + }); + + it("adds palette widgets below the current occupied workspace", () => { + const layout = createWorkspaceLayout([ + { id: "a", widgetId: "summary", x: 0, y: 0, w: 6, h: 2, z: 1 }, + { id: "b", widgetId: "chart", x: 6, y: 0, w: 6, h: 5, z: 1 }, + ]); + + const next = addWorkspaceWidget(layout, widgets[0], "summary-copy"); + + expect(next.items.find((item) => item.id === "summary-copy")).toMatchObject({ + x: 0, + y: 5, + w: 6, + h: 2, + }); + }); +}); diff --git a/ui-tauri/src/lib/workspaceLayout.ts b/ui-tauri/src/lib/workspaceLayout.ts new file mode 100644 index 00000000..865e50c2 --- /dev/null +++ b/ui-tauri/src/lib/workspaceLayout.ts @@ -0,0 +1,323 @@ +export const WORKSPACE_LAYOUT_VERSION = 1; +export const WORKSPACE_LAYOUT_COLUMNS = 12; +export const WORKSPACE_LAYOUT_ROW_HEIGHT = 96; + +export type WorkspaceResizeEdge = + | "n" + | "e" + | "s" + | "w" + | "ne" + | "nw" + | "se" + | "sw"; + +export interface WorkspaceLayoutItem { + id: string; + widgetId: string; + x: number; + y: number; + w: number; + h: number; + z: number; +} + +export interface WorkspacePageLayout { + version: typeof WORKSPACE_LAYOUT_VERSION; + columns: number; + rowHeight: number; + items: WorkspaceLayoutItem[]; +} + +export interface WorkspaceWidgetDefinition { + id: string; + title: string; + description: string; + minW: number; + minH: number; + defaultW: number; + defaultH: number; + optional?: boolean; + placeholder?: boolean; +} + +export interface WorkspaceIdentityLike { + name?: string; + profile?: string; + workspace?: string; + taxCountry?: string; + fiatCurrency?: string; + importedProject?: { + dataRoot?: string; + database?: string; + stateRoot?: string; + }; +} + +export function pageWorkspaceStorageKey( + pageId: string, + identity: WorkspaceIdentityLike | null | undefined, +) { + const bookKey = + identity?.importedProject?.database || + identity?.importedProject?.dataRoot || + [ + identity?.workspace || "local-books", + identity?.profile || identity?.name || "default-book", + identity?.taxCountry || "generic", + identity?.fiatCurrency || "EUR", + ].join(":"); + + return `${bookKey}::${pageId}`; +} + +export function createWorkspaceLayout( + items: WorkspaceLayoutItem[], +): WorkspacePageLayout { + return normalizeWorkspaceLayout({ + version: WORKSPACE_LAYOUT_VERSION, + columns: WORKSPACE_LAYOUT_COLUMNS, + rowHeight: WORKSPACE_LAYOUT_ROW_HEIGHT, + items, + }); +} + +export function normalizeWorkspaceLayout( + layout: WorkspacePageLayout, + widgets: WorkspaceWidgetDefinition[] = [], +) { + const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); + const seen = new Set(); + const items = layout.items + .filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return widgets.length === 0 || widgetById.has(item.widgetId); + }) + .map((item) => + clampWorkspaceItem(item, widgetById.get(item.widgetId), { + columns: layout.columns || WORKSPACE_LAYOUT_COLUMNS, + }), + ); + + return resolveWorkspaceCollisions({ + version: WORKSPACE_LAYOUT_VERSION, + columns: layout.columns || WORKSPACE_LAYOUT_COLUMNS, + rowHeight: layout.rowHeight || WORKSPACE_LAYOUT_ROW_HEIGHT, + items, + }); +} + +export function clampWorkspaceItem( + item: WorkspaceLayoutItem, + widget?: WorkspaceWidgetDefinition, + options: { columns?: number } = {}, +): WorkspaceLayoutItem { + const columns = options.columns ?? WORKSPACE_LAYOUT_COLUMNS; + const minW = widget?.minW ?? 2; + const minH = widget?.minH ?? 2; + const w = Math.min(columns, Math.max(minW, Math.round(item.w))); + const h = Math.max(minH, Math.round(item.h)); + return { + ...item, + x: Math.min(columns - w, Math.max(0, Math.round(item.x))), + y: Math.max(0, Math.round(item.y)), + w, + h, + z: Math.max(1, Math.round(item.z || 1)), + }; +} + +export function workspaceItemsOverlap( + a: Pick, + b: Pick, +) { + return ( + a.x < b.x + b.w && + a.x + a.w > b.x && + a.y < b.y + b.h && + a.y + a.h > b.y + ); +} + +export function resolveWorkspaceCollisions( + layout: WorkspacePageLayout, + activeItemId?: string, +) { + const items = layout.items.map((item) => ({ ...item })); + const maxPasses = Math.max(4, items.length * items.length); + + for (let pass = 0; pass < maxPasses; pass += 1) { + let changed = false; + const ordered = [...items].sort( + (a, b) => a.y - b.y || a.x - b.x || b.z - a.z, + ); + + for (let index = 0; index < ordered.length; index += 1) { + const current = ordered[index]; + for (let nextIndex = index + 1; nextIndex < ordered.length; nextIndex += 1) { + const next = ordered[nextIndex]; + if (!workspaceItemsOverlap(current, next)) continue; + + const itemToPush = + current.id === activeItemId + ? next + : next.id === activeItemId + ? current + : current.y <= next.y + ? next + : current; + const blocker = itemToPush.id === current.id ? next : current; + const newY = blocker.y + blocker.h; + + if (newY !== itemToPush.y) { + const stored = items.find((item) => item.id === itemToPush.id); + if (stored) stored.y = newY; + itemToPush.y = newY; + changed = true; + } + } + } + + if (!changed) break; + } + + return { + ...layout, + items: items.sort((a, b) => a.y - b.y || a.x - b.x || a.id.localeCompare(b.id)), + }; +} + +export function moveWorkspaceItem( + layout: WorkspacePageLayout, + itemId: string, + x: number, + y: number, + widgets: WorkspaceWidgetDefinition[] = [], +) { + const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); + const nextZ = nextWorkspaceZ(layout); + const items = layout.items.map((item) => + item.id === itemId + ? clampWorkspaceItem( + { ...item, x, y, z: nextZ }, + widgetById.get(item.widgetId), + { columns: layout.columns }, + ) + : item, + ); + return resolveWorkspaceCollisions({ ...layout, items }, itemId); +} + +export function resizeWorkspaceItem( + layout: WorkspacePageLayout, + itemId: string, + edge: WorkspaceResizeEdge, + deltaX: number, + deltaY: number, + widgets: WorkspaceWidgetDefinition[] = [], +) { + const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); + const nextZ = nextWorkspaceZ(layout); + const items = layout.items.map((item) => { + if (item.id !== itemId) return item; + const widget = widgetById.get(item.widgetId); + const minW = widget?.minW ?? 2; + const minH = widget?.minH ?? 2; + let { x, y, w, h } = item; + + if (edge.includes("e")) w += deltaX; + if (edge.includes("s")) h += deltaY; + if (edge.includes("w")) { + x += deltaX; + w -= deltaX; + } + if (edge.includes("n")) { + y += deltaY; + h -= deltaY; + } + + if (w < minW) { + if (edge.includes("w")) x -= minW - w; + w = minW; + } + if (h < minH) { + if (edge.includes("n")) y -= minH - h; + h = minH; + } + + return clampWorkspaceItem( + { ...item, x, y, w, h, z: nextZ }, + widget, + { columns: layout.columns }, + ); + }); + return resolveWorkspaceCollisions({ ...layout, items }, itemId); +} + +export function focusWorkspaceItem(layout: WorkspacePageLayout, itemId: string) { + const nextZ = nextWorkspaceZ(layout); + return { + ...layout, + items: layout.items.map((item) => + item.id === itemId ? { ...item, z: nextZ } : item, + ), + }; +} + +export function removeWorkspaceItem( + layout: WorkspacePageLayout, + itemId: string, +) { + return { + ...layout, + items: layout.items.filter((item) => item.id !== itemId), + }; +} + +export function createWorkspaceItemFromWidget( + layout: WorkspacePageLayout, + widget: WorkspaceWidgetDefinition, + id = `${widget.id}-${Date.now().toString(36)}`, +) { + const y = layout.items.reduce( + (bottom, item) => Math.max(bottom, item.y + item.h), + 0, + ); + return clampWorkspaceItem( + { + id, + widgetId: widget.id, + x: 0, + y, + w: widget.defaultW, + h: widget.defaultH, + z: nextWorkspaceZ(layout), + }, + widget, + { columns: layout.columns }, + ); +} + +export function addWorkspaceWidget( + layout: WorkspacePageLayout, + widget: WorkspaceWidgetDefinition, + id?: string, +) { + return resolveWorkspaceCollisions({ + ...layout, + items: [...layout.items, createWorkspaceItemFromWidget(layout, widget, id)], + }); +} + +export function workspaceLayoutHeight(layout: WorkspacePageLayout) { + const rows = layout.items.reduce( + (bottom, item) => Math.max(bottom, item.y + item.h), + 0, + ); + return Math.max(4, rows); +} + +function nextWorkspaceZ(layout: WorkspacePageLayout) { + return layout.items.reduce((max, item) => Math.max(max, item.z), 0) + 1; +} diff --git a/ui-tauri/src/store/ui.test.ts b/ui-tauri/src/store/ui.test.ts index 970dd621..dc5a2a4d 100644 --- a/ui-tauri/src/store/ui.test.ts +++ b/ui-tauri/src/store/ui.test.ts @@ -69,6 +69,34 @@ describe("UI persistence", () => { expect(encoded).not.toContain("logEntries"); }); + it("persists page workspace layouts separately from transient UI state", () => { + const state = { + ...useUiStore.getState(), + pageWorkspaceLayouts: { + "My Books:Tax:at:EUR::overview": { + version: 1 as const, + columns: 12, + rowHeight: 96, + items: [ + { + id: "chart", + widgetId: "treasury-chart", + x: 0, + y: 0, + w: 8, + h: 6, + z: 2, + }, + ], + }, + }, + }; + + const persisted = uiStatePartialForStorage(state); + + expect(persisted.pageWorkspaceLayouts).toEqual(state.pageWorkspaceLayouts); + }); + it("normalizes persisted UI scale to the supported menu range", () => { expect(normalizeAppScale(0.93)).toBe(0.95); expect(normalizeAppScale(0.1)).toBe(MIN_APP_SCALE); diff --git a/ui-tauri/src/store/ui.ts b/ui-tauri/src/store/ui.ts index da95c0ed..9a58ebde 100644 --- a/ui-tauri/src/store/ui.ts +++ b/ui-tauri/src/store/ui.ts @@ -5,6 +5,7 @@ import { DEFAULT_EXPLORER_SETTINGS, type ExplorerSettings, } from "@/lib/explorer"; +import type { WorkspacePageLayout } from "@/lib/workspaceLayout"; type Lang = "en" | "de"; type Currency = "btc" | "eur"; @@ -158,6 +159,7 @@ export interface UiState { notifications: AppNotification[]; logEntries: AppLogEntry[]; sourceFundsDrafts: Record; + pageWorkspaceLayouts: Record; deferredConnectionSetup: DeferredConnectionSetup | null; setLang: (lang: Lang) => void; setCurrency: (currency: Currency) => void; @@ -187,6 +189,8 @@ export interface UiState { clearLogEntries: () => void; setSourceFundsDraft: (profileKey: string, draft: SourceFundsDraft) => void; clearSourceFundsDraft: (profileKey: string) => void; + setPageWorkspaceLayout: (layoutKey: string, layout: WorkspacePageLayout) => void; + clearPageWorkspaceLayout: (layoutKey: string) => void; setDeferredConnectionSetup: (intent: DeferredConnectionSetup | null) => void; clearDeferredConnectionSetup: () => void; } @@ -249,6 +253,7 @@ export function uiStatePartialForStorage(state: UiState) { daemonSession: state.daemonSession, notifications: stripNotificationProgress(state.notifications), sourceFundsDrafts: state.sourceFundsDrafts, + pageWorkspaceLayouts: state.pageWorkspaceLayouts, }; } @@ -270,6 +275,7 @@ export const useUiStore = create()( notifications: [], logEntries: [], sourceFundsDrafts: {}, + pageWorkspaceLayouts: {}, setLang: (lang) => set({ lang }), setCurrency: (currency) => set({ currency }), setDataMode: (dataMode) => set({ dataMode }), @@ -386,6 +392,20 @@ export const useUiStore = create()( delete next[profileKey]; return { sourceFundsDrafts: next }; }), + setPageWorkspaceLayout: (layoutKey, layout) => + set((state) => ({ + pageWorkspaceLayouts: { + ...state.pageWorkspaceLayouts, + [layoutKey]: layout, + }, + })), + clearPageWorkspaceLayout: (layoutKey) => + set((state) => { + if (!(layoutKey in state.pageWorkspaceLayouts)) return state; + const next = { ...state.pageWorkspaceLayouts }; + delete next[layoutKey]; + return { pageWorkspaceLayouts: next }; + }), deferredConnectionSetup: null, setDeferredConnectionSetup: (intent) => set({ deferredConnectionSetup: intent }), @@ -425,6 +445,8 @@ export const useUiStore = create()( ), sourceFundsDrafts: restored.sourceFundsDrafts ?? current.sourceFundsDrafts, + pageWorkspaceLayouts: + restored.pageWorkspaceLayouts ?? current.pageWorkspaceLayouts, logEntries: current.logEntries, }; }, From 804c004b4110e4b15f794b6f6698723f3d2f675e Mon Sep 17 00:00:00 2001 From: tobomobo <57799306+tobomobo@users.noreply.github.com> Date: Sat, 16 May 2026 23:04:42 +0200 Subject: [PATCH 2/4] Refine workspace edit mode interactions --- ui-tauri/src/components/dashboard5.tsx | 12 +- ui-tauri/src/components/kb/PageWorkspace.tsx | 397 ++++++++++++------- ui-tauri/src/lib/workspaceLayout.ts | 4 +- ui-tauri/src/store/ui.test.ts | 2 +- ui-tauri/src/styles/globals.css | 21 + 5 files changed, 290 insertions(+), 146 deletions(-) diff --git a/ui-tauri/src/components/dashboard5.tsx b/ui-tauri/src/components/dashboard5.tsx index 4c779979..12f61225 100644 --- a/ui-tauri/src/components/dashboard5.tsx +++ b/ui-tauri/src/components/dashboard5.tsx @@ -4859,7 +4859,7 @@ const Dashboard5 = ({ title: "Readiness", description: "Sync, journal, and review status for this book.", minW: 6, - minH: 2, + minH: 1, defaultW: 12, defaultH: 2, render: () => ( @@ -4878,7 +4878,7 @@ const Dashboard5 = ({ title: "Key Metrics", description: "Portfolio, review, and activity counters.", minW: 6, - minH: 2, + minH: 1, defaultW: 12, defaultH: 2, render: () => ( @@ -4894,7 +4894,7 @@ const Dashboard5 = ({ title: "Treasury Timeline", description: "Balance, cost basis, price, and activity markers.", minW: 6, - minH: 4, + minH: 3, defaultW: 8, defaultH: 6, render: () => ( @@ -4910,7 +4910,7 @@ const Dashboard5 = ({ title: "Recent Transactions", description: "Latest transactions with review and flow filters.", minW: 5, - minH: 4, + minH: 3, defaultW: 8, defaultH: 5, render: () => ( @@ -4928,7 +4928,7 @@ const Dashboard5 = ({ title: "Holdings Breakdown", description: "Source concentration and balance movement drivers.", minW: 3, - minH: 4, + minH: 2, defaultW: 4, defaultH: 5, render: () => ( @@ -4944,7 +4944,7 @@ const Dashboard5 = ({ title: "Book Health", description: "Actionable sync, journal, and report readiness checks.", minW: 3, - minH: 3, + minH: 2, defaultW: 4, defaultH: 4, render: () => ( diff --git a/ui-tauri/src/components/kb/PageWorkspace.tsx b/ui-tauri/src/components/kb/PageWorkspace.tsx index 4526f51d..2c91e047 100644 --- a/ui-tauri/src/components/kb/PageWorkspace.tsx +++ b/ui-tauri/src/components/kb/PageWorkspace.tsx @@ -1,9 +1,10 @@ import * as React from "react"; import { + Check, GripHorizontal, Plus, RotateCcw, - SquareDashedMousePointer, + SlidersHorizontal, X, } from "lucide-react"; @@ -72,6 +73,11 @@ type WorkspacePointerOperation = rowHeight: number; }; +interface WorkspacePreview { + item: WorkspaceLayoutItem; + layout: WorkspacePageLayout; +} + export function PageWorkspace({ pageId, title, @@ -93,6 +99,8 @@ export function PageWorkspace({ 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( @@ -107,6 +115,7 @@ export function PageWorkspace({ () => 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( @@ -124,12 +133,23 @@ export function PageWorkspace({ }; }, []); + 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 (event.button !== 0) return; + if (!editing || event.button !== 0) return; const { cellWidth, rowHeight } = measureGrid(); + const startLayout = focusWorkspaceItem(layout, item.id); operationRef.current = { type: "move", pointerId: event.pointerId, @@ -137,12 +157,13 @@ export function PageWorkspace({ startClientX: event.clientX, startClientY: event.clientY, startItem: item, - startLayout: focusWorkspaceItem(layout, item.id), + startLayout, cellWidth, rowHeight, }; + previewLayoutRef.current = startLayout; + updatePreview(startLayout, item.id); event.currentTarget.setPointerCapture(event.pointerId); - persistLayout(focusWorkspaceItem(layout, item.id)); }; const startResize = ( @@ -150,9 +171,10 @@ export function PageWorkspace({ item: WorkspaceLayoutItem, edge: WorkspaceResizeEdge, ) => { - if (event.button !== 0) return; + 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, @@ -160,12 +182,13 @@ export function PageWorkspace({ edge, startClientX: event.clientX, startClientY: event.clientY, - startLayout: focusWorkspaceItem(layout, item.id), + startLayout, cellWidth, rowHeight, }; + previewLayoutRef.current = startLayout; + updatePreview(startLayout, item.id); event.currentTarget.setPointerCapture(event.pointerId); - persistLayout(focusWorkspaceItem(layout, item.id)); }; const updatePointerOperation = (event: React.PointerEvent) => { @@ -177,34 +200,35 @@ export function PageWorkspace({ const deltaY = Math.round( (event.clientY - operation.startClientY) / operation.rowHeight, ); - if (operation.type === "move") { - persistLayout( - moveWorkspaceItem( - operation.startLayout, - operation.itemId, - operation.startItem.x + deltaX, - operation.startItem.y + deltaY, - widgets, - ), - ); - return; - } - persistLayout( - resizeWorkspaceItem( - operation.startLayout, - operation.itemId, - operation.edge, - deltaX, - deltaY, - widgets, - ), - ); + 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); } @@ -220,57 +244,94 @@ export function PageWorkspace({ const resetLayout = () => { clearPageWorkspaceLayout(layoutKey); + setPreview(null); + previewLayoutRef.current = null; }; - const rows = workspaceLayoutHeight(layout); + const rows = Math.max( + workspaceLayoutHeight(layout), + preview ? workspaceLayoutHeight(preview.layout) : 0, + ); const availableWidgets = widgets.filter((widget) => !activeWidgetIds.has(widget.id)); return (
-
- - - + + + Widget palette + + {availableWidgets.length ? ( + availableWidgets.map((widget) => ( + addWidget(widget)} + > + {widget.title} + + {widget.description} + + + )) + ) : ( + + All widgets are on the page + + )} + + + - - - Widget palette - - {availableWidgets.length ? ( - availableWidgets.map((widget) => ( - addWidget(widget)} - > - {widget.title} - - {widget.description} - - - )) - ) : ( - All widgets are on the page - )} - - -
{layout.items.map((item) => { @@ -279,6 +340,7 @@ export function PageWorkspace({ return ( ); })} + {editing && preview && }
); } function WorkspaceItemFrame({ + editing, item, title, placeholder, @@ -308,6 +372,7 @@ function WorkspaceItemFrame({ onResizeStart, onRemove, }: { + editing: boolean; item: WorkspaceLayoutItem; title: string; placeholder?: boolean; @@ -325,11 +390,9 @@ function WorkspaceItemFrame({ ) => void; onRemove: (itemId: string) => void; }) { - const edgeClassName = - "absolute z-20 bg-transparent transition-colors hover:bg-primary/10"; return (
-
onMoveStart(event, item)} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} - > - - - -
-
+
{children}
- + + + + + + + )}
); } + +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 ( +