Conversation
The dashboard and reports pages were slow because stats-heavy views issued many redundant requests and refetched already-cached data on every visit. Statistics are now shared across the components that display them, so the stats endpoint is requested once per view instead of once per component, with a short client cache and request de-duplication. They refresh automatically when a transaction is added, edited, or deleted. Reports now render from server-computed aggregates immediately and load the detailed transaction data needed for the calendar and trends in the background, and no longer fetch a full previous period just to compare. Initial sign-in data loading no longer throws away a warm cache and loads shared data and transactions at the same time rather than one after another.
The dashboard waited for one large statistics response before anything appeared, so the first load felt slow. It now requests the statistics in sections and fills the dashboard in as each arrives: the headline totals paint first from the cheapest section, then the category breakdown, then the insights, each keeping its own skeleton until its data lands. Repeat requests and concurrent consumers still share one fetch per section.
One chat surface: history, attachments, widget-rendered answers, and a side canvas for reports.
List recent imports to open or delete, and link the chat card to a specific live review.
Chat is the default landing after login, with a setting to use the dashboard instead.
Neutral, unified nav and top-bar icons; the assistant moves to /assistant with a redirect.
Locale strings for the assistant, imports and canvas across supported languages.
The assistant landing now greets you with a snapshot of your finances instead of a blank box. A swipeable panel surfaces this period's net flow, savings rate, top categories and recent activity, with a rotating highlight that doubles as a question you can ask. It carves out the content area against a softly coloured background and fills the card edge to edge. Starter prompts are now drawn at random from a large pool on each visit so the empty state stays fresh, and each prompt carries a matching icon. The message composer reads as one clean box whether it sits in the middle or docked at the bottom, an ongoing conversation gains a subtle finance backdrop instead of a flat page, and the chat history sidebar picks up a light tint.
The dashboard now opens with an assistant section: a greeting, a free text ask box, and quick actions to log a transaction, transfer money, build a report, or import a document. Logging and transfers save directly and stay on the dashboard; reports and imports hand the request to the chat. The wallet and period controls moved into a stats bar below it, and the assistant section can be dismissed for the session and returns on refresh. In the chat, the assistant can present more report widgets (callouts, timelines, progress, comparisons, lists, quick actions, and a question with selectable answers), and a generated report now shows a preview inline instead of only a card. Quick action popups always show their submit button, and slow responses keep showing progress instead of prematurely claiming the response did not finish. The advanced importer uses the shared icon set for visual consistency.
Reports now load noticeably faster and no longer hide charts while data is still arriving. - Report transaction pages are fetched concurrently instead of one at a time, so wide date ranges render much sooner. - Per-category spending trends are built in a single pass rather than rescanning every transaction once per category. - The charts and other report tabs stay visible whenever chart data exists, with a loading state instead of a flash of the empty screen. - A notice appears when a period has more transactions than can be charted at once, so partial figures are not mistaken for complete ones. - Amounts that could not be converted to the default currency are now flagged, so cross-currency totals are no longer silently understated. The transactions spreadsheet streams rows in chunks: the first rows and running totals appear immediately and grow as the rest load. Columns can be sorted, the current view can be exported to a CSV file, and search is debounced so typing stays smooth on large sets.
- Add the Financial Position screen: net worth, a money in/out ledger by intent, and a drill-down into the underlying transactions. - Add a Holdings screen and form backed by the holdings API, with live crypto pricing and manual assets; holdings feed total net worth. - Add an intent selector to the transaction form so income and expenses can be tagged as loans, debt, investments or gifts. - Refine navigation: the dashboard is the default landing, the AI assistant is one entry with a Discussions sub-item, and less-used items move down. - Let budgets be created in any supported currency, not just the currencies of existing wallets. - Use Solar duotone icons (via Iconify) for the assistant landing stat cards so the large art reads richly instead of as thin lines.
Add an admin area, reached from the avatar menu when the signed-in user is an administrator. It has its own left-rail shell with an overview dashboard (KPIs, charts and rankings), demographics, a searchable user table whose rows open a detailed profile, and outreach: a history of sent emails plus a composer with a formatted (markdown) body, a header image, attachments, audience or specific-user targeting, and a live preview of the rendered email. Refresh the marketing surfaces to match the assistant-first direction: the login slides now lead with the assistant and bank integrations, the onboarding flow gets a brand-washed background and illustrated steps with a country step, and the emoji and stripe cards in onboarding and the learning modal are replaced with vector icons and clean surfaces.
The user country preference now uses the key "country" instead of "default-country". Nothing reads from a non-default country, so the default- prefix was misleading next to the other locale preferences.
The client now renders UI that backend integrations describe, with no per-plugin code for the common cases. Installed integrations are loaded after login and surfaced into named slots (settings integrations panel and tab bar, onboarding steps, dashboard widgets, sidebar nav). A descriptor renderer draws the common cases (card, connect, gated panel, onboarding step) from a fixed catalog; when a descriptor names a component, a plugin Nuxt layer can register a real one for that slot as an escape hatch. Onboarding is now a data-driven step list: built-in steps and plugin steps merge by order instead of hardcoded branches. Contributions are gated on the server's entitlement decision and shown with a needs-setup state when not yet configured.
Pie and donut charts silently came out as bars: the standard analytics rows carry identifier and transaction-count columns alongside the value, and those were counted as extra data series, which tipped the component into its multi-series bar fallback (and could even have plotted the id). Chart data now ignores identifier and count columns when a real value column is present, so a single-total breakdown renders in the shape the assistant asked for. Count-only data still charts, since the count is then the value.
A multi-step assistant turn showed only animated dots for its whole duration, which on a slow turn reads as a stall. The chat now lists the steps the assistant is taking (looking through records, crunching the numbers, putting the report together) as they arrive on each poll, so a long turn shows visible progress instead of a blank wait. Once the answer lands the steps give way to the result as before.
The chat now subscribes to its session's private channel and applies each progress step and the final answer the instant they are pushed, instead of depending on the 3-second poll. When Reverb is not configured or the socket is unavailable (including unit tests), it falls back to the existing polling unchanged, so nothing regresses.
Printing a report came out unstyled, with the browser's page-title and URL header in the margins and a blank page of space after the heading. It now prints the live, already-styled report in place: a clone of the report is shown only for printing while the rest of the page is hidden, so it uses the styles already loaded on screen. The page margin is zeroed so the browser omits its own header and footer, the report supplies its own padding, and the cloned content's height is reset so it flows to its content instead of reserving a whole empty page. It prints in the light theme regardless of the on-screen one.
Users can now generate, view, and revoke the access tokens an external AI client needs to reach the MCP server, and copy the server endpoint URL. The new token is shown once on creation.
Code Review SummaryA comprehensive update promoting v2.0.0-beta.1 (Ailanthus). This release focuses on AI integration, real-time streaming, and extension slots for plugin-based UI architecture. 🚀 Key Improvements
💡 Minor Suggestions
|
| // Real-time transport. When Reverb is configured, chat turns push their | ||
| // progress and completion over a private channel; polling stays on as the | ||
| // reconnect fallback. | ||
| let nuxtApp: ReturnType<typeof useNuxtApp> | null = null; |
There was a problem hiding this comment.
Wrap the useNuxtApp call in process.client or a more specific check. While the try/catch handles the error, useNuxtApp can only be reliably called inside a Nuxt context. If this composable is used during SSR, $echo might be undefined anyway.
| let nuxtApp: ReturnType<typeof useNuxtApp> | null = null; | |
| let nuxtApp: ReturnType<typeof useNuxtApp> | null = null; | |
| if (import.meta.client) { | |
| try { | |
| nuxtApp = useNuxtApp(); | |
| } catch (e) { | |
| // Fallback handled via null check | |
| } | |
| } |
| const first = await fetchPage(1); | ||
| const lastPage = Math.max(1, first.last_page || 1); | ||
| const targetPages = Math.min(lastPage, maxPages); | ||
|
|
||
| // Index pages by (page - 1) so the final concat preserves server order | ||
| // regardless of which request resolves first. | ||
| const pages: any[][] = new Array(targetPages); | ||
| pages[0] = first.data || []; | ||
|
|
||
| if (targetPages > 1) { | ||
| const queue: number[] = []; | ||
| for (let p = 2; p <= targetPages; p++) queue.push(p); | ||
|
|
||
| let cursor = 0; | ||
| const worker = async () => { | ||
| while (cursor < queue.length) { | ||
| const p = queue[cursor++]; | ||
| const resp = await fetchPage(p); |
There was a problem hiding this comment.
The concurrency limit of 6 is reasonable, but since this runs in a Promise.all inside a loop, ensure that if one page fails, the entire process doesn't halt unless intended. You might want to wrap fetchPage(p) in an individual try/catch within the worker.
| const first = await fetchPage(1); | |
| const lastPage = Math.max(1, first.last_page || 1); | |
| const targetPages = Math.min(lastPage, maxPages); | |
| // Index pages by (page - 1) so the final concat preserves server order | |
| // regardless of which request resolves first. | |
| const pages: any[][] = new Array(targetPages); | |
| pages[0] = first.data || []; | |
| if (targetPages > 1) { | |
| const queue: number[] = []; | |
| for (let p = 2; p <= targetPages; p++) queue.push(p); | |
| let cursor = 0; | |
| const worker = async () => { | |
| while (cursor < queue.length) { | |
| const p = queue[cursor++]; | |
| const resp = await fetchPage(p); | |
| try { | |
| const resp = await fetchPage(p); | |
| pages[p - 1] = resp.data || []; | |
| } catch (e) { | |
| console.error(`Failed to fetch page ${p}`, e); | |
| pages[p - 1] = []; | |
| } |
| return /["\n,]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; | ||
| }; | ||
|
|
||
| const exportCsv = () => { |
There was a problem hiding this comment.
Generating a CSV export by concatenating strings in memory for potentially 6,000+ rows can be slow and cause UI jank. For larger datasets, consider using a Web Worker to build the Blob or a library that handles streaming.
| const exportCsv = () => { | |
| const exportCsv = () => { | |
| // Consider moving this logic to a Worker if filteredRows.length > 2000 | |
| const headers = [ ... ]; |
Deploying trakli-dev with
|
| Latest commit: |
69ccd0f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://847d465e.trakli-dev.pages.dev |
|
|
||
| // Anchor the popover just above the + button (fixed-positioned so the composer's | ||
| // rounded overflow can't clip it). | ||
| const openSheet = () => { |
There was a problem hiding this comment.
The popover anchoring logic assumes window is available but doesn't handle viewport boundary collisions gracefully if the width exceeds 300px on very small screens. Also, using fixed positioning without a resize listener inside this function specifically might lead to the popover detaching if the keyboard appears/disappears on mobile.
| const openSheet = () => { | |
| const openSheet = () => { | |
| const r = attachBtnRef.value?.getBoundingClientRect(); | |
| if (r && import.meta.client) { | |
| const width = Math.min(300, window.innerWidth - 16); | |
| const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8)); | |
| popoverStyle.value = { | |
| position: 'fixed', | |
| left: `${left}px`, | |
| bottom: `${window.innerHeight - r.top + 8}px`, | |
| width: `${width}px`, | |
| maxWidth: 'calc(100vw - 16px)' | |
| }; | |
| } | |
| sheetOpen.value = true; | |
| }; |
| return Object.keys(first).find((k) => typeof first[k] === 'string') ?? null; | ||
| }); | ||
|
|
||
| const numericKeys = computed<string[]>(() => { |
There was a problem hiding this comment.
Excluding keys ending in '_id' is good, but strictly excluding 'percentage' might be too restrictive if the AI intentionally provides a dataset where the percentage is the primary metric to be charted (e.g., probability distribution).
| const numericKeys = computed<string[]>(() => { | |
| const numericKeys = computed<string[]>(() => { | |
| const first = rows.value[0]; | |
| if (!first) return []; | |
| const numeric = Object.keys(first).filter( | |
| (k) => typeof first[k] === 'number' && !/(^|_)id$/i.test(k) | |
| ); | |
| const values = numeric.filter((k) => !/(^|_)count$/i.test(k) && k !== 'percentage'); |
| let searchTimer: ReturnType<typeof setTimeout> | null = null; | ||
| watch(searchInput, (value) => { |
There was a problem hiding this comment.
The search watch fires on every keystroke with a 200ms debounce. If the user clears the input, we can skip the timer and reset immediately for better perceived performance.
| let searchTimer: ReturnType<typeof setTimeout> | null = null; | |
| watch(searchInput, (value) => { | |
| watch(searchInput, (value) => { | |
| if (searchTimer) clearTimeout(searchTimer); | |
| if (!value) { | |
| search.value = ''; | |
| return; | |
| } | |
| searchTimer = setTimeout(() => { | |
| search.value = value; | |
| }, 200); | |
| }); |
Deploying webui with
|
| Latest commit: |
69ccd0f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f791951b.webui-9fh.pages.dev |
| Branch Preview URL: | https://dev.webui-9fh.pages.dev |
First beta of the v2.0.0 line, codenamed Ailanthus, the tree of heaven and the first in the tree-name series (fittingly, its name starts with "AI" for this AI-forward beta). Promotes the accumulated
devwork tomainfor a beta.Headline changes since the last release:
The release workflow generates the full changelog when the tag is cut.