diff --git a/.github/.gitignore b/.github/.gitignore
new file mode 100644
index 00000000..ecb6d6ba
--- /dev/null
+++ b/.github/.gitignore
@@ -0,0 +1,2 @@
+agents
+skills
\ No newline at end of file
diff --git a/.migrations/001_init.down.sql b/.migrations/001_init.down.sql
index 80fb9176..1db35c05 100644
--- a/.migrations/001_init.down.sql
+++ b/.migrations/001_init.down.sql
@@ -1,13 +1,17 @@
-drop table if exists users;
-drop table if exists assets;
-drop table if exists portfolios;
-drop table if exists transactions;
+-- 001_init.down.sql
-drop view if exists portfolios_ext;
+-- triggers
+drop trigger if exists check_holdings_before_insert_sell;
+drop trigger if exists check_holdings_before_update_sell;
+
+-- views
drop view if exists asset_transactions;
drop view if exists asset_holdings;
+drop view if exists portfolios_ext;
drop view if exists assets_contributions;
-drop trigger if exists check_holdings_before_insert_sell;
-drop trigger if exists check_holdings_before_update_sell;
-
+-- tables
+drop table if exists users;
+drop table if exists assets;
+drop table if exists portfolios;
+drop table if exists transactions;
diff --git a/.migrations/002_portfolio_view.down.sql b/.migrations/002_portfolio_view.down.sql
index 30ae95cc..be3fbb37 100644
--- a/.migrations/002_portfolio_view.down.sql
+++ b/.migrations/002_portfolio_view.down.sql
@@ -1,2 +1,26 @@
-ALTER TABLE transactions
-DROP COLUMN comments;
\ No newline at end of file
+-- 002_portfolio_view.down.sql
+
+-- revert views to previous state
+drop view if exists portfolios_ext;
+
+create view portfolios_ext as
+select
+ p.*,
+ coalesce(a.total_invested, 0) as total_invested,
+ coalesce(a.num_assets, 0) as num_assets
+from
+ portfolios p
+ left join (
+ select
+ portfolio_id,
+ sum(invested) as total_invested,
+ count(id) as num_assets
+ from
+ asset_holdings
+ group by
+ portfolio_id
+ ) as a on p.id = a.portfolio_id;
+
+-- remove comments column
+alter table transactions
+drop column comments;
diff --git a/.migrations/005_num_shares.down.sql b/.migrations/005_num_shares.down.sql
index 7765e3ac..2001a1ee 100644
--- a/.migrations/005_num_shares.down.sql
+++ b/.migrations/005_num_shares.down.sql
@@ -1 +1,125 @@
--- drop view if exists asset_transactions;
\ No newline at end of file
+-- 005_num_shares.down.sql
+
+-- drop new triggers
+drop trigger if exists check_holdings_before_insert_sell;
+drop trigger if exists check_holdings_before_update_sell;
+
+-- drop new views
+drop view if exists transactions_ext;
+drop view if exists assets_ext;
+drop view if exists portfolios_ext;
+
+-- recreate portfolios_ext from 002
+create view portfolios_ext as
+select
+ p.*,
+ coalesce(a.total_invested, 0) as total_invested,
+ coalesce(a.num_assets, 0) as num_assets,
+ coalesce(total_invested / sum(total_invested) over (partition by p.user_id), 0) as contribution
+from
+ portfolios p
+ left join (
+ select
+ portfolio_id,
+ sum(invested) as total_invested,
+ count(id) as num_assets
+ from
+ asset_holdings
+ group by
+ portfolio_id
+ ) as a on p.id = a.portfolio_id;
+
+-- recreate old views from 001
+create view asset_holdings as
+select
+ sub.*,
+ case
+ when sub.holdings = 0 then null
+ else sub.invested / sub.holdings
+ end as avg_price
+from
+ (
+ select
+ a.*,
+ u.id as user_id,
+ coalesce(
+ sum(
+ case
+ when t.type = 'buy' then t.quantity
+ else - t.quantity
+ end
+ ),
+ 0
+ ) as holdings,
+ coalesce(
+ sum(
+ case
+ when t.type = 'buy' then t.quantity * t.price
+ else - t.quantity * t.price
+ end
+ ),
+ 0
+ ) as invested,
+ count(t.id) as num_tx
+ from
+ assets a
+ inner join portfolios p on p.id = a.portfolio_id
+ left join transactions t on t.asset_id = a.id
+ inner join users u on u.id = p.user_id
+ group by
+ a.id,
+ a.name
+ ) as sub;
+
+create view asset_transactions as
+select
+ t.*,
+ a.name,
+ a.ticker,
+ p.name as portfolio_name,
+ p.description as portfolio_description,
+ p.user_id
+from
+ transactions t
+ inner join assets a on a.id = t.asset_id
+ inner join portfolios p on p.id = a.portfolio_id;
+
+create view assets_contributions as
+select
+ ah.*,
+ coalesce(ah.invested / coalesce(pt.total_invested, 1), 0) as portfolio_contribution
+from
+ asset_holdings ah
+ left join portfolios_ext pt on ah.portfolio_id = pt.id;
+
+-- recreate old triggers
+create trigger check_holdings_before_insert_sell before insert on transactions for each row when new.type = 'sell' begin
+select
+ case
+ when (
+ select
+ holdings
+ from
+ asset_holdings a
+ where
+ id = new.asset_id
+ ) < new.quantity then raise (abort, 'Insufficient holdings')
+ end;
+
+end;
+
+create trigger check_holdings_before_update_sell before
+update on transactions for each row when new.type = 'sell' begin
+select
+ case
+ when (
+ select
+ holdings
+ from
+ asset_holdings a
+ where
+ id = new.asset_id
+ ) < new.quantity then raise (abort, 'Insufficient holdings')
+ end;
+
+end;
diff --git a/.migrations/006_self_enriched_tx.down.sql b/.migrations/006_self_enriched_tx.down.sql
index e69de29b..f4744044 100644
--- a/.migrations/006_self_enriched_tx.down.sql
+++ b/.migrations/006_self_enriched_tx.down.sql
@@ -0,0 +1,174 @@
+-- 006_self_enriched_tx.down.sql
+
+-- drop new views
+drop view if exists transactions_ext;
+drop view if exists assets_ext;
+drop view if exists portfolios_ext;
+
+-- recreate portfolios_ext from 005
+create view portfolios_ext as
+with
+ portfolio_assets as (
+ select
+ portfolio_id,
+ sum(invested) as total_invested,
+ count(id) as num_assets
+ from
+ assets_ext
+ group by
+ portfolio_id
+ )
+select
+ p.*,
+ coalesce(pa.total_invested, 0) as total_invested,
+ coalesce(pa.num_assets, 0) as num_assets,
+ coalesce(
+ pa.total_invested / sum(pa.total_invested) over user_portfolios,
+ 0
+ ) as contribution
+from
+ portfolios p
+ left join portfolio_assets as pa on p.id = pa.portfolio_id
+window
+ user_portfolios as (
+ partition by
+ p.user_id
+ );
+
+-- recreate assets_ext from 005
+create view assets_ext as
+with
+ avg_unit_cost as (
+ select
+ asset_id,
+ sum(quantity * price) / sum(quantity) as avg_price
+ from
+ transactions
+ where
+ type = 'buy'
+ group by
+ asset_id
+ ),
+ tx_stats as (
+ select
+ asset_id,
+ sum(quantity_ext) as holdings,
+ count(*) as num_txs
+ from
+ transactions_ext
+ group by
+ asset_id
+ )
+select
+ a.*,
+ u.id as user_id,
+ coalesce(t.holdings, 0) as holdings,
+ coalesce(t.holdings * c.avg_price, 0) as invested,
+ coalesce(t.num_txs, 0) as num_txs,
+ c.avg_price
+from
+ assets a
+ inner join portfolios p on p.id = a.portfolio_id
+ left join avg_unit_cost c on a.id = c.asset_id
+ left join tx_stats t on a.id = t.asset_id
+ inner join users u on u.id = p.user_id;
+
+-- recreate transactions_ext from 005
+create view transactions_ext as
+with
+ txs_ext as (
+ select
+ *,
+ case
+ when type = 'buy' then quantity
+ else 0 - quantity
+ end as quantity_ext
+ from
+ transactions
+ ),
+ total_contr as (
+ select
+ asset_id,
+ sum(quantity_ext) as total_quantity
+ from
+ txs_ext
+ group by
+ asset_id
+ )
+select
+ t.*,
+ sum(quantity_ext) over upto_tx as holdings,
+ sum(quantity_ext * price) over upto_tx as total_invested,
+ coalesce(
+ sum(
+ case
+ when type = 'buy' then quantity * price
+ else 0
+ end
+ ) over upto_tx / sum(
+ case
+ when type = 'buy' then quantity
+ else 0
+ end
+ ) over upto_tx,
+ 0
+ ) as avg_price,
+ case
+ when tc.total_quantity = 0 then 0
+ else quantity_ext / tc.total_quantity
+ end AS contribution,
+ a.name,
+ a.ticker,
+ p.name as portfolio_name,
+ p.description as portfolio_description,
+ p.user_id
+from
+ txs_ext t
+ join total_contr tc on t.asset_id = tc.asset_id
+ inner join assets a on a.id = t.asset_id
+ inner join portfolios p on p.id = a.portfolio_id
+window
+ upto_tx as (
+ partition by
+ t.asset_id
+ order by
+ date ROWS BETWEEN UNBOUNDED PRECEDING
+ AND CURRENT ROW
+ )
+order by
+ date;
+
+-- recreate triggers from 005
+drop trigger if exists check_holdings_before_insert_sell;
+drop trigger if exists check_holdings_before_update_sell;
+
+create trigger check_holdings_before_insert_sell before insert on transactions for each row when new.type = 'sell' begin
+select
+ case
+ when (
+ select
+ holdings
+ from
+ assets_ext a
+ where
+ id = new.asset_id
+ ) < new.quantity then raise (abort, 'Insufficient holdings')
+ end;
+
+end;
+
+create trigger check_holdings_before_update_sell before
+update on transactions for each row when new.type = 'sell' begin
+select
+ case
+ when (
+ select
+ holdings
+ from
+ assets_ext a
+ where
+ id = new.asset_id
+ ) < new.quantity then raise (abort, 'Insufficient holdings')
+ end;
+
+end;
diff --git a/.migrations/007_extends_prefs.down.sql b/.migrations/007_extends_prefs.down.sql
new file mode 100644
index 00000000..140b182c
--- /dev/null
+++ b/.migrations/007_extends_prefs.down.sql
@@ -0,0 +1,47 @@
+-- 007_extends_prefs.down.sql
+
+-- recreate old prefs table
+drop table if exists prefs_old;
+
+create table prefs_old (
+ id integer primary key autoincrement,
+ user_id integer not null,
+ base_ccy text check (
+ base_ccy in (
+ 'USD',
+ 'GBP',
+ 'EUR',
+ 'CAD',
+ 'AUD',
+ 'CHF',
+ 'SEK',
+ 'NOK',
+ 'DKK',
+ 'NZD',
+ 'JPY'
+ )
+ ) not null default 'USD',
+ created datetime default current_timestamp,
+ modified datetime default current_timestamp,
+ foreign key (user_id) references users (id) on delete cascade,
+ unique (user_id)
+);
+
+-- migrate data back
+insert into prefs_old (id, user_id, base_ccy, created, modified)
+select id, user_id, base_ccy, created, modified
+from prefs;
+
+-- drop new prefs table
+drop table prefs;
+
+-- rename old back to prefs
+alter table prefs_old rename to prefs;
+
+-- recreate trigger
+drop trigger if exists insert_user_prefs;
+
+create trigger insert_user_prefs after insert on users for each row begin
+insert into prefs (user_id, base_ccy)
+values (new.id, 'USD');
+end;
diff --git a/.migrations/007_extends_prefs.up.sql b/.migrations/007_extends_prefs.up.sql
new file mode 100644
index 00000000..b5eab9e6
--- /dev/null
+++ b/.migrations/007_extends_prefs.up.sql
@@ -0,0 +1,45 @@
+ALTER TABLE prefs
+RENAME TO prefs_old;
+
+CREATE TABLE
+ prefs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ base_ccy TEXT NOT NULL DEFAULT 'USD',
+ additional TEXT NOT NULL DEFAULT '{}',
+ created DATETIME DEFAULT CURRENT_TIMESTAMP,
+ modified DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ UNIQUE (user_id)
+ );
+
+INSERT INTO
+ prefs (
+ id,
+ user_id,
+ base_ccy,
+ additional,
+ created,
+ modified
+ )
+SELECT
+ id,
+ user_id,
+ base_ccy,
+ '{}' AS additional,
+ created,
+ modified
+FROM
+ prefs_old;
+
+DROP TRIGGER IF EXISTS insert_user_prefs;
+
+CREATE TRIGGER insert_user_prefs AFTER INSERT ON users FOR EACH ROW BEGIN
+INSERT INTO
+ prefs (user_id, base_ccy)
+VALUES
+ (NEW.id, 'USD');
+
+END;
+
+DROP TABLE prefs_old;
\ No newline at end of file
diff --git a/README.md b/README.md
index 6eee0f19..a821f70c 100644
--- a/README.md
+++ b/README.md
@@ -1,158 +1,254 @@
# `Assets` — Personal Wealth Tracker
[](https://github.com/venil7/assets/actions/workflows/build-and-test.yaml)
-
[](https://github.com/venil7/assets/actions/workflows/build-docker-container.yaml)
-A self-hosted net worth and portfolio manager.
-Track multiple portfolios (ISA, General, Pension, Crypto, etc.) and monitor individual or total performance.
-Supports any asset available via the [Yahoo Finance API](https://finance.yahoo.com/).
+A self-hosted portfolio and wealth tracker for personal finance management. Consolidate your investments across brokers and accounts - ISA, 401k, IRA, Taxable, Crypto or custom portfolios - and monitor performance with complete privacy and data ownership. Built on real-time market data from [Yahoo Finance](https://finance.yahoo.com/).
+
+**Why self-host?** Keep your financial data private. No third-party tracking, no cloud constraints, no monthly subscriptions. Your wealth data stays on your servers.
+
+## Key Features
+
+- **Multi-user support** — Manage portfolios for multiple users on a single instance
+- **Multi-portfolio tracking** — ISA, 401k, IRA, Taxable, Pension, Crypto, or custom categories
+- **Portfolio performance analytics** — Total net worth, individual or aggregated performance
+- **Real-time market data** — Live prices for any publicly-traded asset via Yahoo Finance API
+- **Multi-currency support** — Track assets in any major currency with automatic FX conversion
+- **Return calculations** — Realized gains, unrealized returns, and FX impact analysis
+- **Transaction-level insights** — Per-transaction P&L, cost basis tracking
+- **CSV import** — Bulk import transactions from broker exports (most platforms supported)
+- **REST API** — Full-featured API for integration or headless operation
+- **Web dashboard** — Modern, responsive UI for desktop and mobile (built with React + TypeScript)
+
+## Ideal For
-## Features
- - Multi user
- - Multi portfolio, total net worth
- - CSV import from your broker
- - All major currencies as base
- - Foreign exchange return impact
- - capital gains and unrealized return and
- - Per transaction impact
+- **Individual investors** tracking portfolios across multiple brokers or accounts
+- **Households** monitoring combined net worth and investment performance
+- **Privacy-conscious users** who want investment data on their own servers
+- **Power users** comfortable self-hosting and importing transaction data manually
+- **Integration scenarios** where REST API access to portfolio data is needed
---
+
---
-## Quick Start (Docker)
+## Quick Start
-```sh
-docker pull ghcr.io/venil7/assets:latest && \
+### Docker (Fastest)
+
+Pull and run the latest image:
+
+```bash
+docker pull ghcr.io/venil7/assets:latest
docker run \
- -e ASSETS_JWT_SECRET=S0meSecretVa1ue \
+ -e ASSETS_JWT_SECRET=YourSecretKey123 \
-e ASSETS_DB=/data/assets.db \
-p 4020:4020 \
-v ${PWD}:/data \
ghcr.io/venil7/assets
```
-Then navigate to [localhost:4020](localhost:4020) and login using `admin` / `admin`
+Then open [http://localhost:4020](http://localhost:4020) and login with `admin` / `admin`
-## Docker Compose (Recommended)
+> **Note:** Change `ASSETS_JWT_SECRET` to a strong random value for production.
-- Create an `.env` with individual parameters, for example:
+### Docker Compose (Recommended for Production)
-```sh
-TAG=latest # or you can try feature branch
-ASSETS_CACHE_TTL=10m # how long to cache for , before hitting Yahoo Finance API
-ASSETS_JWT_SECRET=S0meSecretVa1ue # a unique key for JWT token
-ASSETS_JWT_EXPIRES_IN=1w # how long is JWT valid
-ASSETS_JWT_REFRESH_BEFORE=5d # when to refresh JWT, before expiry
-```
+1. Create a `.env` file with required parameters:
-- Copy this [docker-compose.yaml](docker-compose.yaml)
-- Run `docker compose up -d`
+```bash
+TAG=latest
+ASSETS_JWT_SECRET=YourSecretKey123RandomString
+ASSETS_CACHE_TTL=10m
+ASSETS_JWT_EXPIRES_IN=1w
+ASSETS_JWT_REFRESH_BEFORE=5d
+```
-### Available `.env` Variables
+2. Copy the [docker-compose.yaml](docker-compose.yaml) to your deployment directory
+3. Run: `docker compose up -d`
+4. Open [http://localhost:8084](http://localhost:8084) and login with `admin` / `admin`
+
+**Environment Variables Reference**
+
+| Variable | Purpose | Default | Notes |
+|----------|---------|---------|-------|
+| `ASSETS_JWT_SECRET` | Token signing key | *(none)* | **Required.** Use a strong random string. |
+| `ASSETS_JWT_EXPIRES_IN` | Token expiry duration | `24h` | Format: `24h`, `1w`, etc. |
+| `ASSETS_JWT_REFRESH_BEFORE` | Auto-refresh threshold | `12h` | Refresh token before this duration before expiry. |
+| `ASSETS_CACHE_TTL` | Market data cache duration | `1m` | How long to cache Yahoo Finance prices. |
+| `ASSETS_CACHE_SIZE` | Number of cached items | `1000` | Increase if tracking many assets. |
+| `ASSETS_DB` | Database file path | `/data/assets.db` | SQLite database location. |
+| `ASSETS_PORT` | Server port | `4020` | Internal container port. |
+| `ASSETS_USERNAME` | Default admin username | `admin` | Change after first login. |
+| `ASSETS_PASSWORD` | Default admin password | `admin` | Change after first login. |
+
+## Build & Run Locally
+
+**Requirements:** [Bun runtime](https://bun.sh) (1.0+)
+
+```bash
+# Clone and setup
+git clone https://github.com/venil7/assets.git
+cd assets
+cp .env.example .env # Create config file
+bun install # Install dependencies
+```
-| Variable | Description | Default |
-| --------------------------- | ------------------------------ | ----------------- |
-| `ASSETS_DB` | Path to database | `/data/assets.db` |
-| `ASSETS_APP` | Path to public build | `../dist/public` |
-| `ASSETS_PORT` | HTTP port | `4020` |
-| `ASSETS_CACHE_SIZE` | Cache size | `1000` |
-| `ASSETS_CACHE_TTL` | Cache TTL (`ms` or `1m`, etc.) | `1m` |
-| `ASSETS_USERNAME` | Admin username | `admin` |
-| `ASSETS_PASSWORD` | Admin password | `admin` |
-| `ASSETS_JWT_SECRET` | JWT secret | — |
-| `ASSETS_JWT_EXPIRES_IN` | Token expiry | `24h` |
-| `ASSETS_JWT_REFRESH_BEFORE` | Refresh before expiry | `12h` |
+**Development servers** (run in separate terminals):
-- Navigate to [localhost:8084](http://localhost:8084), login using `admin` / `admin`
+```bash
+# Terminal 1: Backend API
+bun run backend:dev # Runs on http://localhost:4020
-## Build & Run Locally (Bun)
+# Terminal 2: Frontend UI (Vite dev server)
+bun run web:dev # Runs on http://localhost:5173, proxies API to :4020
+```
-This software is written in TypeScript and assumes that it runs in [Bun](https://bun.sh).
+**Build for production:**
-- Clone repository
-- Have a `.env` placed in repository root
-- The following commands are available
+```bash
+bun run build # Builds both backend and frontend
+```
-```sh
-bun install # install all dependencies
+**Testing:**
-bun run check # checks the integrity of the code
-bun run web:dev # runs UI in dev mode
-bun run backend:dev # runs backend API in dev mode
-bun run build # runs the build for both backend and frontend
+```bash
+# Run all tests (requires backend:dev running in another terminal)
+bun test
-bun test # runs unit and integartion tests , make sure to run backend in another terminal
+# Run specific package tests
+cd packages/backend && bun test
+cd packages/core && bun test
```
-- To build a docker container locally `$ docker buildx build -t assets .`
+**Code quality checks:**
-## Development Notes
+```bash
+bun run check # Lint and type-check all packages
+```
-### build time `.env` parameters placed in `packages/web`
+**Build Docker image locally:**
-```sh
-VITE_ASSETS_URL=http://localhost:4020 # base part of backend REST api url, this param required in VITE DEV mode, but defaults to empty '' in production
-VITE_ASSET_BASENAME=/app # the beginnig part of URL before any routing
+```bash
+docker buildx build -t assets:local .
```
-### sqlite database migration
+### TypeScript Monorepo Structure
+
+This project uses Bun workspaces with three packages:
-- To install `migrate` tool run (requires `go`)
+- **backend** — Express.js REST API, database queries, business logic
+- **core** — Shared types, utilities, validation, domain models
+- **web** — React + TypeScript frontend (Vite + SCSS)
-```sh
-$ go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
+## Development
+
+### Environment Configuration
+
+**Vite build-time variables** (in `packages/web`):
+
+```bash
+VITE_ASSETS_URL=http://localhost:4020 # Backend base URL (required in dev; empty string in production)
+VITE_ASSET_BASENAME=/app # URL path prefix for routing (default: /)
```
-The following `migrate` commands are available
+### Database Migrations
+
+Migrations are managed with [golang-migrate](https://github.com/golang-migrate/migrate). This is **optional**—the app creates the schema automatically on first run.
-```sh
+If you want to manually manage migrations:
+
+```bash
+# Install migrate tool (requires Go)
+go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
+
+# Create a new migration
migrate create -ext sql -dir .migrations/ -seq -digits 3
+
+# Apply pending migrations
migrate -path ./.migrations -database=sqlite3://assets.db up
+
+# Rollback migrations
migrate -path ./.migrations -database=sqlite3://assets.db down
-migrate -path ./.migrations -database=sqlite3://assets.db version
```
-### Running tests
+### Testing in Docker
+
+To run the full test suite in an isolated container:
-```sh
-# Build a test container
+```bash
+# Build test image
docker buildx build -t assets-test -f ./Dockerfile.test .
-# Run tests in a container
+
+# Run tests
docker run -it assets-test
```
-## API-First Design
+## API-First Architecture
-The UI is optional — the backend exposes a full REST API.
+The web UI is optional. The backend exposes a **complete REST API** for any frontend or integration:
-- [API Documentation](./API.md)
+- REST endpoint documentation: [API.md](./API.md)
+- Use in headless mode, CLI tools, or custom integrations
+- Full authentication and multi-tenant support
-## Upload External Transactions
+## Importing Transactions
-It is possible to upload transactions for individual assets using CSV files, which can usually be downloaded from most brokers and platforms. The CSV file should have the following columns:
+Assets supports bulk CSV import for transaction history. Most brokers and trading platforms allow you to export transaction history in CSV format.
-- _type_: The type of transaction, either "buy" or "sell" (case insensitive)
-- _quantity_: The number of shares (float)
-- _price_: The price per share (float)
-- _date_: The date of the transaction in ISO format
-- _comments_: Any comments related to the transaction (can be left blank)
+### CSV Format
-Example:
+Your CSV file should have these columns:
-```
+| Column | Format | Example |
+|--------|--------|---------|
+| `type` | `buy` or `sell` (case-insensitive) | `buy` |
+| `quantity` | Decimal number | `65.5` |
+| `price` | Decimal number | `125.50` |
+| `date` | ISO 8601 datetime | `2025-05-11T06:56:39.379Z` |
+| `comments` | Text (optional) | `Dividend reinvestment` |
+
+### Example CSV
+
+```csv
type,quantity,price,date,comments
-buy,65,1,2025-05-11T06:56:39.379Z,comment
-sell,18,2,2025-06-12T06:56:39.379Z,
+buy,65,1,2025-05-11T06:56:39.379Z,Initial investment
+sell,18,2,2025-06-12T06:56:39.379Z,Rebalancing
+buy,100,125.50,2025-07-01T10:30:00Z,Monthly investment
```
+> **Tip:** Export transaction history from your broker's web portal, clean it up to match this format, and upload via the UI.
+
## Contributing
-This codebase is 100% hand written, no AI slop. If you feel comfortable with TypeScript, [functional programming](https://amzn.eu/d/axUrvVz) and basic SQL - contributions are welcome. If you find a bug, kindly open a [Github Issue](https://github.com/venil7/assets/issues)
+This codebase is **hand-written, AI-free code**. 100% human.
+
+Contributions welcome if you're comfortable with:
+
+- **TypeScript** — Full-stack, strict type safety
+- **Functional programming** — Heavy use of fp-ts, category theory patterns
+- **SQL & SQLite** — Database queries and schema design
+- **React** — Modern TypeScript component patterns
+- **REST APIs** — Express.js, RESTful design
+
+### Getting Started
+
+1. [Build locally](#build--run-locally) with `bun install && bun run backend:dev`
+2. Run tests: `bun test` (backend must be running)
+3. Make changes, ensure `bun run check` passes (lint + type-check)
+4. Submit a PR with clear description of changes
+
+### Found a Bug?
+
+Open a [GitHub Issue](https://github.com/venil7/assets/issues) with:
+
+- Steps to reproduce
+- Expected vs. actual behavior
+- Your setup (Docker/local, OS, environment)
## Licence
diff --git a/TODO.md b/TODO.md
index cf46e3c7..9fd0a80e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,5 +1,12 @@
# todo
+## 1.8.1
+- [x] multi asset layered chart for portfolios
+- [x] multi portfollio layered chart for home screen
+- [x] caching of enriched data
+- [x] INR
+- [-] mock yahoo api for tests
+
## 1.8
- [x] chore: move assets-core to core
@@ -15,10 +22,6 @@
- [x] enriched portfolio : break even
- [x] chart merge in polars
- [x] chart shows transactions
-- [ ] multi asset layered chart for portfolios
-- [ ] multi portfollio layered chart for home screen
-- [ ] INR
-- [ ] mock yahoo api for tests
## 1.7.1
diff --git a/bun.lock b/bun.lock
index 6407457a..104c9b56 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,14 +7,14 @@
"dependencies": {
"date-fns": "^4.1.0",
"fp-ts": "^2.16.11",
+ "io-ts": "~2.2.21",
"io-ts-types": "^0.5.19",
- "ms": "^2.1.3",
- "prettier": "^3.6.2",
},
"devDependencies": {
"@types/bun": "1.3.11",
"bun-types": "^1.3.11",
- "typescript": "~5.9.3",
+ "prettier": "^3.6.2",
+ "typescript": "~6.0.2",
},
},
"packages/backend": {
@@ -22,15 +22,14 @@
"version": "1.8.0",
"dependencies": {
"@darkruby/assets-core": "workspace:*",
- "@types/ms": "^2.1.0",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
"express": "~4.21.2",
"heap-js": "^2.7.1",
- "io-ts": "~2.2.21",
"jsonwebtoken": "^9.0.2",
"lru-cache": "^11.0.2",
+ "ms": "^2.1.3",
"nodejs-polars": "^0.24.0",
},
"devDependencies": {
@@ -39,6 +38,7 @@
"@types/faker": "5",
"@types/jsonwebtoken": "^9.0.9",
"@types/lru-cache": "^7.10.10",
+ "@types/ms": "^2.1.0",
"faker": "5",
"nock": "^14.0.1",
},
@@ -74,7 +74,7 @@
"react-dropzone": "^14.3.8",
"react-router": "^7.3.0",
"react-select": "^5.10.1",
- "recharts": "^3.8.0",
+ "recharts": "3.8.0",
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -119,15 +119,15 @@
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
- "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
+ "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
- "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
+ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
- "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
+ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
@@ -251,7 +251,7 @@
"@fortawesome/free-regular-svg-icons": ["@fortawesome/free-regular-svg-icons@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw=="],
- "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@3.2.0", "", { "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~6 || ~7", "react": "^18.0.0 || ^19.0.0" } }, "sha512-E9Gu1hqd6JussVO26EC4WqRZssXMnQr2ol7ZNWkkFOH8jZUaxDJ9Z9WF9wIVkC+kJGXUdY3tlffpDwEKfgQrQw=="],
+ "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@3.3.0", "", { "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~6 || ~7", "react": "^18.0.0 || ^19.0.0" } }, "sha512-EHmHeTf8WgO29sdY3iX/7ekE3gNUdlc2RW6mm/FzELlHFKfTrA9S4MlyquRR+RRCRCn8+jXfLFpLGB2l7wCWyw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -311,7 +311,7 @@
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
- "@preact/signals-react": ["@preact/signals-react@3.9.1", "", { "dependencies": { "@preact/signals-core": "^1.14.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-4bj3wUfXrYOqDDs6sX2Y5GBC8jgSa8ah8ZJHN2A+23ej+TdPDrtwVJ0e1cEhwemyOZ7Q7NHbilPRhtd5zVpaBA=="],
+ "@preact/signals-react": ["@preact/signals-react@3.10.0", "", { "dependencies": { "@preact/signals-core": "^1.14.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-Uxu6lidNVr9z27b/6DbCin86ekzHiJDrLXZii82aXSzvyMXYMr7l0Bab1cKbfWdbkxq13e7kS7paix3pjKBTLA=="],
"@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="],
@@ -323,55 +323,55 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
- "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
- "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
- "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
- "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
- "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
- "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
- "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
- "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
- "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
- "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
- "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
- "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
- "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
- "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
- "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
- "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
- "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
- "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
- "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
- "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
- "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
- "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
- "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
- "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
- "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
"@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="],
@@ -379,7 +379,7 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
- "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
+ "@swc/helpers": ["@swc/helpers@0.5.20", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -455,27 +455,27 @@
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
- "@types/warning": ["@types/warning@3.0.3", "", {}, "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q=="],
+ "@types/warning": ["@types/warning@3.0.4", "", {}, "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg=="],
- "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="],
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
- "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="],
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
- "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.2", "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw=="],
- "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2" } }, "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw=="],
- "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw=="],
- "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="],
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg=="],
- "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
- "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.2", "@typescript-eslint/tsconfig-utils": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA=="],
- "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
- "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="],
"@unhead/react": ["@unhead/react@2.1.12", "", { "dependencies": { "unhead": "2.1.12" }, "peerDependencies": { "react": ">=18.3.1" } }, "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw=="],
@@ -503,13 +503,13 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
"bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="],
- "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+ "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -525,7 +525,7 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -561,9 +561,9 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
- "csv-parse": ["csv-parse@6.1.0", "", {}, "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw=="],
+ "csv-parse": ["csv-parse@6.2.1", "", {}, "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ=="],
- "csv-stringify": ["csv-stringify@6.6.0", "", {}, "sha512-YW32lKOmIBgbxtu3g5SaiqWNwa/9ISQt2EcgOq0+RAIFufFp9is6tqNnKahqE5kuKvrnYAzs28r+s6pXJR8Vcw=="],
+ "csv-stringify": ["csv-stringify@6.7.0", "", {}, "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -611,7 +611,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.328", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -681,7 +681,7 @@
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
- "flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="],
+ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -719,7 +719,7 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
- "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="],
+ "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
@@ -761,7 +761,7 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
- "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
+ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -901,7 +901,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
@@ -943,7 +943,7 @@
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
- "react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="],
+ "react-router": ["react-router@7.13.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA=="],
"react-select": ["react-select@5.10.2", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ=="],
@@ -963,7 +963,7 @@
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
- "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+ "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
@@ -1063,7 +1063,7 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
- "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
+ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1071,9 +1071,9 @@
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
- "typescript-eslint": ["typescript-eslint@8.57.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/parser": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA=="],
+ "typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="],
"uncontrollable": ["uncontrollable@7.2.1", "", { "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, "peerDependencies": { "react": ">=15.0.0" } }, "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ=="],
@@ -1109,7 +1109,7 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
- "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
+ "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1127,6 +1127,8 @@
"@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "@darkruby/assets-web/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1167,7 +1169,7 @@
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
- "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
diff --git a/package.json b/package.json
index 33dd4946..25a45b22 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,19 @@
{
"name": "@darkruby/assets",
"private": true,
- "version": "1.8.0",
+ "version": "1.8.1",
"type": "module",
"dependencies": {
"date-fns": "^4.1.0",
"fp-ts": "^2.16.11",
- "io-ts-types": "^0.5.19",
- "ms": "^2.1.3",
- "prettier": "^3.6.2"
+ "io-ts": "~2.2.21",
+ "io-ts-types": "^0.5.19"
},
"devDependencies": {
+ "prettier": "^3.6.2",
"@types/bun": "1.3.11",
"bun-types": "^1.3.11",
- "typescript": "~5.9.3"
+ "typescript": "~6.0.2"
},
"scripts": {
"web:dev": "cd packages/web && bun run dev",
@@ -23,9 +23,9 @@
"backend:test": "cd packages/backend && bun test",
"backend:check": "cd packages/backend && bun run check",
"backend:build": "cd packages/backend && bun run build",
- "assets-core:check": "cd packages/core && bun run check",
- "assets-core:test": "cd packages/core && bun run test",
- "check": "bun run assets-core:check && bun run backend:check && bun run web:check",
+ "core:check": "cd packages/core && bun run check",
+ "core:test": "cd packages/core && bun run test",
+ "check": "bun run core:check && bun run backend:check && bun run web:check",
"build": "bun run web:build && bun run backend:build"
},
"workspaces": [
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 3cd78538..b94d2016 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@darkruby/assets-backend",
- "version": "1.8.0",
+ "version": "1.8.1",
"main": "src/index.ts",
"type": "module",
"scripts": {
@@ -11,18 +11,18 @@
},
"dependencies": {
"@darkruby/assets-core": "workspace:*",
- "@types/ms": "^2.1.0",
+ "ms": "^2.1.3",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
"express": "~4.21.2",
"heap-js": "^2.7.1",
- "io-ts": "~2.2.21",
"jsonwebtoken": "^9.0.2",
"lru-cache": "^11.0.2",
"nodejs-polars": "^0.24.0"
},
"devDependencies": {
+ "@types/ms": "^2.1.0",
"@types/cors": "^2.8.17",
"@types/faker": "5",
"@types/jsonwebtoken": "^9.0.9",
diff --git a/packages/backend/src/enrichment/asset.ts b/packages/backend/src/enrichment/asset.ts
index 428f6219..4a39caab 100644
--- a/packages/backend/src/enrichment/asset.ts
+++ b/packages/backend/src/enrichment/asset.ts
@@ -14,20 +14,19 @@ import * as TE from "fp-ts/lib/TaskEither";
import type { Repository } from "../repository";
import { type YahooApi } from "../yahoo/client";
import { $enrichedAssetBase, $enrichedAssetCcy, txsWithRates } from "./returns";
-import { getTxsEnricher } from "./tx";
+import type { TxEnricher } from "./tx";
-export const getAssetEnricher =
- (repo: Repository, yahooApi: YahooApi) =>
+const getAssetEnricher =
+ (repo: Repository, yahooApi: YahooApi, { enrichMany }: TxEnricher) =>
(
asset: GetAsset,
range: ChartRange = DEFAULT_CHART_RANGE
): Action => {
- const enrichTxs = getTxsEnricher(yahooApi);
return pipe(
TE.Do,
TE.bind("chart", () => yahooApi.chart(asset.ticker, range)),
TE.bind("txs", () => repo.tx.getAll(asset.id, asset.user_id, false)),
- TE.bind("enrichedTxs", ({ txs }) => enrichTxs(txs)),
+ TE.bind("enrichedTxs", ({ txs }) => enrichMany(txs)),
TE.bind("fxRates", ({ chart }) =>
yahooApi.fxRates(chart.meta.currency, asset.base_ccy)
),
@@ -61,10 +60,10 @@ export const getAssetEnricher =
);
};
-export const getAssetsEnricher =
- (repo: Repository, yahooApi: YahooApi) =>
+const getAssetsEnricher =
+ (repo: Repository, yahooApi: YahooApi, txEnricher: TxEnricher) =>
(assets: GetAsset[], range?: ChartRange): Action => {
- const enrichAsset = getAssetEnricher(repo, yahooApi);
+ const enrichAsset = getAssetEnricher(repo, yahooApi, txEnricher);
return pipe(
assets,
TE.traverseArray((asset) => enrichAsset(asset, range)),
@@ -72,20 +71,20 @@ export const getAssetsEnricher =
) as Action;
};
-export const getOptionalAssetEnricher =
- (repo: Repository, yahooApi: YahooApi) =>
+const getOptionalAssetEnricher =
+ (repo: Repository, yahooApi: YahooApi, txEnricher: TxEnricher) =>
(
asset: Optional,
range?: ChartRange
): Action> => {
if (asset) {
- const enrichAsset = getAssetEnricher(repo, yahooApi);
+ const enrichAsset = getAssetEnricher(repo, yahooApi, txEnricher);
return enrichAsset(asset, range);
}
return TE.of(null);
};
-export const calcAssetWeights = (assets: EnrichedAsset[]): EnrichedAsset[] => {
+const calcAssetWeights = (assets: EnrichedAsset[]): EnrichedAsset[] => {
const total = pipe(
assets,
sum(({ base }) => base.invested)
@@ -100,3 +99,18 @@ export const calcAssetWeights = (assets: EnrichedAsset[]): EnrichedAsset[] => {
})
);
};
+
+export type AssetEnricher = ReturnType;
+
+export const createAssetEnricher = (
+ repo: Repository,
+ yahooApi: YahooApi,
+ txEnricher: TxEnricher
+) => {
+ return {
+ enrich: getAssetEnricher(repo, yahooApi, txEnricher),
+ enrichMany: getAssetsEnricher(repo, yahooApi, txEnricher),
+ enrichMaybe: getOptionalAssetEnricher(repo, yahooApi, txEnricher),
+ calcAssetWeights
+ };
+};
diff --git a/packages/backend/src/enrichment/cached.ts b/packages/backend/src/enrichment/cached.ts
new file mode 100644
index 00000000..f56e7cf6
--- /dev/null
+++ b/packages/backend/src/enrichment/cached.ts
@@ -0,0 +1,122 @@
+import {
+ DEFAULT_CHART_RANGE,
+ type ChartRange,
+ type GetAsset,
+ type GetPortfolio,
+ type GetTx,
+ type Optional
+} from "@darkruby/assets-core";
+import ms from "ms";
+import type { Repository } from "../repository";
+import type { AppCache } from "../services/cache";
+import type { YahooApi } from "../yahoo/client";
+import { createAssetEnricher, type AssetEnricher } from "./asset";
+import { key } from "./key";
+import { createPortfolioEnricher, type PortfolioEnricher } from "./portfolio";
+import { createSummaryEnricher, type SummaryEnricher } from "./summary";
+import { createTxEnricher, type TxEnricher } from "./tx";
+
+const txKey = key("enrich-tx");
+const assetKey = key("enrich-asset");
+const portfolioKey = key("enrich-portfolio");
+
+export type Enricher = {
+ tx: TxEnricher;
+ asset: AssetEnricher;
+ portfolio: PortfolioEnricher;
+ summary: SummaryEnricher;
+};
+
+export const createEnricher = (
+ repo: Repository,
+ yahooApi: YahooApi,
+ cache: AppCache
+): Enricher => {
+ const ENRICH_TTL = ms("1min");
+
+ const txEnricher = createTxEnricher(yahooApi);
+ const cachedTxEnricher = {
+ ...txEnricher,
+ enrich: (tx: GetTx) => {
+ const action = () => txEnricher.enrich(tx);
+ return cache.cachedAction(txKey(tx), action, ENRICH_TTL);
+ },
+ enrichMany: (txs: GetTx[]) => {
+ const action = () => txEnricher.enrichMany(txs);
+ return cache.cachedAction(txKey({ txs }), action, ENRICH_TTL);
+ }
+ };
+
+ const assetEnricher = createAssetEnricher(repo, yahooApi, cachedTxEnricher);
+ const cachedAssetEnricher = {
+ ...assetEnricher,
+ enrich: (asset: GetAsset, range: ChartRange = DEFAULT_CHART_RANGE) => {
+ const action = () => assetEnricher.enrich(asset, range);
+ return cache.cachedAction(assetKey({ asset, range }), action, ENRICH_TTL);
+ },
+ enrichMaybe: (
+ asset: Optional,
+ range: ChartRange = DEFAULT_CHART_RANGE
+ ) => {
+ const action = () => assetEnricher.enrichMaybe(asset, range);
+ return cache.cachedAction(assetKey({ asset, range }), action, ENRICH_TTL);
+ },
+ enrichMany: (
+ assets: GetAsset[],
+ range: ChartRange = DEFAULT_CHART_RANGE
+ ) => {
+ const action = () => assetEnricher.enrichMany(assets, range);
+ return cache.cachedAction(
+ assetKey({ assets, range }),
+ action,
+ ENRICH_TTL
+ );
+ }
+ };
+
+ const portfolioEnricher = createPortfolioEnricher(repo, cachedAssetEnricher);
+ const cachedPortfolioEnricher = {
+ ...portfolioEnricher,
+ enrich: (
+ portfolio: GetPortfolio,
+ range: ChartRange = DEFAULT_CHART_RANGE
+ ) => {
+ const action = () => portfolioEnricher.enrich(portfolio, range);
+ return cache.cachedAction(
+ portfolioKey({ portfolio, range }),
+ action,
+ ENRICH_TTL
+ );
+ },
+ enrichMaybe: (
+ portfolio: Optional,
+ range: ChartRange = DEFAULT_CHART_RANGE
+ ) => {
+ const action = () => portfolioEnricher.enrichMaybe(portfolio, range);
+ return cache.cachedAction(
+ portfolioKey({ portfolio, range }),
+ action,
+ ENRICH_TTL
+ );
+ },
+ enrichMany: (
+ portfolios: GetPortfolio[],
+ range: ChartRange = DEFAULT_CHART_RANGE
+ ) => {
+ const action = () => portfolioEnricher.enrichMany(portfolios, range);
+ return cache.cachedAction(
+ portfolioKey({ portfolios, range }),
+ action,
+ ENRICH_TTL
+ );
+ }
+ };
+ const summaryEnricher = createSummaryEnricher();
+
+ return {
+ tx: cachedTxEnricher,
+ asset: cachedAssetEnricher,
+ portfolio: cachedPortfolioEnricher,
+ summary: { ...summaryEnricher }
+ };
+};
diff --git a/packages/backend/src/enrichment/chart.ts b/packages/backend/src/enrichment/chart.ts
index f5c73898..3e787714 100644
--- a/packages/backend/src/enrichment/chart.ts
+++ b/packages/backend/src/enrichment/chart.ts
@@ -307,29 +307,16 @@ const combineMultiChart =
);
};
-export const combineAssetsMultiChart = combineMultiChart(
+export const portfolioMultiChart = combineMultiChart(
({ name: id, base }) => ({
id,
chart: base.chart
})
);
-export const combineSummaryMultiChart = combineMultiChart(
+export const summaryMultiChart = combineMultiChart(
({ name: id, chart }) => ({
id,
chart
})
);
-
-// export const flattenMultiChart = (chart: MultiChartData): ChartData => {
-// return pipe(
-// chart,
-// R.toEntries,
-// A.map(([, chart]) => readRecords(chart, { schema: ChartSchema })),
-// (dfs) => concat(dfs)
-// )
-// .groupBy("timestamp")
-// .agg(col("price").sum(), col("volume").sum())
-// .sort("timestamp")
-// .toRecords() as ChartData;
-// };
diff --git a/packages/backend/src/enrichment/index.ts b/packages/backend/src/enrichment/index.ts
index 43621f1b..d821b867 100644
--- a/packages/backend/src/enrichment/index.ts
+++ b/packages/backend/src/enrichment/index.ts
@@ -1,4 +1,5 @@
export * from "./asset";
+export * from "./cached";
export * from "./portfolio";
export * from "./summary";
export * from "./tx";
diff --git a/packages/backend/src/enrichment/key.ts b/packages/backend/src/enrichment/key.ts
new file mode 100644
index 00000000..b8e6ffa5
--- /dev/null
+++ b/packages/backend/src/enrichment/key.ts
@@ -0,0 +1,11 @@
+export const key =
+ (prefix: string) =>
+ (t: NonNullable) =>
+ `${prefix}-${JSON.stringify(t)}`;
+// export const keys = (prefix: string) => {
+// const maybeKey = (t: Optional) => (defined(t) ? key(t) : `-`);
+// const multiKey = (ts: NonNullable[]) =>
+// `${prefix}s-${ts.map(key).join("-")}`;
+
+// return { key, maybeKey, multiKey };
+// };
diff --git a/packages/backend/src/enrichment/portfolio.ts b/packages/backend/src/enrichment/portfolio.ts
index 6302b0b8..c05695f7 100644
--- a/packages/backend/src/enrichment/portfolio.ts
+++ b/packages/backend/src/enrichment/portfolio.ts
@@ -22,9 +22,12 @@ import { pipe } from "fp-ts/lib/function";
import * as Ord from "fp-ts/lib/Ord";
import * as TE from "fp-ts/lib/TaskEither";
import type { Repository } from "../repository";
-import type { YahooApi } from "../yahoo/client";
-import { calcAssetWeights, getAssetsEnricher } from "./asset";
-import { combineAssetCharts, commonAssetRanges } from "./chart";
+import type { AssetEnricher } from "./asset";
+import {
+ combineAssetCharts,
+ commonAssetRanges,
+ portfolioMultiChart
+} from "./chart";
const sumInvested = sum(({ base }) => base.invested);
const sumRealizedPnl = sum(({ base }) => base.realizedPnl);
@@ -105,21 +108,19 @@ const portfolioTotals = (assets: EnrichedAsset[]): Totals => {
return { returnValue, returnPct };
};
-export const getPortfolioEnricher =
- (repo: Repository, yahooApi: YahooApi) =>
+const getPortfolioEnricher =
+ (repo: Repository, { enrichMany, calcAssetWeights }: AssetEnricher) =>
(
portfolio: GetPortfolio,
range: ChartRange = DEFAULT_CHART_RANGE
): Action => {
- const enrichAssets = getAssetsEnricher(repo, yahooApi);
-
return pipe(
TE.Do,
TE.apS("portfolio", TE.of(portfolio)),
TE.bind("assets", () =>
pipe(
repo.asset.getAll(portfolio.id, portfolio.user_id),
- TE.chain((assets) => enrichAssets(assets, range)),
+ TE.chain((assets) => enrichMany(assets, range)),
TE.map(A.filter((a) => Boolean(a.invested))),
TE.map(calcAssetWeights)
)
@@ -135,6 +136,7 @@ export const getPortfolioEnricher =
const totals = portfolioTotals(assets);
const changes = portfolioChanges(assets);
const chart = combineAssetCharts(assets);
+ const multiChart = portfolioMultiChart(assets);
return {
...portfolio,
@@ -145,6 +147,7 @@ export const getPortfolioEnricher =
domestic,
changes,
chart,
+ multiChart,
invested,
breakEven,
totals,
@@ -155,13 +158,13 @@ export const getPortfolioEnricher =
);
};
-export const getPortfoliosEnricher =
- (repo: Repository, yahooApi: YahooApi) =>
+const getPortfoliosEnricher =
+ (repo: Repository, assetEnricher: AssetEnricher) =>
(
portfolios: GetPortfolio[],
range?: ChartRange
): Action => {
- const enrichPortfolio = getPortfolioEnricher(repo, yahooApi);
+ const enrichPortfolio = getPortfolioEnricher(repo, assetEnricher);
return pipe(
portfolios,
TE.traverseArray((p) => enrichPortfolio(p, range)),
@@ -170,19 +173,19 @@ export const getPortfoliosEnricher =
};
export const getOptionalPorfolioEnricher =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, assetEnricher: AssetEnricher) =>
(
portfolio: Optional,
range?: ChartRange
): Action> => {
if (portfolio) {
- const enrichPortfolio = getPortfolioEnricher(repo, yahooApi);
+ const enrichPortfolio = getPortfolioEnricher(repo, assetEnricher);
return enrichPortfolio(portfolio, range);
}
return TE.of(null);
};
-export const calcPortfolioWeights = (
+const calcPortfolioWeights = (
portfolios: EnrichedPortfolio[]
): EnrichedPortfolio[] => {
const total = pipe(
@@ -199,3 +202,17 @@ export const calcPortfolioWeights = (
})
);
};
+
+export type PortfolioEnricher = ReturnType;
+
+export const createPortfolioEnricher = (
+ repo: Repository,
+ assetEnricher: AssetEnricher
+) => {
+ return {
+ enrich: getPortfolioEnricher(repo, assetEnricher),
+ enrichMany: getPortfoliosEnricher(repo, assetEnricher),
+ enrichMaybe: getOptionalPorfolioEnricher(repo, assetEnricher),
+ calcPortfolioWeights
+ };
+};
diff --git a/packages/backend/src/enrichment/summary.ts b/packages/backend/src/enrichment/summary.ts
index f3070f8b..5c2b9579 100644
--- a/packages/backend/src/enrichment/summary.ts
+++ b/packages/backend/src/enrichment/summary.ts
@@ -16,7 +16,11 @@ import {
import * as A from "fp-ts/lib/Array";
import { pipe } from "fp-ts/lib/function";
import * as Ord from "fp-ts/lib/Ord";
-import { combinePortfolioCharts, commonPortfolioRanges } from "./chart";
+import {
+ combinePortfolioCharts,
+ commonPortfolioRanges,
+ summaryMultiChart
+} from "./chart";
const summaryMeta = (
portfolios: EnrichedPortfolio[]
@@ -130,20 +134,27 @@ export const enrichSummary = (
);
const chart = combinePortfolioCharts(portfolios);
+ const multiChart = summaryMultiChart(portfolios);
const meta = summaryMeta(portfolios);
const changes = summaryChanges(portfolios);
const totals = summaryTotals(portfolios);
return {
- numPortfolios,
- chart,
meta,
- changes,
+ chart,
totals,
+ changes,
invested,
fxImpact,
+ breakEven,
+ multiChart,
realizedPnl,
- breakEven
- };
+ numPortfolios
+ } satisfies EnrichedSummary;
+};
+
+export type SummaryEnricher = ReturnType;
+export const createSummaryEnricher = () => {
+ return { enrich: enrichSummary };
};
diff --git a/packages/backend/src/enrichment/tx.ts b/packages/backend/src/enrichment/tx.ts
index 62b023f4..8f4741ce 100644
--- a/packages/backend/src/enrichment/tx.ts
+++ b/packages/backend/src/enrichment/tx.ts
@@ -9,7 +9,7 @@ import { pipe } from "fp-ts/lib/function";
import * as TE from "fp-ts/lib/TaskEither";
import { type YahooApi } from "../yahoo/client";
-export const getTxEnricher =
+const getTxEnricher =
(yahooApi: YahooApi) =>
(tx: GetTx): Action => {
return pipe(
@@ -25,17 +25,26 @@ export const getTxEnricher =
const [pnl, pnlPct] = calcPnl({ before: tx.cost, after: value });
return { ...tx, value, pnl, pnl_pct: pnlPct };
}
- // consider using EnrichTx decoder
+ // todo: use EnrichTx decoder
const { pnl, value, pnl_pct, ...rest } = tx;
return { ...rest, pnl: pnl!, pnl_pct: pnl_pct!, value: value! };
})
);
};
-export const getTxsEnricher =
+const getTxsEnricher =
(yahooApi: YahooApi) =>
(txs: GetTx[]): Action => {
const enrichTx = getTxEnricher(yahooApi);
- // return pipe(txs, TE.traverseSeqArray(enrichTx)) as Action; // <-- sequential to take advantage of yahoo cache
- return pipe(txs, TE.traverseArray(enrichTx)) as Action; // <-- sequential to take advantage of yahoo cache
+ return pipe(txs, TE.traverseSeqArray(enrichTx)) as Action; // <-- sequential to take advantage of yahoo cache
+ // return pipe(txs, TE.traverseArray(enrichTx)) as Action; // <-- sequential to take advantage of yahoo cache
};
+
+export type TxEnricher = ReturnType;
+
+export const createTxEnricher = (yahooApi: YahooApi) => {
+ return {
+ enrich: getTxEnricher(yahooApi),
+ enrichMany: getTxsEnricher(yahooApi)
+ };
+};
diff --git a/packages/backend/src/handlers/asset.ts b/packages/backend/src/handlers/asset.ts
index b877af14..f8248223 100644
--- a/packages/backend/src/handlers/asset.ts
+++ b/packages/backend/src/handlers/asset.ts
@@ -45,7 +45,7 @@ export const getAsset: HandlerTask, Context> = ({
export const createAsset: HandlerTask, Context> = ({
params: [req, res],
- context: { repo, yahooApi, service }
+ context: { service }
}) =>
pipe(
TE.Do,
diff --git a/packages/backend/src/handlers/index.ts b/packages/backend/src/handlers/index.ts
index 56da42ca..afda372c 100644
--- a/packages/backend/src/handlers/index.ts
+++ b/packages/backend/src/handlers/index.ts
@@ -13,6 +13,7 @@ import * as user from "./user";
import * as yahoo from "./yahoo";
export type Handlers = ReturnType;
+
export const createHandlers = (
expressify: (task: HandlerTask) => express.RequestHandler
) => ({
diff --git a/packages/backend/src/handlers/prefs.ts b/packages/backend/src/handlers/prefs.ts
index ab2422f2..c73eafb3 100644
--- a/packages/backend/src/handlers/prefs.ts
+++ b/packages/backend/src/handlers/prefs.ts
@@ -13,7 +13,7 @@ export const getPrefs: HandlerTask = ({
export const updatePrefs: HandlerTask = ({
params: [req, res],
- context: { repo, service }
+ context: { service }
}) =>
pipe(
service.auth.requireUserId(res),
diff --git a/packages/backend/src/handlers/profile.ts b/packages/backend/src/handlers/profile.ts
index 8b32685f..b685a427 100644
--- a/packages/backend/src/handlers/profile.ts
+++ b/packages/backend/src/handlers/profile.ts
@@ -43,7 +43,7 @@ export const updatePassword: HandlerTask = ({
export const deleteProfile: HandlerTask, Context> = ({
params: [, res],
- context: { repo, service }
+ context: { service }
}) =>
pipe(
TE.Do,
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index f84c43dd..60b1cccc 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -4,20 +4,16 @@ import cors from "cors";
import { default as express } from "express";
import { pipe } from "fp-ts/lib/function";
import * as TE from "fp-ts/lib/TaskEither";
-import { LRUCache } from "lru-cache";
import { Server } from "node:http";
import path from "node:path";
+import { createEnricher } from "./enrichment";
import { createRequestHandler } from "./fp-express";
import { createHandlers } from "./handlers";
import type { Context } from "./handlers/context";
import { createRepository, type Repository } from "./repository";
import { execute } from "./repository/database";
import { createWebService } from "./services";
-import {
- createCache,
- type AppCache,
- type Stringifiable
-} from "./services/cache";
+import { createCache, type AppCache } from "./services/cache";
import { env, envDurationMsec, envNumber } from "./services/env";
import { initializeApp } from "./services/init";
import { cachedYahooApi } from "./yahoo/cached";
@@ -48,15 +44,7 @@ const repository = (c: Config): Action =>
);
const cache = ({ cacheSize, cacheTtl }: Config): Action =>
- pipe(
- TE.of(
- new LRUCache({
- max: cacheSize,
- ttl: cacheTtl
- })
- ),
- TE.map(createCache)
- );
+ pipe(TE.of(createCache(cacheSize, cacheTtl)));
const server = ({ port, app }: Config, ctx: Context): Action => {
const expressify = createRequestHandler(ctx);
@@ -175,9 +163,12 @@ const app = () =>
TE.Do,
TE.bind("repo", () => repository(config)),
TE.bind("cache", () => cache(config)),
- TE.bind("yahooApi", ({ cache }) => TE.of(cachedYahooApi(cache))),
- TE.bind("service", ({ repo, yahooApi }) =>
- TE.of(createWebService(repo, yahooApi))
+ TE.let("yahooApi", ({ cache }) => cachedYahooApi(cache)),
+ TE.let("enricher", ({ repo, cache, yahooApi }) =>
+ createEnricher(repo, yahooApi, cache)
+ ),
+ TE.bind("service", ({ repo, yahooApi, enricher }) =>
+ TE.of(createWebService(repo, yahooApi, enricher))
)
)
),
diff --git a/packages/backend/src/repository/prefs.ts b/packages/backend/src/repository/prefs.ts
index e23961e0..ce6322ee 100644
--- a/packages/backend/src/repository/prefs.ts
+++ b/packages/backend/src/repository/prefs.ts
@@ -1,8 +1,8 @@
import {
+ DbPrefsDecoder,
handleError,
- PrefsDecoder,
type Action,
- type Prefs,
+ type Prefs
} from "@darkruby/assets-core";
import { liftTE } from "@darkruby/assets-core/src/decoders/util";
import type { UserId } from "@darkruby/assets-core/src/domain/user";
@@ -16,8 +16,8 @@ import { getPrefsSql, updatePrefsSql } from "./sql" with { type: "macro" };
const sql = {
prefs: {
get: TE.of(getPrefsSql()),
- update: TE.of(updatePrefsSql()),
- },
+ update: TE.of(updatePrefsSql())
+ }
};
export const getPrefs =
@@ -27,14 +27,15 @@ export const getPrefs =
queryOne({ userId }),
ID.ap(sql.prefs.get),
ID.ap(db),
- TE.chain(liftTE(PrefsDecoder))
+ TE.chain(liftTE(DbPrefsDecoder))
);
export const updatePrefs =
(db: Database) =>
(userId: UserId, prefs: Prefs): Action => {
+ const dbPrefs = DbPrefsDecoder.encode(prefs);
return pipe(
- execute({ ...prefs, userId }),
+ execute({ ...dbPrefs, userId }),
ID.ap(sql.prefs.update),
ID.ap(db),
TE.chain(() => getPrefs(db)(userId)),
diff --git a/packages/backend/src/repository/sql/asset/update.sql b/packages/backend/src/repository/sql/asset/update.sql
index af648bd5..0f5b6886 100644
--- a/packages/backend/src/repository/sql/asset/update.sql
+++ b/packages/backend/src/repository/sql/asset/update.sql
@@ -1,6 +1,6 @@
UPDATE assets
SET name = $name,
ticker = $ticker,
- modified = CURRENT_TIMESTAMP
+ modified = datetime('subsec')
WHERE id = $assetId
and portfolio_id = $portfolioId
\ No newline at end of file
diff --git a/packages/backend/src/repository/sql/portfolio/update.sql b/packages/backend/src/repository/sql/portfolio/update.sql
index 6bed22d2..183a905d 100644
--- a/packages/backend/src/repository/sql/portfolio/update.sql
+++ b/packages/backend/src/repository/sql/portfolio/update.sql
@@ -1,6 +1,6 @@
UPDATE portfolios
SET name = $name,
description = $description,
- modified = CURRENT_TIMESTAMP
+ modified = datetime('subsec')
WHERE id = $portfolioId
AND user_id = $userId;
\ No newline at end of file
diff --git a/packages/backend/src/repository/sql/prefs/get.sql b/packages/backend/src/repository/sql/prefs/get.sql
index 1f1618b0..5a9a39b9 100644
--- a/packages/backend/src/repository/sql/prefs/get.sql
+++ b/packages/backend/src/repository/sql/prefs/get.sql
@@ -1,4 +1,6 @@
-select id, base_ccy
+select id,
+ base_ccy,
+ additional
from prefs p
where p.user_id = $userId
limit 1;
\ No newline at end of file
diff --git a/packages/backend/src/repository/sql/prefs/update.sql b/packages/backend/src/repository/sql/prefs/update.sql
index 4615ce2c..decc04a8 100644
--- a/packages/backend/src/repository/sql/prefs/update.sql
+++ b/packages/backend/src/repository/sql/prefs/update.sql
@@ -1,3 +1,6 @@
update prefs
-set base_ccy = $base_ccy
+set
+ base_ccy = $base_ccy,
+ additional = $additional,
+ modified = datetime('subsec')
where user_id = $userId;
\ No newline at end of file
diff --git a/packages/backend/src/repository/sql/tx/update.sql b/packages/backend/src/repository/sql/tx/update.sql
index cdb3b917..8af8f597 100644
--- a/packages/backend/src/repository/sql/tx/update.sql
+++ b/packages/backend/src/repository/sql/tx/update.sql
@@ -4,6 +4,6 @@ SET type = $type,
price = $price,
comments = $comments,
date = $date,
- modified = CURRENT_TIMESTAMP
+ modified = datetime('subsec')
WHERE id = $txId
and asset_id = $assetId;
\ No newline at end of file
diff --git a/packages/backend/src/repository/sql/user/update-profile-only.sql b/packages/backend/src/repository/sql/user/update-profile-only.sql
index 8ceabc7c..321bea43 100644
--- a/packages/backend/src/repository/sql/user/update-profile-only.sql
+++ b/packages/backend/src/repository/sql/user/update-profile-only.sql
@@ -3,5 +3,5 @@ set username = $username,
admin = $admin,
login_attempts = $login_attempts,
locked = $locked,
- modified = CURRENT_TIMESTAMP
+ modified = datetime('subsec')
where id = $userId;
\ No newline at end of file
diff --git a/packages/backend/src/repository/sql/user/update.sql b/packages/backend/src/repository/sql/user/update.sql
index 8533cc60..11553568 100644
--- a/packages/backend/src/repository/sql/user/update.sql
+++ b/packages/backend/src/repository/sql/user/update.sql
@@ -5,5 +5,5 @@ set username = $username,
admin = $admin,
login_attempts = $login_attempts,
locked = $locked,
- modified = CURRENT_TIMESTAMP
+ modified = datetime('subsec')
where id = $userId;
\ No newline at end of file
diff --git a/packages/backend/src/services/asset.ts b/packages/backend/src/services/asset.ts
index 6ccf2fa4..e49315b1 100644
--- a/packages/backend/src/services/asset.ts
+++ b/packages/backend/src/services/asset.ts
@@ -12,11 +12,7 @@ import { liftTE } from "@darkruby/assets-core/src/decoders/util";
import { pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import { mapWebError } from "../domain/error";
-import {
- getAssetEnricher,
- getAssetsEnricher,
- getOptionalAssetEnricher
-} from "../enrichment";
+import { type AssetEnricher } from "../enrichment";
import type { WebAction } from "../fp-express";
import type { Repository } from "../repository";
import type { YahooApi } from "../yahoo/client";
@@ -24,34 +20,32 @@ import type { YahooApi } from "../yahoo/client";
const assetDecoder = liftTE(PostAssetDecoder);
export const getAsset =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrichMaybe }: AssetEnricher) =>
(
assetId: AssetId,
portfolioId: PortfolioId,
userId: UserId,
range: ChartRange
): WebAction> => {
- const enrichAsset = getOptionalAssetEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("asset", () => repo.asset.get(assetId, portfolioId, userId)),
- TE.chain(({ asset }) => enrichAsset(asset, range)),
+ TE.chain(({ asset }) => enrichMaybe(asset, range)),
mapWebError
);
};
export const getAssets =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrichMany }: AssetEnricher) =>
(
userId: UserId,
portfolioId: PortfolioId,
range: ChartRange
): WebAction => {
- const enrichAssets = getAssetsEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("assets", () => repo.asset.getAll(portfolioId, userId)),
- TE.chain(({ assets }) => enrichAssets(assets, range)),
+ TE.chain(({ assets }) => enrichMany(assets, range)),
mapWebError
);
};
@@ -71,13 +65,12 @@ export const deleteAsset =
};
export const createAsset =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, yahooApi: YahooApi, { enrich }: AssetEnricher) =>
(
portfolioId: PortfolioId,
userId: UserId,
payload: unknown
): WebAction => {
- const enrichAsset = getAssetEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("asset", () => assetDecoder(payload)),
@@ -85,20 +78,19 @@ export const createAsset =
TE.bind("created", ({ asset }) =>
repo.asset.create(asset, portfolioId, userId)
),
- TE.chain(({ created }) => enrichAsset(created)),
+ TE.chain(({ created }) => enrich(created)),
mapWebError
);
};
export const updateAsset =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, yahooApi: YahooApi, { enrich }: AssetEnricher) =>
(
assetId: AssetId,
portfolioId: PortfolioId,
userId: UserId,
payload: unknown
): WebAction => {
- const enrichAsset = getAssetEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("asset", () => assetDecoder(payload)),
@@ -106,7 +98,7 @@ export const updateAsset =
TE.bind("updated", ({ asset }) =>
repo.asset.update(assetId, portfolioId, userId, asset)
),
- TE.chain(({ updated }) => enrichAsset(updated)),
+ TE.chain(({ updated }) => enrich(updated)),
mapWebError
);
};
diff --git a/packages/backend/src/services/cache.ts b/packages/backend/src/services/cache.ts
index ccb1bba8..46efd01e 100644
--- a/packages/backend/src/services/cache.ts
+++ b/packages/backend/src/services/cache.ts
@@ -1,8 +1,8 @@
-import { type Action } from "@darkruby/assets-core";
+import { defined, type Action } from "@darkruby/assets-core";
import * as O from "fp-ts/lib/Option";
import * as TE from "fp-ts/lib/TaskEither";
import { pipe } from "fp-ts/lib/function";
-import { type LRUCache } from "lru-cache";
+import { LRUCache } from "lru-cache";
import { createHash } from "node:crypto";
import { createLogger } from "../fp-express";
@@ -21,7 +21,7 @@ const getter =
(key: string): O.Option => {
return pipe(
O.tryCatch(() => cache.get(toKey(key))),
- O.filter((x) => x !== null && x !== undefined)
+ O.filter(defined /*(x) => x !== null && x !== undefined*/)
);
};
@@ -29,8 +29,7 @@ const setter =
(cache: Cache) =>
(key: string, val: T, ttl?: number): O.Option => {
return pipe(
- O.of(val),
- O.chain(() => O.tryCatch(() => cache.set(toKey(key), val, { ttl }))),
+ O.tryCatch(() => cache.set(toKey(key), val, { ttl })),
O.map(() => val)
);
};
@@ -41,10 +40,10 @@ const cachedAction =
const get = getter(cache);
const res = get(key);
if (O.isSome(res)) {
- // log.debug(`hit for ${key}`);
+ log.debug(`HIT for ${key.substring(0, 10)}`);
return TE.of(res.value as T);
}
- // log.debug(`miss for ${key}`);
+ log.debug(`MISS for ${key.substring(0, 10)}`);
const set = setter(cache);
return pipe(
@@ -55,7 +54,8 @@ const cachedAction =
export type AppCache = ReturnType;
-export const createCache = (cache: Cache) => {
+export const createCache = (size: number, ttl: number) => {
+ const cache = new LRUCache({ max: size, ttl });
return {
has: has(cache),
getter: getter(cache),
@@ -63,3 +63,9 @@ export const createCache = (cache: Cache) => {
cachedAction: cachedAction(cache)
};
};
+
+// const aaa = (cache: AppCache) => {
+// return function wrap(f: FunctionN>) {
+// return (...a: A) => B;
+// }
+// };
diff --git a/packages/backend/src/services/index.ts b/packages/backend/src/services/index.ts
index 46977e88..e8fc340f 100644
--- a/packages/backend/src/services/index.ts
+++ b/packages/backend/src/services/index.ts
@@ -1,3 +1,4 @@
+import type { Enricher } from "../enrichment";
import type { Repository } from "../repository";
import type { YahooApi } from "../yahoo/client";
import * as asset from "./asset";
@@ -9,7 +10,11 @@ import * as user from "./user";
export type WebService = ReturnType;
-export const createWebService = (repo: Repository, yahooApi: YahooApi) => {
+export const createWebService = (
+ repo: Repository,
+ yahooApi: YahooApi,
+ enricher: Enricher
+) => {
return {
auth: {
createToken: auth.createToken,
@@ -29,26 +34,26 @@ export const createWebService = (repo: Repository, yahooApi: YahooApi) => {
updateOwnPasswordOnly: user.updateOwnPasswordOnly(repo)
},
assets: {
- get: asset.getAsset(repo, yahooApi),
- getMany: asset.getAssets(repo, yahooApi),
+ get: asset.getAsset(repo, enricher.asset),
+ getMany: asset.getAssets(repo, enricher.asset),
delete: asset.deleteAsset(repo),
- create: asset.createAsset(repo, yahooApi),
- update: asset.updateAsset(repo, yahooApi),
+ create: asset.createAsset(repo, yahooApi, enricher.asset),
+ update: asset.updateAsset(repo, yahooApi, enricher.asset),
move: asset.moveAsset(repo)
},
portfolio: {
- get: portfolio.getPortfolio(repo, yahooApi),
- getMany: portfolio.getPortfolios(repo, yahooApi),
+ get: portfolio.getPortfolio(repo, enricher.portfolio),
+ getMany: portfolio.getPortfolios(repo, enricher.portfolio),
delete: portfolio.deletePortfolio(repo),
- create: portfolio.createPortfolio(repo, yahooApi),
- update: portfolio.updatePortfolio(repo, yahooApi)
+ create: portfolio.createPortfolio(repo, enricher.portfolio),
+ update: portfolio.updatePortfolio(repo, enricher.portfolio)
},
tx: {
- get: tx.getTx(repo, yahooApi),
- getMany: tx.getTxs(repo, yahooApi),
+ get: tx.getTx(repo, enricher.tx),
+ getMany: tx.getTxs(repo, enricher.tx),
delete: tx.deleteTx(repo),
- create: tx.createTx(repo, yahooApi),
- update: tx.updateTx(repo, yahooApi),
+ create: tx.createTx(repo, enricher.tx),
+ update: tx.updateTx(repo, enricher.tx),
uploadAssetTxs: tx.uploadAssetTxs(repo),
deleteAllAsset: tx.deleteAllAssetTxs(repo)
},
diff --git a/packages/backend/src/services/portfolio.ts b/packages/backend/src/services/portfolio.ts
index 495b0e12..7d9c31b6 100644
--- a/packages/backend/src/services/portfolio.ts
+++ b/packages/backend/src/services/portfolio.ts
@@ -11,79 +11,69 @@ import { liftTE } from "@darkruby/assets-core/src/decoders/util";
import { pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import { mapWebError } from "../domain/error";
-import {
- getOptionalPorfolioEnricher,
- getPortfolioEnricher,
- getPortfoliosEnricher
-} from "../enrichment";
+import { type PortfolioEnricher } from "../enrichment";
import type { WebAction } from "../fp-express";
import type { Repository } from "../repository";
-import type { YahooApi } from "../yahoo/client";
-// import { getTxs as enrichedTxsGetter } from "./tx";
const portfolioDecoder = liftTE(PostPortfolioDecoder);
export const getPortfolio =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrichMaybe }: PortfolioEnricher) =>
(
portfolioId: PortfolioId,
userId: UserId,
range: ChartRange
): WebAction> => {
- const enrichPortfolio = getOptionalPorfolioEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("portfolio", () => repo.portfolio.get(portfolioId, userId)),
- TE.chain(({ portfolio }) => enrichPortfolio(portfolio, range)),
+ TE.chain(({ portfolio }) => enrichMaybe(portfolio, range)),
mapWebError
);
};
export const getPortfolios =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrichMany }: PortfolioEnricher) =>
(
userId: UserId,
range: ChartRange
): WebAction => {
- const enrichPortfolios = getPortfoliosEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("portfolios", () => repo.portfolio.getAll(userId)),
- TE.chain(({ portfolios }) => enrichPortfolios(portfolios, range)),
+ TE.chain(({ portfolios }) => enrichMany(portfolios, range)),
mapWebError
);
};
export const createPortfolio =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrich }: PortfolioEnricher) =>
(userId: UserId, payload: unknown): WebAction => {
- const enrichPortfolio = getPortfolioEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("portfolio", () => portfolioDecoder(payload)),
TE.bind("created", ({ portfolio }) =>
repo.portfolio.create(portfolio, userId)
),
- TE.chain(({ created }) => enrichPortfolio(created)),
+ TE.chain(({ created }) => enrich(created)),
mapWebError
);
};
export const updatePortfolio =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrich }: PortfolioEnricher) =>
(
portfolioId: PortfolioId,
userId: UserId,
payload: unknown
): WebAction => {
- const enrichPortfolio = getPortfolioEnricher(repo, yahooApi);
return pipe(
TE.Do,
TE.bind("portfolio", () => portfolioDecoder(payload)),
TE.bind("updated", ({ portfolio }) =>
repo.portfolio.update(portfolioId, portfolio, userId)
),
- TE.chain(({ updated }) => enrichPortfolio(updated)),
+ TE.chain(({ updated }) => enrich(updated)),
mapWebError
);
};
diff --git a/packages/backend/src/services/tx.ts b/packages/backend/src/services/tx.ts
index 89da725e..c331245d 100644
--- a/packages/backend/src/services/tx.ts
+++ b/packages/backend/src/services/tx.ts
@@ -13,66 +13,61 @@ import { liftTE } from "@darkruby/assets-core/src/decoders/util";
import { pipe } from "fp-ts/lib/function";
import * as TE from "fp-ts/TaskEither";
import { mapWebError } from "../domain/error";
-import { getTxEnricher, getTxsEnricher } from "../enrichment";
+import type { TxEnricher } from "../enrichment";
import type { WebAction } from "../fp-express";
import type { Repository } from "../repository";
-import type { YahooApi } from "../yahoo/client";
const txDecoder = liftTE(PostTxDecoder);
const txUploadDecoder = liftTE(PostTxsUploadDecoder);
export const getTx =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrich }: TxEnricher) =>
(
txId: TxId,
assetId: AssetId,
_portfolioId: PortfolioId,
userId: UserId
): WebAction> => {
- const enrichTx = getTxEnricher(yahooApi);
return pipe(
repo.tx.get(txId, assetId, userId),
- TE.chain((tx) => (tx ? enrichTx(tx) : TE.of(null))),
+ TE.chain((tx) => (tx ? enrich(tx) : TE.of(null))),
mapWebError
);
};
export const getTxs =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrichMany }: TxEnricher) =>
(
assetId: AssetId,
_portfolioId: PortfolioId,
userId: UserId,
finalStretch: boolean = false
): WebAction => {
- const enrichTxs = getTxsEnricher(yahooApi);
-
return pipe(
repo.tx.getAll(assetId, userId, finalStretch),
- TE.chain(enrichTxs),
+ TE.chain(enrichMany),
mapWebError
);
};
export const createTx =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrich }: TxEnricher) =>
(
assetId: AssetId,
_portfolioId: PortfolioId,
userId: UserId,
payload: unknown
): WebAction => {
- const enrichTx = getTxEnricher(yahooApi);
return pipe(
txDecoder(payload),
TE.chain((tx) => repo.tx.create(tx, assetId, userId)),
- TE.chain(enrichTx),
+ TE.chain(enrich),
mapWebError
);
};
export const updateTx =
- (repo: Repository, yahooApi: YahooApi) =>
+ (repo: Repository, { enrich }: TxEnricher) =>
(
txId: TxId,
assetId: AssetId,
@@ -80,11 +75,10 @@ export const updateTx =
userId: UserId,
payload: unknown
): WebAction => {
- const enrichTx = getTxEnricher(yahooApi);
return pipe(
txDecoder(payload),
TE.chain((tx) => repo.tx.update(txId, tx, assetId, userId)),
- TE.chain(enrichTx),
+ TE.chain(enrich),
mapWebError
);
};
diff --git a/packages/backend/src/yahoo/cached.ts b/packages/backend/src/yahoo/cached.ts
index f648750e..0fa1df19 100644
--- a/packages/backend/src/yahoo/cached.ts
+++ b/packages/backend/src/yahoo/cached.ts
@@ -5,56 +5,57 @@ import {
} from "@darkruby/assets-core/src/decoders/yahoo/meta";
import { createLogger } from "../fp-express";
+import ms from "ms";
import type { AppCache } from "../services/cache";
import { yahooApi as rawYahooApi, type YahooApi } from "./client";
const logger = createLogger("cached yahoo");
export const cachedYahooApi = (cache: AppCache): YahooApi => {
- const CHART_TTL = 1000 * 60 * 10; // 1 minutes
- const SEARCH_TTL = 1000 * 60 * 10; // 10 minutes
- const LOOKUP_TTL = 1000 * 60 * 60; // 1 hr
+ const MIN_1 = ms("1min");
+ const MIN_10 = ms("10min");
+ const HOUR_1 = ms("1hr");
const search = (term: string) =>
cache.cachedAction(
`yahoo-search-${term}`,
() => rawYahooApi.search(term),
- SEARCH_TTL
+ MIN_10
);
const chart = (symbol: string, range?: ChartRange) =>
cache.cachedAction(
`yahoo-chart-${symbol}-${range ?? DEFAULT_CHART_RANGE}`,
() => rawYahooApi.chart(symbol, range),
- CHART_TTL
+ MIN_1
);
const meta = (symbol: string) =>
cache.cachedAction(
`yahoo-meta-${symbol}`,
() => rawYahooApi.meta(symbol),
- LOOKUP_TTL
+ HOUR_1
);
const fxRate = (ccy: string, base: Ccy, date?: Optional) =>
cache.cachedAction(
`yahoo-ccy-lookup-${ccy}-${base}-${date?.getTime() ?? "latest"}`,
() => rawYahooApi.fxRate(ccy, base, date),
- LOOKUP_TTL
+ HOUR_1
);
const checkTickerExists = (symbol: string) =>
cache.cachedAction(
`yahoo-check-ticker-${symbol}`,
() => rawYahooApi.checkTickerExists(symbol),
- LOOKUP_TTL
+ HOUR_1
);
const fxRates = (ccy: string, base: Ccy) =>
cache.cachedAction(
`yahoo-fx-rates-${ccy}-${base}`,
() => rawYahooApi.fxRates(ccy, base),
- LOOKUP_TTL
+ HOUR_1
);
return {
diff --git a/packages/backend/test/chart.spec.ts b/packages/backend/test/chart.spec.ts
index 0fa38a5d..501ddb06 100644
--- a/packages/backend/test/chart.spec.ts
+++ b/packages/backend/test/chart.spec.ts
@@ -4,7 +4,7 @@ import type {
UnixDate
} from "@darkruby/assets-core";
import { expect, test } from "bun:test";
-import { combineAssetsMultiChart } from "../src/enrichment/chart";
+import { portfolioMultiChart } from "../src/enrichment/chart";
const createMockChartPoint = (
timestamp: number,
@@ -24,14 +24,14 @@ const createMockAsset = (name: string, ts: number[]): EnrichedAsset => {
} as unknown as EnrichedAsset;
};
-test.failing("combineMultiChart with asset containers", () => {
+test.failing("portfolioMultiChart with asset containers", () => {
const assets = [
createMockAsset("aapl", [1, 3, 5]),
createMockAsset("googl", [2, 4, 6]),
createMockAsset("msft", [1, 4, 5])
] as EnrichedAsset[];
- const { aapl, googl, msft } = combineAssetsMultiChart(assets);
+ const { aapl, googl, msft } = portfolioMultiChart(assets);
expect(aapl.length).toBe(googl.length);
expect(googl.length).toBe(msft.length);
diff --git a/packages/backend/test/helper.ts b/packages/backend/test/helper.ts
index bd381f1e..677eb80e 100644
--- a/packages/backend/test/helper.ts
+++ b/packages/backend/test/helper.ts
@@ -21,7 +21,8 @@ import * as TE from "fp-ts/lib/TaskEither";
const BASE_URL = `http://${process.env.URL ?? "localhost:4020"}`;
export const fakePrefs = (): Prefs => ({
- base_ccy: faker.random.arrayElement(BASE_CCYS)
+ base_ccy: faker.random.arrayElement(BASE_CCYS),
+ additional: { altChart: false }
});
export const fakeNewUser = (admin = false): NewUser => ({
diff --git a/packages/backend/test/portfolio.spec.ts b/packages/backend/test/portfolio.spec.ts
index b7ee3afb..2b38b3c8 100644
--- a/packages/backend/test/portfolio.spec.ts
+++ b/packages/backend/test/portfolio.spec.ts
@@ -33,6 +33,9 @@ test("Create portfolio", async () => {
});
test("Get multiple portfolios", async () => {
+ for (let _ in [1, 2, 3]) {
+ await run(api.portfolio.create(fakePortfolio()));
+ }
const portfolios = await run(api.portfolio.getMany());
expect(portfolios).toSatisfy((a) => Array.isArray(a) && a.length > 0);
});
diff --git a/packages/core/package.json b/packages/core/package.json
index de0bfad2..e7856f55 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@darkruby/assets-core",
- "version": "1.8.0",
+ "version": "1.8.1",
"main": "src/index.ts",
"type": "module",
"scripts": {
diff --git a/packages/core/src/decoders/portfolio.ts b/packages/core/src/decoders/portfolio.ts
index b2c89462..d5120d72 100644
--- a/packages/core/src/decoders/portfolio.ts
+++ b/packages/core/src/decoders/portfolio.ts
@@ -2,7 +2,7 @@ import * as t from "io-ts";
import { dateDecoder } from "./date";
import { UserIdDecoder } from "./user";
import { nullableDecoder } from "./util";
-import { ChartDataDecoder } from "./yahoo/chart";
+import { ChartDataDecoder, MultiChartDataDecoder } from "./yahoo/chart";
import { RangeDecoder } from "./yahoo/meta";
import { PeriodChangesDecoder, TotalsDecoder } from "./yahoo/period";
@@ -42,7 +42,7 @@ export const EnrichedPortfolioDecoder = t.type({
weight: nullableDecoder(t.number),
domestic: t.boolean,
chart: ChartDataDecoder,
- // multiChart: MultiChartDataDecoder,
+ multiChart: MultiChartDataDecoder,
changes: PeriodChangesDecoder,
totals: TotalsDecoder,
invested: t.number,
diff --git a/packages/core/src/decoders/prefs.ts b/packages/core/src/decoders/prefs.ts
index 2f3bd60b..a15556a3 100644
--- a/packages/core/src/decoders/prefs.ts
+++ b/packages/core/src/decoders/prefs.ts
@@ -1,6 +1,7 @@
import * as A from "fp-ts/lib/Array";
import { pipe } from "fp-ts/lib/function";
import * as t from "io-ts";
+import { JsonFromString, withFallback } from "io-ts-types";
export const BASE_CCYS = [
"USD",
@@ -14,6 +15,7 @@ export const BASE_CCYS = [
"DKK",
"NZD",
"JPY",
+ "INR"
] as const;
export type Ccy = (typeof BASE_CCYS)[number] | "GBp";
@@ -26,16 +28,34 @@ export const CcyDecoder = pipe(
codecs as [
t.LiteralC,
t.LiteralC,
- ...t.LiteralC[],
+ ...t.LiteralC[]
]
)
) as t.Type;
+const additionqlPrefsTypes = {
+ altChart: withFallback(t.boolean, false)
+};
+
+export const AdditionalPrefsDecoder = t.type(additionqlPrefsTypes);
+export const defaultAdditionalPrefs = (): t.TypeOf<
+ typeof AdditionalPrefsDecoder
+> => ({
+ altChart: false
+});
+
const prefsTypes = {
base_ccy: CcyDecoder,
+ additional: withFallback(AdditionalPrefsDecoder, defaultAdditionalPrefs())
+};
+
+const dbPrefsTypes = {
+ ...prefsTypes,
+ additional: t.string.pipe(JsonFromString).pipe(prefsTypes.additional)
};
export const PrefsDecoder = t.type(prefsTypes);
+export const DbPrefsDecoder = t.type(dbPrefsTypes);
export const ccyToLocale = (ccy: Ccy): string => {
switch (ccy) {
@@ -59,6 +79,8 @@ export const ccyToLocale = (ccy: Ccy): string => {
return "en-NZ";
case "JPY":
return "ja-JP";
+ case "INR":
+ return "en-IN";
case "USD":
default:
return "en-US";
diff --git a/packages/core/src/decoders/summary.ts b/packages/core/src/decoders/summary.ts
index 67a01a22..8aaa7580 100644
--- a/packages/core/src/decoders/summary.ts
+++ b/packages/core/src/decoders/summary.ts
@@ -1,12 +1,12 @@
import * as t from "io-ts";
import { PortfolioMetaDecoder } from "./portfolio";
-import { ChartDataDecoder } from "./yahoo/chart";
+import { ChartDataDecoder, MultiChartDataDecoder } from "./yahoo/chart";
import { PeriodChangesDecoder, TotalsDecoder } from "./yahoo/period";
const summaryTypes = {
numPortfolios: t.number,
chart: ChartDataDecoder,
- // multiChart: MultiChartDataDecoder,
+ multiChart: MultiChartDataDecoder,
changes: PeriodChangesDecoder,
totals: TotalsDecoder,
meta: PortfolioMetaDecoder,
diff --git a/packages/core/src/decoders/user.ts b/packages/core/src/decoders/user.ts
index d3864a06..da862b00 100644
--- a/packages/core/src/decoders/user.ts
+++ b/packages/core/src/decoders/user.ts
@@ -1,9 +1,9 @@
import { pipe } from "fp-ts/lib/function";
import type { Refinement } from "fp-ts/lib/Refinement";
import * as t from "io-ts";
+import { nonEmptyField } from "../validation/util";
import { BooleanDecoder } from "./boolean";
import { dateDecoder } from "./date";
-import { nonEmptyString } from "./string";
export const UserIdDecoder = t.brand(
t.number,
@@ -15,14 +15,14 @@ export const UserIdDecoder = t.brand(
);
const credentialsTypes = {
- username: nonEmptyString,
- password: nonEmptyString
+ username: nonEmptyField("username"),
+ password: nonEmptyField("password")
};
const passwordChangeTypes = {
- oldPassword: nonEmptyString,
- newPassword: nonEmptyString,
- repeat: nonEmptyString
+ oldPassword: nonEmptyField("old password"),
+ newPassword: nonEmptyField("new password"),
+ repeat: nonEmptyField("repeat password")
};
const newUserTypes = {
diff --git a/packages/core/src/domain/prefs.ts b/packages/core/src/domain/prefs.ts
index 67b8db5b..5c907c41 100644
--- a/packages/core/src/domain/prefs.ts
+++ b/packages/core/src/domain/prefs.ts
@@ -1,8 +1,18 @@
import * as t from "io-ts";
-import { BASE_CCYS, type PrefsDecoder } from "../decoders";
+import {
+ AdditionalPrefsDecoder,
+ BASE_CCYS,
+ type PrefsDecoder
+} from "../decoders";
export type Prefs = t.TypeOf;
+export type AdditionalPrefs = t.TypeOf;
+
+export const defaultAdditional = (): AdditionalPrefs => ({
+ altChart: false
+});
export const defaultPrefs = (): Prefs => ({
base_ccy: BASE_CCYS[0],
+ additional: defaultAdditional()
});
diff --git a/packages/core/src/domain/tx.ts b/packages/core/src/domain/tx.ts
index 82d475d7..423154e5 100644
--- a/packages/core/src/domain/tx.ts
+++ b/packages/core/src/domain/tx.ts
@@ -40,6 +40,10 @@ export const byDateAsc = pipe(
export const isBuy = ({ type }: T) => type == "buy";
export const isSell = (tx: T) => !isBuy(tx);
+export const toKey = (tx: T) =>
+ `tx-${tx.id}-${tx.modified.getTime()}`;
+export const toKeys = (tx: T[]) => tx.map(toKey).join(`-`);
+
export const cloneTx = ({
type,
quantity,
diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts
index b13d6d62..8b821a70 100644
--- a/packages/core/src/utils/utils.ts
+++ b/packages/core/src/utils/utils.ts
@@ -9,7 +9,7 @@ export type Replace = Identity<
Omit & { [key in K]: R }
>;
-export const defined = (t: Optional): t is T =>
+export const defined = (t: Optional): t is NonNullable =>
t !== null && t !== undefined;
export type Result = E.Either;
diff --git a/packages/core/src/validation/util.ts b/packages/core/src/validation/util.ts
index 5c550a05..625cbaae 100644
--- a/packages/core/src/validation/util.ts
+++ b/packages/core/src/validation/util.ts
@@ -1,7 +1,7 @@
import * as E from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/function";
import * as t from "io-ts";
-import { nonEmptyString } from "../decoders";
+import { nonEmptyString } from "../decoders/string";
import { validationErr, withErrorMessage } from "../decoders/util";
export type Validator = ReturnType;
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index 0f02816f..a979c738 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -1,5 +1,8 @@
{
"compilerOptions": {
+ "types": [
+ "bun-types"
+ ],
// Enable latest features
"lib": [
"ESNext",
diff --git a/packages/web/package.json b/packages/web/package.json
index 5a87f71d..05d755d7 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@darkruby/assets-web",
"private": true,
- "version": "1.8.0",
+ "version": "1.8.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -29,7 +29,7 @@
"react-dropzone": "^14.3.8",
"react-router": "^7.3.0",
"react-select": "^5.10.1",
- "recharts": "^3.8.0"
+ "recharts": "3.8.0"
},
"devDependencies": {
"sass-embedded": "^1.93.2",
diff --git a/packages/web/src/components/Charts/AutoChart.tsx b/packages/web/src/components/Charts/AutoChart.tsx
new file mode 100644
index 00000000..12903d9a
--- /dev/null
+++ b/packages/web/src/components/Charts/AutoChart.tsx
@@ -0,0 +1,46 @@
+import type { Identity } from "@darkruby/assets-core";
+import { usePrefs } from "../../hooks/prefs";
+import type { PropsOf } from "../../util/props";
+import { AssetChart } from "./AssetChart";
+import { MultiAssetChart } from "./MultiAssetChart";
+
+type AssetChartProps = PropsOf;
+type MultiAssetChartProps = PropsOf;
+
+type AutoChartProps = Identity<
+ Omit | Omit
+> & {
+ chart: AssetChartProps["data"];
+ multiChart: MultiAssetChartProps["data"];
+};
+
+export const AutoChart: React.FC = ({
+ chart,
+ multiChart,
+ onChange,
+ range,
+ ranges,
+ hidden
+}: AutoChartProps) => {
+ const { additional } = usePrefs();
+ if (additional.altChart) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
diff --git a/packages/web/src/components/Charts/MultiAssetChart.tsx b/packages/web/src/components/Charts/MultiAssetChart.tsx
index ec7332d8..2de8416f 100644
--- a/packages/web/src/components/Charts/MultiAssetChart.tsx
+++ b/packages/web/src/components/Charts/MultiAssetChart.tsx
@@ -4,6 +4,7 @@ import { pipe } from "fp-ts/lib/function";
import * as NeA from "fp-ts/lib/NonEmptyArray";
import * as R from "fp-ts/lib/Record";
import * as React from "react";
+import { useMemo } from "react";
import {
Area,
CartesianGrid,
@@ -23,29 +24,44 @@ import { withProps } from "../../decorators/props";
import { useFormatters } from "../../hooks/prefs";
import { RangeChart, type ChartProps } from "./RangeChart";
+const fillColors = () => {
+ const reds = ["#DB7093", "#CD5C5C", "#F08080", "#FA8072", "#ff2323"];
+ const greens = ["#228B22", "#3CB371", "#32CD32", "#00FA9A", "#006400"];
+ let [redIdx, greenIdx] = [0, 0];
+ const red = () => reds[redIdx++ % reds.length];
+ const green = () => greens[greenIdx++ % greens.length];
+
+ return (first: number, last: number) => {
+ return last >= first ? green() : red();
+ };
+};
+
export type MultiAssetChartProps = ChartProps;
const RawMultiAssetChart: React.FC = ({
data,
timeFormatter
}: MultiAssetChartProps) => {
- const { money } = useFormatters();
- const tickFormatter = (n: number) => `${n.toFixed(1)}%`;
-
+ const { percent } = useFormatters();
const names = R.keys(data);
- const timestamp = pipe(
- data,
- R.toEntries,
- A.map(([, chart]) =>
+
+ const timestamp = useMemo(
+ () =>
pipe(
- chart,
- NeA.map((n) => n.timestamp)
- )
- ),
- A.reduce([0], (_, c) => c)
+ data,
+ R.toEntries,
+ A.map(([, chart]) =>
+ pipe(
+ chart,
+ NeA.map((n) => n.timestamp)
+ )
+ ),
+ A.reduce([0], (_, c) => c)
+ ),
+ [data]
);
- const data1 = (function () {
+ const combinedData = useMemo(() => {
const res = [];
for (const t in timestamp) {
let item = { timestamp: timestamp[t] } as Record;
@@ -55,37 +71,14 @@ const RawMultiAssetChart: React.FC = ({
res.push(item);
}
return res;
- })();
-
- const entries = R.toEntries(data);
-
- const tooltipValueFormatter = (value?: ValueType, key?: NameType) => {
- if (key === "timestamp") return null;
- const val = Number(value);
- return (
- <>
- {val >= 0 ? "+" : ""}
- {val.toFixed(2)}%
- >
- );
- };
+ }, [timestamp]);
- const fill = (i: number) => {
- const colors = [
- "#20B2AA",
- "#F0E68C",
- "#7B68EE",
- "#E6E6FA",
- "#4B0082",
- "#9932CC"
- ];
- return colors[i % colors.length];
- };
+ const color = fillColors();
return (
<>
-
+
= ({
@@ -103,20 +96,26 @@ const RawMultiAssetChart: React.FC = ({
labelFormatter={(t) => `Time: ${timeFormatter(t)}`}
formatter={tooltipValueFormatter}
/>
- {entries.map(([name, chart], idx) => (
-
- ))}
+ {names.map((name) => {
+ const clr = color(
+ combinedData[0][name],
+ combinedData[combinedData.length - 1][name]
+ );
+ return (
+
+ );
+ })}
@@ -130,3 +129,14 @@ export const MultiAssetChart = pipe(
withProps({ Chart: RawMultiAssetChart }),
withVisibility()
);
+
+const tooltipValueFormatter = (value?: ValueType, key?: NameType) => {
+ if (key === "timestamp") return null;
+ const val = Number(value);
+ return (
+ <>
+ {val >= 0 ? "+" : ""}
+ {val.toFixed(2)}%
+ >
+ );
+};
diff --git a/packages/web/src/components/Form/Form.scss b/packages/web/src/components/Form/Form.scss
index 75c2ef2e..6ad322b2 100644
--- a/packages/web/src/components/Form/Form.scss
+++ b/packages/web/src/components/Form/Form.scss
@@ -1,3 +1,6 @@
+.form-check-input {
+ cursor: pointer;
+}
.date-picker-btn {
border: 1px solid #495057;
}
diff --git a/packages/web/src/components/Portfolio/Portfolio.tsx b/packages/web/src/components/Portfolio/Portfolio.tsx
index 69d5fb99..00ed96aa 100644
--- a/packages/web/src/components/Portfolio/Portfolio.tsx
+++ b/packages/web/src/components/Portfolio/Portfolio.tsx
@@ -17,7 +17,7 @@ import { withFetching } from "../../decorators/fetching";
import { withNoData } from "../../decorators/nodata";
import { assetModal } from "../Asset/AssetFields";
import { AssetLink } from "../Asset/AssetLink";
-import { AssetChart } from "../Charts";
+import { AutoChart } from "../Charts/AutoChart";
import { Info } from "../Form/Alert";
import { AddBtn } from "../Form/Button";
import { generateTabId, TabContent, Tabs } from "../Form/Tabs";
@@ -78,28 +78,17 @@ const RawPortfolioDetails: React.FC = ({
This portfolio doesn have any assets yet
-
+
-
- {/*
-
- */}
diff --git a/packages/web/src/components/Profile/AdditionalPrefs.tsx b/packages/web/src/components/Profile/AdditionalPrefs.tsx
new file mode 100644
index 00000000..e7884b2f
--- /dev/null
+++ b/packages/web/src/components/Profile/AdditionalPrefs.tsx
@@ -0,0 +1,32 @@
+import type { AdditionalPrefs as AdditionalPrefsData } from "@darkruby/assets-core";
+import * as React from "react";
+import { Form } from "react-bootstrap";
+import { usePartialChange } from "../../hooks/formData";
+import type { FieldsProps } from "../Form/Form";
+import { CheckBox } from "../Form/FormControl";
+
+type AdditionalPrefsFieldsProps = FieldsProps;
+
+export const AdditionalFieldsPrefs: React.FC = ({
+ data,
+ onChange,
+ disabled
+}: AdditionalPrefsFieldsProps) => {
+ const setField = usePartialChange(data, onChange);
+
+ return (
+ <>
+
+
+
+ Layered chart (Experimental)
+
+
+ >
+ );
+};
diff --git a/packages/web/src/components/Profile/NewUser.tsx b/packages/web/src/components/Profile/NewUser.tsx
index 50d45feb..fa404474 100644
--- a/packages/web/src/components/Profile/NewUser.tsx
+++ b/packages/web/src/components/Profile/NewUser.tsx
@@ -15,7 +15,7 @@ type NewUserFieldsProps = FieldsProps;
export const NewUserFields: React.FC = ({
data,
onChange,
- disabled,
+ disabled
}: NewUserFieldsProps) => {
const setField = usePartialChange(data, onChange);
return (
@@ -37,20 +37,21 @@ export const NewUserFields: React.FC = ({
/>
- Admin
- Locked
+ Admin
+
+ Locked
);
diff --git a/packages/web/src/components/Profile/Prefs.tsx b/packages/web/src/components/Profile/Prefs.tsx
index 58f53369..eec443bf 100644
--- a/packages/web/src/components/Profile/Prefs.tsx
+++ b/packages/web/src/components/Profile/Prefs.tsx
@@ -12,6 +12,7 @@ import { withProps } from "../../decorators/props";
import { usePartialState } from "../../hooks/formData";
import { PrimaryButton } from "../Form/FormControl";
import { Select } from "../Form/Select";
+import { AdditionalFieldsPrefs } from "./AdditionalPrefs";
export const CcySelect = pipe(
Select,
@@ -21,20 +22,41 @@ export const CcySelect = pipe(
type PrefsProps = {
prefs: PrefsData;
onUpdate: (p: PrefsData) => void;
+ disabled?: boolean;
};
-const RawPrefs: React.FC = ({ prefs, onUpdate }) => {
+const RawPrefs: React.FC = ({ prefs, onUpdate, disabled }) => {
const [prf, setField] = usePartialState(prefs);
const handleSubmit = () => onUpdate(prf);
const handleBaseCcy = setField("base_ccy");
+ const handleAdditional = setField("additional");
return (
<>
- Base currency
-
+
+ Base currency
+
+
- Save
+
+
+ Additional
+
+
+
+
+
+ Save
+
>
);
diff --git a/packages/web/src/components/Summary/Summary.tsx b/packages/web/src/components/Summary/Summary.tsx
index d5877cf7..388107f8 100644
--- a/packages/web/src/components/Summary/Summary.tsx
+++ b/packages/web/src/components/Summary/Summary.tsx
@@ -11,7 +11,7 @@ import { Stack } from "react-bootstrap";
import { withError } from "../../decorators/errors";
import { withFetching } from "../../decorators/fetching";
import { withNoData, type WithNoData } from "../../decorators/nodata";
-import { AssetChart } from "../Charts";
+import { AutoChart } from "../Charts/AutoChart";
import { Info } from "../Form/Alert";
import { AddBtn } from "../Form/Button";
import { generateTabId, TabContent, Tabs } from "../Form/Tabs";
@@ -57,14 +57,15 @@ const RawSummary: React.FC = ({
No portfolios yet
-
+
-
diff --git a/packages/web/src/decoders/tx.ts b/packages/web/src/decoders/tx.ts
index 373a9466..35e6a4be 100644
--- a/packages/web/src/decoders/tx.ts
+++ b/packages/web/src/decoders/tx.ts
@@ -1,8 +1,7 @@
import { PostTxDecoder, type PostTx } from "@darkruby/assets-core";
-import { pipe } from "fp-ts/lib/function";
import * as t from "io-ts";
import { fromCsvBrowser } from "./csv";
-export const CsvPostTxDecoder = pipe(
- fromCsvBrowser(PostTxDecoder as t.Type),
+export const CsvPostTxDecoder = fromCsvBrowser(
+ PostTxDecoder as t.Type
);