Building an AI-powered Financial Behavior Analyzer with NodeJS, Python, SvelteKit, and TailwindCSS - Part 4

Introduction Part 5 is already out on: https://johnowolabiidogun.dev/blog/asynchronous-server-building-and-rigorously-testing-a-websocket-and-http-server-3918df/67b0ab3c7a900ac23e502c51 In this part of the series, we will implement our backend service's WebSocket handler and begin building the frontend. This will enable real-time communication between the backend and frontend, allowing us to display analysis results as they become available. Prerequisite The main prerequisite is that you have gone through the previous articles in this series. This ensures you have the necessary context and environment set up. Source code Sirneij / finance-analyzer An AI-powered financial behavior analyzer and advisor written in Python (aiohttp) and TypeScript (ExpressJS & SvelteKit with Svelte 5) View on GitHub Implementation Step: WebSocket handler To provide real-time updates for financial data analysis and summaries, we use WebSockets, a bidirectional communication protocol, instead of traditional HTTP requests that require constant refreshing. The TransactionWebSocketHandler function manages WebSocket connections: import { WebSocket } from "ws"; import { TransactionService } from "$services/transaction.service.js"; import { baseConfig } from "$config/base.config.js"; import mongoose from "mongoose"; import { sendError } from "$utils/error.utils.js"; export function TransactionWebSocketHandler(ws: WebSocket): void { ws.on("message", async (message: string) => { try { const actions = JSON.parse(message); if (!Array.isArray(actions)) { sendError(ws, "Invalid message format. Expected an array."); return; } for (const actionObj of actions) { if (!actionObj.action || !actionObj.userId) { sendError( ws, "Invalid action format. Each action requires 'action' and 'userId'." ); return; } const { action, userId } = actionObj; if (!mongoose.Types.ObjectId.isValid(userId)) { sendError(ws, "Invalid userId format."); return; } switch (action) { case "analyze": case "summary": await handleAction(ws, new mongoose.Types.ObjectId(userId), action); break; default: sendError(ws, `Unknown action: ${action}`); } } } catch (error) { baseConfig.logger.error( `Error processing message: ${ error instanceof Error ? error.message : error }` ); sendError( ws, `Failed to process message: ${ error instanceof Error ? error.message : error }` ); } }); ws.on("close", () => { baseConfig.logger.info("Frontend WebSocket connection closed"); }); ws.on("error", (error) => { baseConfig.logger.error(`WebSocket error: ${error.message}`); }); } async function handleAction( frontendWs: WebSocket, userId: mongoose.Types.ObjectId, action: string ) { try { const transactions = await TransactionService.findTransactionsByUserId( userId, -1, -1 ); if (!transactions) { sendError( frontendWs, `No transactions found for userId: ${userId}`, action ); return; } await TransactionService.connectToUtilityServer( action, transactions.transactions, frontendWs ); } catch (error) { baseConfig.logger.error( `Error handling action: ${error instanceof Error ? error.message : error}` ); sendError( frontendWs, `Failed to handle action: ${ error instanceof Error ? error.message : error }` ); } } Using the ws module (installed in the previous article), this function sets up the WebSocket connection and defines handlers for incoming messages, connection close, and errors. As soon as it receives a message, the callback for the message event listener gets fired and it works as follows: Parses incoming messages as JSON, expecting an array of actions. Validates the format of each action, ensuring it contains action and userId. Validates the userId to ensure it is a valid Mongoose ObjectId. Uses a switch statement to handle different actions (analyze and summary), and Calls the handleAction function to process each valid action. The handleAction processes specific actions requested by the client by retrieving transactions for the specified userId using TransactionService.findTransactionsByUserId static method discussed in the previous article. Any transactions retrieved are sent via the TransactionService.connectToUtilityServer method to the utility server for analysis or summary. Let's register this handler with our app: ... import { WebSocketServer } from "ws"; ... import { TransactionWebSocketHandler

Feb 15, 2025 - 16:43
 0
