Introduction
In this part, we'll integrate the finance dashboard we bootstrapped and the improved analyzer model into our application. Specifically, we will integrate the WebSocket-backed server with our SvelteKit frontend.
Prerequisite
It's assumed you have completed the previous parts of this series.
Live version
Source code
An AI-powered financial behavior analyzer and advisor written in Python (aiohttp) and TypeScript (ExpressJS & SvelteKit with Svelte 5)
Implementation
Step 1: WebSocket service
To kick things off, we'll create a reusable WebSocket service. This service will handle the communication between our SvelteKit frontend and the backend server:
export enum NEEDEDDATA {
ANALYSIS = 'analyze',
SUMMARY = 'summary'
}
export class WebSocketService {
public socket: WebSocket;
private url: string;
private userId: string;
private neededData: NEEDEDDATA[] = [];
constructor(url: string, userId: string, neededData: NEEDEDDATA[]) {
this.url = url;
this.userId = userId;
this.neededData = neededData;
this.socket = new WebSocket(this.url);
this.connect();
}
private connect() {
this.socket = new WebSocket(this.url);
this.socket.onopen = this.onOpen;
this.socket.onmessage = this.onMessage;
this.socket.onclose = this.handleClose;
this.socket.onerror = this.onError;
}
private onOpen = (event: Event) => {
console.log('WebSocket connection opened:', event);
const messages = this.neededData.map((data) => ({
action: data,
userId: this.userId
}));
this.socket.send(JSON.stringify(messages));
};
public onMessage = (event: MessageEvent) => {
// const data = JSON.parse(event.data);
console.log('WebSocket message received:', event);
};
private onError = (event: Event) => {
console.error('WebSocket error:', event);
};
public close() {
this.userId = '';
this.neededData = [];
// Use 1000 code for normal closure
this.socket.close(1000, 'Normal closure');
this.socket.onopen = null;
this.socket.onmessage = null;
this.socket.onclose = null;
this.socket.onerror = null;
}
private handleClose = (event: CloseEvent) => {
console.log('WebSocket connection closed:', event);
this.close();
};
}
As mentioned earlier, we currently support two primary operations: analyze
and summary
. This WebSocket service abstracts the underlying complexities of `WebSocket` communication, providing a clean and manageable interface for our SvelteKit components. We'll be focusing on modifying the onMessage
method within these components to handle incoming data effectively.
Step 2: Using the WebSocket in components
Let's see how the service is used in a component:
<script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/state';
import BehaviouralInsights from '$lib/components/transactions/BehaviouralInsights.svelte';
import FinanceChart from '$lib/components/transactions/FinanceChart.svelte';
import Summary from '$lib/components/transactions/Summary.svelte';
import Transactions from '$lib/components/transactions/Transactions.svelte';
import type { SpendingReport, FinancialSummary } from '$lib/types/transaction.types';
import { getFirstName } from '$lib/utils/helpers/name.helpers';
import { onDestroy, onMount } from 'svelte';
import type { PageData } from './$types';
import MonthlySummary from '$lib/components/transactions/MonthlySummary.svelte';
import Add from '$lib/components/icons/Add.svelte';
import Bar from '$lib/components/icons/Bar.svelte';
import { BASE_WS_URI } from '$lib/utils/contants';
import { NEEDEDDATA, WebSocketService } from '$lib/services/websocket';
import type { ProgressSteps } from '$lib/types/notification.types';
import AnimatedContainer from '$lib/components/animations/AnimatedContainer.svelte';
import AnimatedSection from '$lib/components/animations/AnimatedSection.svelte';
let { data }: { data: PageData } = $props();
let transAnalysis: SpendingReport = $state({} as SpendingReport),
loadingAnalysis = $state(true),
loadingSummary = $state(true),
finance = $state({} as FinancialSummary),
loadingSummaryProgress: ProgressSteps[] = $state([]),
loadingAnalysisProgress: ProgressSteps[] = $state([]),
webSocketService: WebSocketService;
onMount(() => {
if (browser) {
webSocketService = new WebSocketService(`${BASE_WS_URI}`, data.user?._id || '', [
NEEDEDDATA.SUMMARY,
NEEDEDDATA.ANALYSIS
]);
webSocketService.socket.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.action) {
case 'progress':
if (data.taskType === 'Summarize') {
loadingSummaryProgress.push({
progress: data.progress,
message: data.message
});
} else if (data.taskType === 'Analysis') {
loadingAnalysisProgress.push({
progress: data.progress,
message: data.message
});
}
break;
case 'summary_complete':
finance = data.result;
loadingSummary = false;
break;
case 'analysis_complete':
transAnalysis = data.result;
loadingAnalysis = false;
break;
default:
break;
}
};
}
});
onDestroy(() => {
if (
webSocketService &&
(webSocketService.socket.readyState === WebSocket.OPEN ||
webSocketService.socket.readyState === WebSocket.CONNECTING)
) {
webSocketService.close();
}
});
</script>
<svelte:head>
<title>Dashboard | {page.data.user?.name}</title>
<meta name="description" content="Track and analyze your financial movements" />
<meta name="keywords" content="transactions, financial movements, financial analysis" />
</svelte:head>
<AnimatedContainer class="space-y-6">
<!-- Welcome Section -->
<AnimatedSection
y={20}
class="flex flex-col space-y-4 rounded-lg bg-white p-4 shadow-xs sm:p-6 dark:bg-gray-800"
>
<div class="flex flex-col items-center space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4">
<img
src={page.data.user?.avatar}
alt={page.data.user?.name}
class="h-12 w-12 rounded-full ring-2 ring-indigo-500 sm:h-14 sm:w-14 md:h-16 md:w-16"
loading="lazy"
/>
<div class="text-center sm:text-left">
<h1 class="text-xl font-bold text-gray-900 sm:text-2xl md:text-3xl dark:text-white">
Welcome back, {getFirstName(page.data.user?.name)}!
</h1>
<p class="text-sm text-gray-600 sm:text-base dark:text-gray-400">
Here's your financial overview
</p>
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:gap-3">
<a
class="flex w-full items-center justify-center space-x-2 rounded-lg bg-blue-50 px-4 py-2 text-indigo-600 hover:bg-indigo-100 sm:w-auto dark:bg-indigo-900/20 dark:text-indigo-400 dark:hover:bg-indigo-900/30"
href="/behavior#manual-add"
>
<Add class="h-5 w-5" />
<span>Add Transaction</span>
</a>
<button
class="flex w-full items-center justify-center space-x-2 rounded-lg bg-gray-50 px-4 py-2 text-gray-600 hover:bg-gray-100 sm:w-auto dark:bg-gray-700/50 dark:text-gray-400 dark:hover:bg-gray-700"
>
<Bar class="h-5 w-5" />
<span>Reports</span>
</button>
</div>
</AnimatedSection>
<!-- Financial Summary Cards -->
<AnimatedSection y={30} delay={200}>
<Summary bind:financialSummaries={finance} />
</AnimatedSection>
<!-- Card and Insights Grid -->
<AnimatedSection y={40} delay={400} class="grid gap-4 sm:gap-6 md:grid-cols-1 lg:grid-cols-2">
<!-- Behavioral Insights -->
<BehaviouralInsights
categories={transAnalysis.categories}
loading={loadingAnalysis}
steps={loadingAnalysisProgress}
/>
<!-- Monthly summary -->
<MonthlySummary
financialSummaries={finance}
loading={loadingSummary}
steps={loadingSummaryProgress}
/>
</AnimatedSection>
<!-- Charts + Transactions Grid -->
<AnimatedSection y={50} delay={600} class="grid gap-6 lg:grid-cols-2">
<!-- Financial Charts -->
<FinanceChart
loading={loadingSummary}
spending_analysis={finance.spending_analysis}
steps={loadingSummaryProgress}
/>
<!-- Recent Transactions -->
<div class="rounded-xl bg-white p-6 shadow-xs dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Transactions</h3>
<a
class="text-sm text-indigo-600 hover:text-indigo-700 dark:text-indigo-400"
href="/finanalyzer/transactions"
>
View All
</a>
</div>
<Transactions transactions={data.transactions} />
</div>
</AnimatedSection>
</AnimatedContainer>
We have a couple of SvelteKit-specific concepts to clarify. First, notice the (dashboard)
directory within the routes
folder. This is a Route Group in SvelteKit. SvelteKit uses file-system routing, where +page.svelte
files define your application's routes. As SvelteKit's documentation states:
By default, the layout hierarchy mirrors the route hierarchy.
Route groups allow you to organize your routes without affecting the URL structure. This is useful when you want a subset of your application to share a layout without including the group's name in the URL.
In this group, there is a layout:
<script lang="ts">
import Collapse from '$lib/components/icons/Collapse.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import ThemeSwitcher from '$lib/components/reusables/ThemeSwitcher.svelte';
let isMobile = $state(false),
isSidebarOpen = $state(true),
innerWidth = $state(0);
function toggleSidebar() {
isSidebarOpen = !isSidebarOpen;
}
const checkWidth = () => {
const wasMobile = isMobile;
isMobile = innerWidth < 768;
// Handle transition between mobile and desktop
if (wasMobile !== isMobile) {
if (isMobile) {
isSidebarOpen = false;
} else {
isSidebarOpen = true;
}
}
};
let { children } = $props();
$effect(() => {
checkWidth();
});
</script>
<svelte:window on:resize={checkWidth} bind:innerWidth />
<div class="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-900" id="main-content">
<!-- Sidebar -->
<Sidebar bind:isSidebarOpen bind:isMobile {toggleSidebar} />
<!-- Main content -->
<div
class="relative h-full transform transition-all duration-300 md:translate-x-0"
class:margin-left-64={isSidebarOpen}
class:margin-left-20={!isSidebarOpen}
class:overflow-hidden={isMobile && isSidebarOpen}
>
<header
class="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center gap-4">
<!-- Mobile menu button -->
<button
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 md:hidden dark:text-gray-400 dark:hover:bg-gray-700"
onclick={toggleSidebar}
aria-label="Toggle Menu"
title="Toggle Menu"
>
<Collapse class="h-6 w-6" collapse={false} />
</button>
<h1 class="text-2xl font-semibold text-gray-800 dark:text-white">Dashboard</h1>
</div>
<ThemeSwitcher
class="rounded-full bg-white p-2 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-gray-700 dark:ring-2"
/>
</header>
<main class="h-[calc(100vh-4rem)] overflow-y-auto p-6">
{@render children()}
</main>
</div>
<!-- Mobile Overlay -->
{#if isMobile && isSidebarOpen}
<button
class="fixed inset-0 z-20 bg-gray-900/50 backdrop-blur-xs"
onclick={() => (isSidebarOpen = false)}
aria-label="Close Sidebar"
></button>
{/if}
</div>
This code implements the main dashboard layout, building upon the concepts discussed in the finance dashboard article. The layout includes a responsive sidebar, a header with a theme switcher, and a main content area. The other components used in this layout can be found in the series repository.
Back in +page.svelte
, the WebSocket service is initialized using Svelte's onMount
lifecycle hook. This ensures that the service is started after the component has been mounted in the DOM. Immediately after initialization, requests for analysis and summaries are sent to the server. The component also subscribes to progress updates to keep the user informed about the status of their requests. The received data is then dispatched to other Svelte components for rendering (we will only zoom in on one here for brevity. Others can be seen in the repo.).
Step 3: Visualizing Financial Data with the `FinanceChart.svelte` Component
Let's delve into the FinanceChart
component, which leverages ApexChartsJS to provide a visually appealing representation of the user's spending analysis. This component offers a fullscreen mode for enhanced interaction and data exploration.
<script lang="ts">
import { financialChartConfig, updateChartTheme } from '$lib/utils/helpers/charts.helpers';
import { transformChartData } from '$lib/utils/helpers/transactions.helpers';
import type { SpendingAnalysis } from '$lib/types/transaction.types';
import LoadingChart from '$lib/components/reusables/LoadingChart.svelte';
import Empty from '$lib/components/reusables/Empty.svelte';
import Expand from '$lib/components/icons/Expand.svelte';
import Minimize from '$lib/components/icons/Minimize.svelte';
import { COLORS } from '$lib/utils/contants';
import type { ProgressSteps } from '$lib/types/notification.types';
import { browser } from '$app/environment';
let {
spending_analysis,
loading,
steps
}: { spending_analysis: SpendingAnalysis; loading: boolean; steps: ProgressSteps[] } = $props();
let chartElement = $state<HTMLDivElement>(),
chart: ApexCharts | null = null,
isFullscreen = $state(false);
function toggleFullscreen() {
isFullscreen = !isFullscreen;
}
async function initChart() {
if (!browser || !chartElement) return;
try {
// Dynamically import ApexCharts
const { default: ApexCharts } = await import('apexcharts');
const financialChartData = transformChartData(
spending_analysis.daily_summary,
spending_analysis.cumulative_balance
);
const options = {
...financialChartConfig,
title: {
text: 'Daily Financial Summary',
align: 'center',
margin: 10,
style: {
fontWeight: 'bold',
fontFamily: 'inherit',
color: '#263238'
}
},
series: [
{
name: 'Income',
data: financialChartData.income
},
{
name: 'Expenses',
data: financialChartData.expenses
},
{
name: 'Balance',
data: financialChartData.balances
}
],
xaxis: {
categories: financialChartData.labels,
labels: {
style: {
colors: 'rgba(156, 163, 175, 0.9)'
}
}
},
stroke: {
width: [2, 2, 2],
curve: 'smooth',
dashArray: [0, 0, 5]
}
};
// Cleanup previous instance
if (chart) {
chart.destroy();
}
chart = new ApexCharts(chartElement, options);
chart.render();
// Handle dark mode changes
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
chart?.updateOptions(updateChartTheme(isDark));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// Cleanup on component destruction
return () => {
observer.disconnect();
if (chart) {
chart.destroy();
}
};
} catch (error) {
console.error('Error initializing chart:', error);
}
}
$effect(() => {
if (browser && chartElement && spending_analysis) {
initChart();
}
});
</script>
<div
class="group relative rounded-xl bg-white p-6 shadow-xs dark:bg-gray-800"
class:fixed={isFullscreen}
class:inset-0={isFullscreen}
class:z-50={isFullscreen}
>
<!-- Fullscreen button -->
<button
class="absolute top-2 right-2 rounded-lg bg-gray-100 p-2 opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
onclick={toggleFullscreen}
>
{#if isFullscreen}
<!-- Minimize icon -->
<Minimize class="h-5 w-5" />
{:else}
<!-- Expand icon -->
<Expand class="h-5 w-5" />
{/if}
</button>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Financials</h3>
<div class="flex items-center gap-4">
<span class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<span class="mr-1 h-3 w-3 rounded-full {COLORS.income.background}"></span> Income
</span>
<span class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<span class="mr-1 h-3 w-3 rounded-full {COLORS.expense.background}"></span> Expenses
</span>
<span class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<span class="mr-1 h-3 w-3 rounded-full {COLORS.balance.background}"></span> Balance
</span>
</div>
</div>
<div class={isFullscreen ? 'h-[calc(100vh-120px)]' : 'h-64'}>
{#if loading}
<LoadingChart {steps} />
{:else if !spending_analysis}
<Empty
title="No financial data available"
description="Financial data will be available once you have made a few transactions."
/>
{:else}
<div bind:this={chartElement}></div>
{/if}
</div>
</div>
{#if isFullscreen}
<button
type="button"
class="fixed inset-0 z-40 bg-gray-900/50 backdrop-blur-xs"
onclick={toggleFullscreen}
onkeydown={(e) => e.key === 'Escape' && toggleFullscreen()}
aria-label="Close fullscreen view"
></button>
{/if}
<style>
.fixed {
animation: zoom-in 0.2s ease-out;
position: fixed;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 2rem);
width: 100%;
margin: auto;
}
@keyframes zoom-in {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>
Since we are exclusively building our app with Svelte 5 runes, this component retrieves the spending analysis data from the $props() and then defines some states. The initChart function handles the chart plotting. It's crucial to check for the browser environment and the presence of the chart element in the DOM to prevent errors like apexcharts.common.js:... Uncaught (in promise) Error: Element not found. Dynamic import for ApexCharts is used instead of direct import at the top of the file to further mitigate these issues. The base configurations for all charts are defined in src/lib/utils/helpers/charts.helpers.ts
:
import type { ApexOptions } from 'apexcharts';
import { formatCurrency } from './money.helpers.svelte';
// Base configuration for all charts
const baseOptions: ApexOptions = {
chart: {
type: 'line',
height: '100%',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
},
autoSelected: 'zoom'
},
fontFamily: 'inherit',
background: 'transparent'
},
stroke: {
width: 2,
curve: 'smooth'
},
grid: {
borderColor: 'rgba(156, 163, 175, 0.1)',
strokeDashArray: 4,
yaxis: { lines: { show: true } },
xaxis: { lines: { show: false } }
},
dataLabels: { enabled: false },
tooltip: {
theme: 'dark',
y: {
formatter: (value: number) => formatCurrency(value)
}
},
legend: {
show: false
}
};
// Financial chart configuration
export const financialChartConfig: ApexOptions = {
...baseOptions,
chart: {
...baseOptions.chart,
type: 'area'
},
colors: ['#22c55e', '#ef4444', '#3b82f6'],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
inverseColors: false,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 90, 100]
}
},
yaxis: {
labels: {
formatter: (value) => formatCurrency(value),
style: {
colors: 'rgba(156, 163, 175, 0.9)'
}
}
}
};
...
// Helper function to update chart theme based on dark mode
export function updateChartTheme(isDark: boolean): Partial<ApexOptions> {
return {
tooltip: { theme: isDark ? 'dark' : 'light' },
theme: {
mode: isDark ? 'dark' : 'light'
},
grid: {
borderColor: isDark ? 'rgba(156, 163, 175, 0.1)' : 'rgba(156, 163, 175, 0.2)'
}
};
}
...
These configurations define basic options for all charts and specific settings for financial charts.
With that, we'll stop here for this part. See you in the next one!
Outro
Enjoyed this article? I'm a Software Engineer, Technical Writer and Technical Support Engineer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and X. I am also an email away.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!