Building an AI-powered Financial Behavior Analyzer with NodeJS, Python, SvelteKit, and TailwindCSS - Part 4

Introduction

Part 5 is already out on: https://johnowolabiidogun.dev/blog/asynchronous-server-building-and-rigorously-testing-a-websocket-and-http-server-3918df/67b0ab3c7a900ac23e502c51

In this part of the series, we will implement our backend service's WebSocket handler and begin building the frontend. This will enable real-time communication between the backend and frontend, allowing us to display analysis results as they become available.

Prerequisite

The main prerequisite is that you have gone through the previous articles in this series. This ensures you have the necessary context and environment set up.

Source code

GitHub logo Sirneij / finance-analyzer

An AI-powered financial behavior analyzer and advisor written in Python (aiohttp) and TypeScript (ExpressJS & SvelteKit with Svelte 5)




Implementation

Step: WebSocket handler

To provide real-time updates for financial data analysis and summaries, we use WebSockets, a bidirectional communication protocol, instead of traditional HTTP requests that require constant refreshing. The TransactionWebSocketHandler function manages WebSocket connections:

import { WebSocket } from "ws";
import { TransactionService } from "$services/transaction.service.js";
import { baseConfig } from "$config/base.config.js";
import mongoose from "mongoose";
import { sendError } from "$utils/error.utils.js";

export function TransactionWebSocketHandler(ws: WebSocket): void {
  ws.on("message", async (message: string) => {
    try {
      const actions = JSON.parse(message);

      if (!Array.isArray(actions)) {
        sendError(ws, "Invalid message format. Expected an array.");
        return;
      }

      for (const actionObj of actions) {
        if (!actionObj.action || !actionObj.userId) {
          sendError(
            ws,
            "Invalid action format. Each action requires 'action' and 'userId'."
          );
          return;
        }

        const { action, userId } = actionObj;

        if (!mongoose.Types.ObjectId.isValid(userId)) {
          sendError(ws, "Invalid userId format.");
          return;
        }

        switch (action) {
          case "analyze":
          case "summary":
            await handleAction(ws, new mongoose.Types.ObjectId(userId), action);
            break;
          default:
            sendError(ws, `Unknown action: ${action}`);
        }
      }
    } catch (error) {
      baseConfig.logger.error(
        `Error processing message: ${
          error instanceof Error ? error.message : error
        }`
      );
      sendError(
        ws,
        `Failed to process message: ${
          error instanceof Error ? error.message : error
        }`
      );
    }
  });

  ws.on("close", () => {
    baseConfig.logger.info("Frontend WebSocket connection closed");
  });

  ws.on("error", (error) => {
    baseConfig.logger.error(`WebSocket error: ${error.message}`);
  });
}

async function handleAction(
  frontendWs: WebSocket,
  userId: mongoose.Types.ObjectId,
  action: string
) {
  try {
    const transactions = await TransactionService.findTransactionsByUserId(
      userId,
      -1,
      -1
    );

    if (!transactions) {
      sendError(
        frontendWs,
        `No transactions found for userId: ${userId}`,
        action
      );
      return;
    }

    await TransactionService.connectToUtilityServer(
      action,
      transactions.transactions,
      frontendWs
    );
  } catch (error) {
    baseConfig.logger.error(
      `Error handling action: ${error instanceof Error ? error.message : error}`
    );
    sendError(
      frontendWs,
      `Failed to handle action: ${
        error instanceof Error ? error.message : error
      }`
    );
  }
}

Using the ws module (installed in the previous article), this function sets up the WebSocket connection and defines handlers for incoming messages, connection close, and errors. As soon as it receives a message, the callback for the message event listener gets fired and it works as follows:

  • Parses incoming messages as JSON, expecting an array of actions.
  • Validates the format of each action, ensuring it contains action and userId.
  • Validates the userId to ensure it is a valid Mongoose ObjectId.
  • Uses a switch statement to handle different actions (analyze and summary), and
  • Calls the handleAction function to process each valid action.

The handleAction processes specific actions requested by the client by retrieving transactions for the specified userId using TransactionService.findTransactionsByUserId static method discussed in the previous article. Any transactions retrieved are sent via the TransactionService.connectToUtilityServer method to the utility server for analysis or summary.

Let's register this handler with our app:

...
import { WebSocketServer } from "ws";
...
import { TransactionWebSocketHandler } from "$websockets/transaction.websocket.js";
...
const startServer = async () => {
  try {
    const server: HttpServer = createServer(app);
    const wss = new WebSocketServer({ server, path: "/ws" });

    // 5. Setup WebSocket handlers
    wss.on("connection", (ws) => {
      TransactionWebSocketHandler(ws);
    });

    // 6. Connect to MongoDB
    baseConfig.logger.info("Connecting to MongoDB cluster...");
    const db = await connectToCluster();

    ...
  } catch (error) {
    baseConfig.logger.error("Error starting server:", error);
    process.exit(1);
  }
};
startServer();

With that, we conclude the backend service. Now it's time to set SvelteKit up with TailwindCSS.

Step 2: SvelteKit with TailwindCSS

To setup a SvelteKit project with TailwindCSS, we will refer to the official TailwindCSS guide with some modifications from the migration guide I previously wrote.

First, create a new SvelteKit project via the Svelte 5 sv CLI:

$ npx sv create interface # modify the name as you please

You will be prompted to install sv which you should accent to. Your interaction with the CLI should look like this:

projects npx sv create interface
Need to install the following packages:
sv@0.6.18
Ok to proceed? (y) y

┌  Welcome to the Svelte CLI! (v0.6.18)
│
◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with Typescript?
│  Yes, using Typescript syntax
│
◆  Project created
│
◇  What would you like to add to your project? (use arrow keys / space bar)
│  prettier, eslint, vitest, tailwindcss, sveltekit-adapter
│
◇  tailwindcss: Which plugins would you like to add?
│  typography, forms
│
◇  sveltekit-adapter: Which SvelteKit adapter would you like to use?
│  node
│
◇  Which package manager do you want to install dependencies with?
│  npm
│
◆  Successfully setup add-ons
│
◆  Successfully installed dependencies
│
◇  Successfully formatted modified files
│
◇  Project next steps ─────────────────────────────────────────────────────╮
│                                                                          │
│  1: cd interface                                                         │
│  2: git init && git add -A && git commit -m "Initial commit" (optional)  │
│  3: npm run dev -- --open                                                │
│                                                                          │
│  To close the dev server, hit Ctrl-C                                     │
│                                                                          │
│  Stuck? Visit us at https://svelte.dev/chat                              │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────╯
│
└  You're all set!

Feel free to modify any of the steps as you like. You can change directory into the newly created project and install the dependencies.

For some reasons, the tailwindcss installed by sv was version 3.4.17. However, at the time of writting this, TailwindCSS is already at version 4.0.2. So we need to migrate. To incept the migration steps, run this command:

interface$ npx @tailwindcss/upgrade@next

You should get something like this:

interface$ npx @tailwindcss/upgrade@next
Need to install the following packages:
@tailwindcss/upgrade@4.0.2
Ok to proceed? (y) y

≈ tailwindcss v4.0.2

fatal: not a git repository (or any of the parent directories): .git
│ Searching for CSS files in the current
│ directory and its subdirectories…

│ ↳ Linked `./tailwind.config.ts` to
│   `./src/app.css`

│ Migrating JavaScript configuration files…

│ ↳ Migrated configuration file:
│   `./tailwind.config.ts`

│ Migrating templates…

│ ↳ Migrated templates for configuration file:
│   `./tailwind.config.ts`

│ Migrating stylesheets…

│ ↳ Migrated stylesheet: `./src/app.css`

│ Migrating PostCSS configuration…

│ ↳ Installed package: `@tailwindcss/postcss`

│ ↳ Removed package: `autoprefixer`

│ ↳ Migrated PostCSS configuration:
│   `./postcss.config.js`

│ Updating dependencies…

│ ↳ Updated package:
│   `prettier-plugin-tailwindcss`

│ ↳ Updated package: `tailwindcss`

fatal: not a git repository (or any of the parent directories): .git
│ No changes were made to your repository.

It will magically modify your src/app.css to look like:

@import "tailwindcss";

@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';

/*
  The default border color has changed to `currentColor` in Tailwind CSS v4,
  so we've added these compatibility styles to make sure everything still
  looks the same as it did with Tailwind CSS v3.

  If we ever want to remove these styles, we need to add an explicit border
  color utility to any element that depends on these defaults.
*/
@layer base {
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    border-color: var(--color-gray-200, currentColor);
  }
}

tailwind.config.ts will be removed and postcss.config.js will be modified. We need to make a slight change to src/app.css and vite.config.js:

@import "tailwindcss";

@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';

@custom-variant dark (&:where(.dark, .dark *));

@layer base {
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    border-color: var(--color-gray-200, currentColor);
  }
}

We need line 6 so that we can leverage classes (and later, prefers-color-scheme) to dynamically switch themes.

Next is vite.config.js:

import { defineConfig } from "vitest/config";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss(), sveltekit()],

  test: {
    include: ["src/**/*.{test,spec}.{js,ts}"],
  },
});

That concludes the initial setup.

Step 3: Leverage prefers-color-scheme in theme toggling

Many modern operating systems (OS) have made dark mode a first-class feature, and tuning your web application to honor the user's OS theme preference provides a better user experience. The CSS prefers-color-scheme media feature makes this easy to implement. Here's how to leverage it:


 lang="en">
  
     charset="utf-8" />
     name="language" content="en" />
    
    
      name="theme-color"
      content="#ffffff"
      media="(prefers-color-scheme: light)"
    />
    
      name="theme-color"
      content="#111827"
      media="(prefers-color-scheme: dark)"
    />
     name="viewport" content="width=device-width, initial-scale=1" />
     rel="preconnect" href="https://fonts.googleapis.com" />
     rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    
      href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
      rel="stylesheet"
    />
    ... %sveltekit.head%
    
  
  
    data-sveltekit-preload-data="hover"
    class="bg-white text-black dark:bg-gray-900 dark:text-white"
  >
     style="display: contents">%sveltekit.body%

This short HTML code does a lot of things. First, the theme-color meta tags adapt the browser's UI (e.g., address bar) to match the theme. prefers-color-scheme is used to set different colors for light and dark modes. Then, in the script, we first checks if the user has a saved theme preference in localStorage. If not, it checks the OS-level dark mode setting using window.matchMedia('(prefers-color-scheme: dark)'). It then applies the appropriate theme by adding or removing the dark class to the element. This class is used to toggle CSS styles (e.g., using dark:bg-gray-900 in the class). Finally, it listens for changes in the OS-level dark mode setting and updates the theme accordingly (but only if the user hasn't explicitly set a preference). We also used the element to set the background and text colors based on the presence of the dark class using Tailwind CSS classes.

Step 4: Theme switching logic with icons

Before we proceed to creating the authentication/login page, let's make a simple ThemeSwitcher.svelte component:



 onclick="{toggleTheme}" {...props}>
  {#if isDark}
   />
  {:else}
   />
  {/if}

Since this app will be fully powered by Svelte 5, we are using the $props() rune to accept any attributes passed to the component as props, and the spread operator helps expand these attributes. We also declared a reactive variable with the $state rune, and it gets updated in the $effect rune and in the toggleTheme function. The $effect rune runs once on component initialization and whenever its dependencies change. In this case, it checks if the dark class is present on the element and updates the isDark state accordingly. This ensures the component's initial state matches the current theme. As for the toggleTheme function, it gets called when the button is clicked and toggles the isDark state, saves the selected theme ("dark" or "light") to localStorage, and toggles the dark class on the element. The