diff --git a/.github/workflows/build-docker-container.yaml b/.github/workflows/build-docker-container.yaml index 7982d275..58f00e32 100644 --- a/.github/workflows/build-docker-container.yaml +++ b/.github/workflows/build-docker-container.yaml @@ -50,6 +50,7 @@ jobs: with: push: true tags: | + ghcr.io/venil7/assets:nightly ghcr.io/venil7/assets:${{ env.BUILD_TAG }} # builds release image, pushes under version and latest tags diff --git a/API.md b/API.md deleted file mode 100644 index 36d52ef1..00000000 --- a/API.md +++ /dev/null @@ -1,37 +0,0 @@ -**API** - -| Method | Endpoint | Description | Request Body | Response Body | -| ------ | --------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ | -| POST | `/login` | Authenticate and get a bearer token | [Credentials](packages/core/src/domain/user.ts) | [Token](packages/core/src/domain/token.ts) | -| GET | `/auth/refresh_token` | Gets a new token, with extended expiry | - | [Token](packages/core/src/domain/token.ts) | -| GET | `/profile` | User profile | - | [GetUser](packages/core/src/domain/user.ts) | -| PUT | `/profile` | Update current user profile | [PostUser](packages/core/src/domain/user.ts) | [GetUser](packages/core/src/domain/user.ts) | -| POST | `/profile` | Update current user password | [PasswordChange](packages/core/src/domain/user.ts) | [GetUser](packages/core/src/domain/user.ts) | -| DELETE | `/profile` | Delete current user profile | - | [Optional](packages/core/src/domain/id.ts) | -| GET | `/prefs` | Get current user prefs | - | [GetPrefs](packages/core/src/domain/prefs.ts) | -| PUT | `/prefs` | Update current user prefs | [PostPrefs](packages/core/src/domain/prefs.ts) | [GetPrefs](packages/core/src/domain/prefs.ts) | -| GET | `/user` | Get users profiles (admin only) | - | [GetUser[]](packages/core/src/domain/user.ts) | -| GET | `/user/{user_id}` | Get user profile (admin only) | - | [GetUser](packages/core/src/domain/user.ts) | -| DELETE | `/user/{user_id}` | Delete user profile (admin only) | - | [GetUser](packages/core/src/domain/user.ts) | -| POST | `/user` | Create new user (admin only) | [PostUser](packages/core/src/domain/user.ts) | [GetUser](packages/core/src/domain/user.ts) | -| PUT | `/user` | Update user profile (admin only) | [PostUser](packages/core/src/domain/user.ts) | [GetUser](packages/core/src/domain/user.ts) | -| GET | `/summary` | Summary across all portfolios | - | [Summary](packages/core/src/domain/summary.ts) | -| POST | `/portfolios` | Create a new portfolio | [PostPortfolio](packages/core/src/domain/portfolio.ts) | [GetPortfolio](packages/core/src/domain/portfolio.ts) | -| GET | `/portfolios` | List all portfolios | — | [GetPortfolio[]](packages/core/src/domain/portfolio.ts) | -| GET | `/portfolios/{portfolio_id}` | Get a portfolio by ID | — | [GetPortfolio](packages/core/src/domain/portfolio.ts) | -| DELETE | `/portfolios/{portfolio_id}` | Delete a portfolio | — | [Optional](packages/core/src/domain/id.ts) | -| PUT | `/portfolios/{portfolio_id}` | Update a portfolio | [PostPortfolio](packages/core/src/domain/portfolio.ts) | [GetPortfolio](packages/core/src/domain/portfolio.ts) | -| POST | `/portfolios/{portfolio_id}/assets` | Add an asset to a portfolio | [PostAsset](packages/core/src/domain/portfolio.ts) | [GetAsset](packages/core/src/domain/portfolio.ts) | -| GET | `/portfolios/{portfolio_id}/assets` | List assets in a portfolio | — | [GetAsset[]](packages/core/src/domain/portfolio.ts) | -| GET | `/portfolios/{portfolio_id}/assets/{asset_id}` | Get an asset by ID | — | [GetAsset](packages/core/src/domain/portfolio.ts) | -| DELETE | `/portfolios/{portfolio_id}/assets/{asset_id}` | Delete an asset by ID | — | [Optional](packages/core/src/domain/id.ts) | -| PUT | `/portfolios/{portfolio_id}/assets/{asset_id}` | Update an asset by ID | [PostAsset](packages/core/src/domain/portfolio.ts) | [GetAsset](packages/core/src/domain/portfolio.ts) | -| POST | `/portfolios/{portfolio_id}/assets/{asset_id}/tx` | Create a transaction for a given asset | [PostTx](packages/core/src/domain/tx.ts) | [GetTx](packages/core/src/domain/tx.ts) | -| GET | `/portfolios/{portfolio_id}/assets/{asset_id}/tx` | Get all transactions for asset | — | [GetTx[]](packages/core/src/domain/tx.ts) | -| GET | `/portfolios/{portfolio_id}/assets/{asset_id}/tx/{tx_id}` | Get a transaction by ID | — | [GetTx](packages/core/src/domain/tx.ts) | -| DELETE | `/portfolios/{portfolio_id}/assets/{asset_id}/tx/{tx_id}` | Delete a transaction by ID | — | [Optional](packages/core/src/domain/id.ts) | -| PUT | `/portfolios/{portfolio_id}/assets/{asset_id}/tx/{tx_id}` | Update a transaction by ID | [PostTx](packages/core/src/domain/tx.ts) | [GetTx](packages/core/src/domain/tx.ts) | -| DELETE | `/portfolios/{portfolio_id}/assets/{asset_id}/txs` | Delete all transactions for an asset | — | [Optional](packages/core/src/domain/id.ts) | -| POST | `/portfolios/{portfolio_id}/assets/{asset_id}/txs` | Bulk insert transactions for an asset | [PostTxsUpload](packages/core/src/domain/tx.ts) | [GetTx[]](packages/core/src/domain/tx.ts) | -| POST | `/lookup/ticker?query=TICKER` | Query for ticker details | — | [YahooTickerSearchResult](packages/core/src/domain/yahoo.ts) | -| POST | `/lookup/fx/{base}/{ccy}/{date?}` | Get FX rates for base/ccy | — | [Fx](packages/core/src/domain/yahoo.ts) | diff --git a/Dockerfile b/Dockerfile index d2eee51f..3955264c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,11 @@ RUN go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@ FROM oven/bun:1.3 AS builder WORKDIR /app COPY . . +RUN apt-get update \ + && apt-get install -y \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN bun install RUN bun run check RUN bun run build diff --git a/README.md b/README.md index a821f70c..e260f5f7 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ bun run check # Lint and type-check all packages **Build Docker image locally:** ```bash +# export DOCKER_API_VERSION=1.43 docker buildx build -t assets:local . ``` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..323a39f4 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,943 @@ +# API Documentation + +## Authentication Endpoints + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | -------------------------- | ---------------------------------------- | ------------ | ------------- | +| POST | `/login` | Authenticate and get a bearer token | See below | See below | +| GET | `/auth/refresh_token` | Gets a new token with extended expiry | - | See below | + +### Login +Authenticate with username and password to receive a JWT bearer token. + +**Request:** +```json +{ + "username": "string (required, non-empty)", + "password": "string (required, non-empty)" +} +``` + +**Response:** +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshBefore": 518400 +} +``` + +### Refresh Token +Get a new bearer token with extended expiry without credentials (requires valid token in Authorization header). + +**Response:** +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshBefore": 518400 +} +``` + +--- + +## User/Profile Endpoints + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | ------------- | ----------------------------- | ------------ | ------------- | +| GET | `/profile` | Get current user profile | - | See below | +| PUT | `/profile` | Update current user profile | See below | See below | +| POST | `/profile` | Update current user password | See below | See below | +| DELETE | `/profile` | Delete current user profile | - | See below | + +### Get Profile +Retrieve the current authenticated user's profile information. + +**Response:** +```json +{ + "id": 1, + "username": "admin", + "admin": true, + "login_attempts": 0, + "locked": false, + "created": "2026-02-14T21:20:21.000Z", + "modified": "2026-02-14T21:20:21.000Z" +} +``` + +### Update Profile +Update the current user's profile information (username, admin status, etc.). + +**Request:** +```json +{ + "username": "admin_updated", + "admin": true, + "login_attempts": 0, + "locked": false +} +``` + +**Response:** (same as Get Profile) + +### Change Password +Update the current user's password. + +**Request:** +```json +{ + "oldPassword": "string (required, non-empty)", + "newPassword": "string (required, non-empty)", + "repeat": "string (required, must match newPassword)" +} +``` + +**Response:** (same as Get Profile) + +### Delete Profile +Delete the current user's profile and all associated data. + +**Response:** +```json +{ + "id": 1 +} +``` + +--- + +## User Preferences Endpoints + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | -------- | ------------------------ | ------------ | ------------- | +| GET | `/prefs` | Get current user prefs | - | See below | +| PUT | `/prefs` | Update current user prefs| See below | See below | + +### Get Preferences +Retrieve the current user's preferences (e.g., base currency, display options). + +**Response:** +```json +{ + "id": 1, + "base_ccy": "GBP", + "additional": { + "altChart": false + } +} +``` + +### Update Preferences +Update the current user's preferences. + +**Request:** +```json +{ + "base_ccy": "EUR", + "additional": { + "altChart": true + } +} +``` + +**Supported currencies:** USD, GBP, EUR, CAD, AUD, CHF, SEK, NOK, DKK, NZD, JPY, INR + +**Response:** (same as Get Preferences) + +--- + +## User Management Endpoints (Admin Only) + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | -------------------- | --------------------------------- | ------------ | ------------- | +| GET | `/users` | Get all users (admin only) | - | See below | +| GET | `/users/{user_id}` | Get user by ID (admin only) | - | See below | +| DELETE | `/users/{user_id}` | Delete user by ID (admin only) | - | See below | +| POST | `/users` | Create new user (admin only) | See below | See below | +| PUT | `/users/{user_id}` | Update user (admin only) | See below | See below | + +### List All Users +Retrieve all users in the system. **Requires admin privileges.** + +**Response:** +```json +[ + { + "id": 1, + "username": "admin", + "admin": true, + "login_attempts": 0, + "locked": false, + "created": "2026-02-14T21:20:21.000Z", + "modified": "2026-02-14T21:20:21.000Z" + }, + { + "id": 2, + "username": "jane_smith", + "admin": false, + "login_attempts": 0, + "locked": false, + "created": "2026-03-01T10:30:00.000Z", + "modified": "2026-04-21T14:22:00.000Z" + } +] +``` + +### Get User +Retrieve a specific user by ID. **Requires admin privileges.** + +**Response:** (same structure as single user in List All Users) + +### Create User +Create a new user account. **Requires admin privileges.** + +**Request:** +```json +{ + "username": "jane_smith", + "admin": false, + "login_attempts": 0, + "locked": false +} +``` + +**Response:** (same as Get User) + +### Update User +Update an existing user's information. **Requires admin privileges.** + +**Request:** (same as Create User) + +**Response:** (same as Get User) + +### Delete User +Delete a user account. **Requires admin privileges.** + +**Response:** +```json +{ + "id": 2 +} +``` + +--- + +## Summary Endpoint + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | ------------ | -------------------------------- | ------------ | ------------- | +| GET | `/summary` | Summary across all portfolios | - | See below | + +### Get Summary +Retrieve aggregated summary data across all portfolios, including total invested, realized P&L, and charts. + +**Query Parameters (optional):** +- `range`: Time range for chart data. Valid values: `1d`, `5d`, `1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `10y`, `ytd`, `max`. Default: `1d` + +**Example:** `GET /summary?range=1y` + +**Response:** +```json +{ + "numPortfolios": 1, + "meta": { + "range": "1d", + "validRanges": ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"], + "fiftyTwoWeekLow": 163533.54, + "fiftyTwoWeekHigh": 221598.82, + "volatilityRange": 58065.28, + "volatilityPct": 0.3015, + "currencies": ["USD", "GBp", "GBP"], + "exchanges": ["NMS", "NYQ", "LSE", "CCC"], + "types": ["EQUITY", "ETF", "CRYPTOCURRENCY"] + }, + "chart": [ + { + "timestamp": 1776729300, + "price": 32091.83, + "volume": 0, + "tx": null + }, + { + "timestamp": 1776729600, + "price": 32094.20, + "volume": 5242880, + "tx": null + } + ], + "multiChart": { + "USD": [...], + "GBP": [...] + }, + "changes": { + "startPrice": 0, + "endPrice": 32091.83, + "returnValue": 1000.50, + "returnPct": 0.0312, + "startTs": 1776729300, + "endTs": 1776794139 + }, + "totals": { + "returnValue": 1000.50, + "returnPct": 0.0312 + }, + "invested": 32000.00, + "realizedPnl": 500.00, + "breakEven": 31500.00, + "fxImpact": -50.00 +} +``` + +--- + +## Portfolio Endpoints + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | --------------------------------- | ---------------------------- | ------------ | ------------- | +| POST | `/portfolios` | Create a new portfolio | See below | See below | +| GET | `/portfolios` | List all portfolios | - | See below | +| GET | `/portfolios/{portfolio_id}` | Get a portfolio by ID | - | See below | +| PUT | `/portfolios/{portfolio_id}` | Update a portfolio | See below | See below | +| DELETE | `/portfolios/{portfolio_id}` | Delete a portfolio | - | See below | + +### Create Portfolio +Create a new portfolio for organizing assets. + +**Request:** +```json +{ + "name": "Test Portfolio", + "description": "Test Description" +} +``` + +**Response:** +```json +{ + "id": 1733, + "user_id": 1, + "name": "Test Portfolio", + "description": "Test Description", + "num_assets": 0, + "created": "2026-04-21T16:55:49.000Z", + "modified": "2026-04-21T16:55:49.000Z", + "meta": { + "range": "1d", + "validRanges": ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"], + "volatilityRange": 0, + "volatilityPct": 0, + "currencies": [], + "exchanges": [], + "types": [], + "fiftyTwoWeekLow": 0, + "fiftyTwoWeekHigh": 0 + }, + "weight": null, + "domestic": false, + "chart": [], + "multiChart": {}, + "changes": { + "startPrice": 0, + "endPrice": 0, + "returnValue": 0, + "returnPct": 0, + "startTs": 0, + "endTs": 0 + }, + "totals": { + "returnValue": 0, + "returnPct": 0 + }, + "invested": 0, + "realizedPnl": 0, + "breakEven": 0, + "fxImpact": 0 +} +``` + +### List All Portfolios +Retrieve all portfolios for the current user. + +**Query Parameters (optional):** +- `range`: Time range for chart data. Valid values: `1d`, `5d`, `1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `10y`, `ytd`, `max`. Default: `1d` + +**Example:** `GET /portfolios?range=1y` + +**Response:** +```json +[ + { + "id": 1733, + "user_id": 1, + "name": "Test Portfolio", + "description": "Test Description", + "num_assets": 1, + "created": "2026-04-21T16:55:49.000Z", + "modified": "2026-04-21T16:55:49.000Z", + "meta": { + "range": "1d", + "validRanges": ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"], + "volatilityRange": 95.37, + "volatilityPct": 0.3958, + "currencies": ["USD"], + "exchanges": ["NMS"], + "types": ["EQUITY"], + "fiftyTwoWeekLow": 193.25, + "fiftyTwoWeekHigh": 288.62 + }, + "weight": 0.0621, + "domestic": false, + "chart": [...], + "multiChart": {}, + "changes": { + "startPrice": 0, + "endPrice": 1972.64, + "returnValue": 859.90, + "returnPct": 0.7723, + "startTs": 1776777900, + "endTs": 1776794239 + }, + "totals": { + "returnValue": 1355.82, + "returnPct": 0.6614 + }, + "invested": 2050, + "realizedPnl": 0, + "breakEven": 2050, + "fxImpact": 0 + } +] +``` + +### Get Portfolio +Retrieve a specific portfolio by ID. + +**Query Parameters (optional):** +- `range`: Time range for chart data. Valid values: `1d`, `5d`, `1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `10y`, `ytd`, `max`. Default: `1d` + +**Example:** `GET /portfolios/1733?range=1y` + +**Response:** (same structure as single portfolio in List All Portfolios) + +### Update Portfolio +Update portfolio name or description. + +**Request:** +```json +{ + "name": "Tech Stocks Updated", + "description": "Updated growth-focused technology investments" +} +``` + +**Response:** (same as Get Portfolio) + +### Delete Portfolio +Delete a portfolio. This will also delete all assets and transactions within it. + +**Response:** +```json +{ + "id": 1 +} +``` + +--- + +## Asset Endpoints + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | -------------------------------------------------- | --------------------------------------- | ------------ | ------------- | +| POST | `/portfolios/{portfolio_id}/assets` | Add an asset to a portfolio | See below | See below | +| GET | `/portfolios/{portfolio_id}/assets` | List assets in a portfolio | - | See below | +| GET | `/portfolios/{portfolio_id}/assets/{asset_id}` | Get an asset by ID | - | See below | +| PUT | `/portfolios/{portfolio_id}/assets/{asset_id}` | Update an asset | See below | See below | +| DELETE | `/portfolios/{portfolio_id}/assets/{asset_id}` | Delete an asset | - | See below | +| PATCH | `/portfolios/{portfolio_id}/assets/{asset_id}/move/{new_portfolio_id}` | Move asset to another portfolio | - | See below | + +### Create Asset +Add a new asset (stock, ETF, etc.) to a portfolio. + +**Request:** +```json +{ + "ticker": "AAPL", + "name": "Apple Inc." +} +``` + +**Response:** +```json +{ + "id": 1, + "portfolio_id": 1733, + "ticker": "AAPL", + "name": "Apple Inc.", + "user_id": 1, + "holdings": 0, + "invested": 0, + "avg_price": 0, + "break_even": 0, + "realized_pnl": 0, + "num_txs": 0, + "last_activity": null, + "last_activity_ts": null, + "base_ccy": "USD", + "created": "2026-04-21T16:55:49.000Z", + "modified": "2026-04-21T16:55:49.000Z", + "meta": { + "range": "1d", + "validRanges": ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"], + "volatilityRange": 0, + "volatilityPct": 0, + "fiftyTwoWeekLow": 0, + "fiftyTwoWeekHigh": 0 + }, + "weight": null, + "volatilityRange": 0, + "volatilityPct": 0, + "ccy": { + "chart": [], + "changes": { + "startPrice": 0, + "endPrice": 0, + "returnValue": 0, + "returnPct": 0, + "startTs": 0, + "endTs": 0 + }, + "totals": { + "returnValue": 0, + "returnPct": 0 + } + }, + "base": { + "domestic": false, + "invested": 0, + "fxImpact": 0, + "fxRate": 1, + "chart": [], + "changes": { + "startPrice": 0, + "endPrice": 0, + "returnValue": 0, + "returnPct": 0, + "startTs": 0, + "endTs": 0 + }, + "totals": { + "returnValue": 0, + "returnPct": 0 + }, + "avgPrice": null, + "breakEven": null, + "realizedPnl": 0 + } +} +``` + +### List Assets +Retrieve all assets in a portfolio. + +**Query Parameters (optional):** +- `range`: Time range for chart data. Valid values: `1d`, `5d`, `1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `10y`, `ytd`, `max`. Default: `1d` + +**Example:** `GET /portfolios/1733/assets?range=1y` + +**Response:** +```json +[ + { + "id": 1, + "portfolio_id": 1733, + "ticker": "AAPL", + "name": "Apple Inc.", + "user_id": 1, + "holdings": 10, + "invested": 2050, + "avg_price": 205, + "break_even": 205, + "realized_pnl": 0, + "num_txs": 1, + "last_activity": "2026-04-21T16:55:49.000Z", + "last_activity_ts": 1776794549, + "base_ccy": "USD", + "created": "2026-04-21T16:55:49.000Z", + "modified": "2026-04-21T16:55:49.000Z", + "meta": {...}, + "weight": 0.0621, + "volatilityRange": 95.37, + "volatilityPct": 0.3958, + "ccy": {...}, + "base": {...} + } +] +``` + +### Get Asset +Retrieve details for a specific asset. + +**Query Parameters (optional):** +- `range`: Time range for chart data. Valid values: `1d`, `5d`, `1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `10y`, `ytd`, `max`. Default: `1d` + +**Example:** `GET /portfolios/1733/assets/1?range=1y` + +**Response:** (same as single asset in List Assets) + +### Update Asset +Update asset details (ticker, name). + +**Request:** +```json +{ + "ticker": "AAPL", + "name": "Apple Inc. - Updated" +} +``` + +**Response:** (same as Get Asset) + +### Delete Asset +Delete an asset and all its associated transactions. + +**Response:** +```json +{ + "id": 10 +} +``` + +### Move Asset +Move an asset from one portfolio to another. + +**Response:** +```json +{ + "id": 10 +} +``` + +--- + +## Transaction Endpoints + +| Method | Endpoint | Description | Request Body | Response Body | +| ------ | ------------------------------------------------------ | -------------------------------------- | ------------ | ------------- | +| POST | `/portfolios/{portfolio_id}/assets/{asset_id}/tx` | Create a transaction for an asset | See below | See below | +| GET | `/portfolios/{portfolio_id}/assets/{asset_id}/tx` | List all transactions for an asset | - | See below | +| GET | `/portfolios/{portfolio_id}/assets/{asset_id}/tx/{tx_id}` | Get a transaction by ID | - | See below | +| PUT | `/portfolios/{portfolio_id}/assets/{asset_id}/tx/{tx_id}` | Update a transaction | See below | See below | +| DELETE | `/portfolios/{portfolio_id}/assets/{asset_id}/tx/{tx_id}` | Delete a transaction | - | See below | +| POST | `/portfolios/{portfolio_id}/assets/{asset_id}/txs` | Bulk insert transactions (CSV upload) | See below | See below | +| DELETE | `/portfolios/{portfolio_id}/assets/{asset_id}/txs` | Delete all transactions for an asset | - | See below | + +### Create Transaction +Record a buy or sell transaction for an asset. + +**Request:** +```json +{ + "type": "buy", + "quantity": 10, + "price": 205, + "date": "2026-04-21T16:55:49.000Z", + "comments": "Initial purchase" +} +``` + +**Response:** +```json +{ + "id": 1, + "asset_id": 1, + "type": "buy", + "quantity": 10, + "price": 205, + "date": "2026-04-21T16:55:49.000Z", + "comments": "Initial purchase", + "quantity_ext": 10, + "stretch": 1, + "final_stretch": false, + "value": null, + "pnl": null, + "pnl_pct": null, + "realized_pnl": 0, + "cost": 2050, + "cost_basis": 2050, + "contribution": 100, + "running_holding": 10, + "running_cost": 2050, + "running_average_price": 205, + "running_break_even": 205, + "running_contribution": 100, + "asset_name": "Apple Inc.", + "asset_ticker": "AAPL", + "portfolio_name": "Test Portfolio", + "portfolio_description": "Test Description", + "user_id": 1, + "user_base_ccy": "GBP", + "timestamp": 1776794549, + "created": "2026-04-21T16:55:49.000Z", + "modified": "2026-04-21T16:55:49.000Z" +} +``` + +### List Transactions +Retrieve all transactions for an asset. + +**Response:** +```json +[ + { + "id": 45, + "asset_id": 10, + "type": "buy", + "quantity": 10, + "price": 150.25, + "date": "2023-10-15T14:30:00Z", + "comments": "Purchased at market open", + "quantity_ext": 10, + "asset_name": "Apple Inc.", + "asset_ticker": "AAPL" + }, + { + "id": 46, + "asset_id": 10, + "type": "buy", + "quantity": 5, + "price": 152.00, + "date": "2023-10-18T09:00:00Z", + "comments": "" + } +] +``` + +### Get Transaction +Retrieve details for a specific transaction. + +**Response:** (same as single transaction in List Transactions) + +### Update Transaction +Update an existing transaction. + +**Request:** +```json +{ + "type": "buy", + "quantity": 12, + "price": 150.25, + "date": "2023-10-15T14:30:00Z", + "comments": "Updated quantity" +} +``` + +**Response:** (same as Get Transaction) + +### Delete Transaction +Delete a specific transaction. + +**Response:** +```json +{ + "id": 45 +} +``` + +### Bulk Upload Transactions +Upload multiple transactions at once (e.g., from CSV file). + +**Request:** +```json +{ + "replace": true, + "txs": [ + { + "type": "buy", + "quantity": 10, + "price": 150.25, + "date": "2023-10-15T14:30:00Z", + "comments": "" + }, + { + "type": "sell", + "quantity": 5, + "price": 160.00, + "date": "2023-10-18T10:00:00Z", + "comments": "Partial profit taking" + } + ] +} +``` + +- **`replace`**: If `true`, deletes all existing transactions and inserts the provided ones. If `false`, appends to existing transactions. + +**Response:** +```json +[ + { + "id": 47 + }, + { + "id": 48 + } +] +``` + +### Delete All Transactions +Delete all transactions for a specific asset. + +**Response:** +```json +{ + "id": 10 +} +``` + +--- + +## Lookup/Search Endpoints + +| Method | Endpoint | Description | Request/Query | Response Body | +| ------ | ---------------------------------- | ---------------------------------------- | ------------- | ------------- | +| GET | `/lookup/ticker` | Search for ticker details | `term` (query param) | See below | +| GET | `/lookup/quote/{base}/{date?}` | Get quote for a ticker (with optional date) | Path params | See below | +| GET | `/lookup/fx/{base}/{ccy}/{date?}` | Get FX rates for base/currency pair (with optional date) | Path params | See below | + +### Search Tickers +Search for ticker symbols and company names. Returns matching results from Yahoo Finance. + +**Query Parameters:** +- `term` (required): Search term (ticker symbol or company name, e.g., "AAPL" or "Apple") + +**Example:** `GET /lookup/ticker?term=apple` + +**Response:** +```json +{ + "quotes": [ + { + "symbol": "AAPL", + "exchange": "NMS", + "shortname": "Apple Inc.", + "longname": "Apple Inc.", + "quoteType": "EQUITY" + }, + { + "symbol": "AAPL.SW", + "exchange": "EBS", + "shortname": "APPLE INC", + "longname": null, + "quoteType": "EQUITY" + }, + { + "symbol": "AAPW", + "exchange": "BTS", + "shortname": "Roundhill AAPL WeeklyPay ETF", + "longname": null, + "quoteType": "ETF" + } + ] +} +``` + +### Get Quote +Retrieve the current or historical quote for a ticker symbol. + +**Path Parameters:** +- `base` (required): Ticker symbol (e.g., "AAPL") +- `date` (optional): ISO 8601 date string (e.g., "2023-10-15"). If omitted, returns latest quote. + +**Example:** `GET /lookup/quote/AAPL/2023-10-15` + +**Response:** +```json +{ + "timestamp": 1776794146, + "price": 266.2950134277344, + "volume": 27800253, + "tx": null +} +``` + +### Get FX Rate +Retrieve foreign exchange rate for a currency pair. + +**Path Parameters:** +- `base` (required): Base currency (e.g., "USD") +- `ccy` (required): Target currency (e.g., "EUR") +- `date` (optional): ISO 8601 date string. If omitted, returns latest rate. + +**Example:** `GET /lookup/fx/USD/EUR/2023-10-15` + +**Response:** +```json +{ + "ccy": "EUR", + "base": "USD", + "rate": 0.8514999747276306, + "timestamp": 1776794139 +} +``` + +--- + +## Error Responses + +All endpoints return error responses in the following format: + +**HTTP 400 Bad Request:** +```json +{ + "status": 400, + "message": "Validation failed", + "errors": [ + { + "field": "username", + "message": "Username is required" + } + ] +} +``` + +**HTTP 401 Unauthorized:** +```json +{ + "status": 401, + "message": "Authentication failed" +} +``` + +**HTTP 403 Forbidden:** +```json +{ + "status": 403, + "message": "Admin privileges required" +} +``` + +**HTTP 404 Not Found:** +```json +{ + "status": 404, + "message": "Resource not found" +} +``` + +**HTTP 500 Internal Server Error:** +```json +{ + "status": 500, + "message": "Internal server error" +} +``` + +--- + +## Authentication + +All endpoints except `/login` and `/lookup/*` require authentication via bearer token: + +``` +Authorization: Bearer eyJhbGc... +``` + +Obtain a token by calling `/login` with credentials, then include it in the `Authorization` header for subsequent requests. diff --git a/EXPORT.md b/docs/EXPORT.md similarity index 100% rename from EXPORT.md rename to docs/EXPORT.md diff --git a/TODO.md b/docs/TODO.md similarity index 90% rename from TODO.md rename to docs/TODO.md index 9fd0a80e..2bb32a78 100644 --- a/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,11 @@ # todo +## 1.9 + - [x] tx modal diff view for buy/sell (cap gains, limit on sales) + - [ ] asset price on specific date + - [ ] portfolio reports, cap gains + - [ ] asset also in.. + ## 1.8.1 - [x] multi asset layered chart for portfolios - [x] multi portfollio layered chart for home screen diff --git a/package.json b/package.json index 25a45b22..189fa702 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@darkruby/assets", "private": true, - "version": "1.8.1", + "version": "1.8.2", "type": "module", "dependencies": { "date-fns": "^4.1.0", @@ -11,8 +11,8 @@ }, "devDependencies": { "prettier": "^3.6.2", - "@types/bun": "1.3.11", - "bun-types": "^1.3.11", + "@types/bun": "1.3.13", + "bun-types": "^1.3.13", "typescript": "~6.0.2" }, "scripts": { diff --git a/packages/backend/package.json b/packages/backend/package.json index b94d2016..a2204bb9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@darkruby/assets-backend", - "version": "1.8.1", + "version": "1.8.2", "main": "src/index.ts", "type": "module", "scripts": { diff --git a/packages/backend/src/enrichment/returns.ts b/packages/backend/src/enrichment/returns.ts index 8a0d4f56..96c16e92 100644 --- a/packages/backend/src/enrichment/returns.ts +++ b/packages/backend/src/enrichment/returns.ts @@ -189,6 +189,7 @@ const $breakEven = ($finalStretchTxsWithRate: DataFrame): number => { const $applyRates = ($finalStretchTxsWithRate: DataFrame): EnrichedTx[] => { return $finalStretchTxsWithRate .withColumns( + col("price").divideBy(col("rate")).alias("price"), col("cost").divideBy(col("rate")).alias("cost"), col("value").divideBy(col("latest_rate")).alias("value") ) diff --git a/packages/backend/src/enrichment/tx.ts b/packages/backend/src/enrichment/tx.ts index 8f4741ce..74e4fabc 100644 --- a/packages/backend/src/enrichment/tx.ts +++ b/packages/backend/src/enrichment/tx.ts @@ -1,6 +1,6 @@ import { calcPnl, - isBuy, + txBuy, type Action, type EnrichedTx, type GetTx @@ -19,7 +19,7 @@ const getTxEnricher = ), TE.map(({ meta }) => { // if meta is present, TX is of last stretch, and needs enrichment - const buy = isBuy(tx); + const buy = txBuy(tx); if (meta && buy) { const value = tx.quantity_ext * meta.regularMarketPrice; const [pnl, pnlPct] = calcPnl({ before: tx.cost, after: value }); diff --git a/packages/backend/src/handlers/index.ts b/packages/backend/src/handlers/index.ts index afda372c..d5c3aabf 100644 --- a/packages/backend/src/handlers/index.ts +++ b/packages/backend/src/handlers/index.ts @@ -67,7 +67,8 @@ export const createHandlers = ( }, yahoo: { search: pipe(yahoo.search, expressify), - fxRate: pipe(yahoo.fxRate, expressify) + fxRate: pipe(yahoo.fxRate, expressify), + quote: pipe(yahoo.quote, expressify) }, middleware: { authenticate: pipe(auth.verifyToken, expressify) diff --git a/packages/backend/src/handlers/yahoo.ts b/packages/backend/src/handlers/yahoo.ts index d8111b22..baf3aa70 100644 --- a/packages/backend/src/handlers/yahoo.ts +++ b/packages/backend/src/handlers/yahoo.ts @@ -1,4 +1,10 @@ -import { type Fx, type YahooTickerSearchResult } from "@darkruby/assets-core"; +import { + type ChartDataPoint, + type Fx, + type UnixDate, + type YahooTickerSearchResult +} from "@darkruby/assets-core"; +import { getUnixTime } from "date-fns"; import * as TE from "fp-ts/TaskEither"; import { pipe } from "fp-ts/lib/function"; import { ccyFromUrl, optDateFromUrl, stringFromUrl } from "../decoders/params"; @@ -21,6 +27,26 @@ export const fxRate: HandlerTask = ({ TE.bind("ccy", () => stringFromUrl(req.params.ccy)), TE.bind("base", () => ccyFromUrl(req.params.base)), TE.bind("date", () => optDateFromUrl(req.params.date)), - TE.chain(({ ccy, base, date }) => yahooApi.fxRate(ccy, base, date)), + TE.chain(({ ccy, base, date }) => + yahooApi.fxRate( + ccy, + base, + date ? (getUnixTime(date) as UnixDate) : undefined + ) + ), + mapWebError + ); + +export const quote: HandlerTask = ({ + params: [req], + context: { yahooApi } +}) => + pipe( + TE.Do, + TE.bind("ticker", () => stringFromUrl(req.params.ticker)), + TE.bind("date", () => optDateFromUrl(req.params.date)), + TE.chain(({ ticker, date }) => + yahooApi.quote(ticker, date ? (getUnixTime(date) as UnixDate) : undefined) + ), mapWebError ); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 60b1cccc..5cfa0ded 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -138,7 +138,8 @@ const server = ({ port, app }: Config, ctx: Context): Action => { const lookup = express(); lookup.get("/ticker", handlers.yahoo.search); - lookup.get("/fx/:base/:ccy/:date", handlers.yahoo.fxRate); + lookup.get("/fx/:base/:ccy/:date?", handlers.yahoo.fxRate); + lookup.get("/quote/:ticker/:date?", handlers.yahoo.quote); api.use("/lookup", lookup); exp.use("/api/v1", api); diff --git a/packages/backend/src/yahoo/cached.ts b/packages/backend/src/yahoo/cached.ts index 0fa1df19..524e122f 100644 --- a/packages/backend/src/yahoo/cached.ts +++ b/packages/backend/src/yahoo/cached.ts @@ -1,4 +1,4 @@ -import { type Ccy, type Optional } from "@darkruby/assets-core"; +import { type Ccy, type Optional, type UnixDate } from "@darkruby/assets-core"; import { DEFAULT_CHART_RANGE, type ChartRange @@ -37,10 +37,17 @@ export const cachedYahooApi = (cache: AppCache): YahooApi => { HOUR_1 ); - const fxRate = (ccy: string, base: Ccy, date?: Optional) => + const fxRate = (ccy: string, base: Ccy, ts: Optional) => cache.cachedAction( - `yahoo-ccy-lookup-${ccy}-${base}-${date?.getTime() ?? "latest"}`, - () => rawYahooApi.fxRate(ccy, base, date), + `yahoo-ccy-lookup-${ccy}-${base}-${ts ?? "latest"}`, + () => rawYahooApi.fxRate(ccy, base, ts), + HOUR_1 + ); + + const quote = (ticker: string, ts: Optional) => + cache.cachedAction( + `yahoo-quote-lookup-${ticker}-${ts ?? "latest"}`, + () => rawYahooApi.quote(ticker, ts), HOUR_1 ); @@ -60,6 +67,7 @@ export const cachedYahooApi = (cache: AppCache): YahooApi => { return { meta, + quote, chart, search, fxRate, diff --git a/packages/backend/src/yahoo/client.ts b/packages/backend/src/yahoo/client.ts index c318a83c..956af795 100644 --- a/packages/backend/src/yahoo/client.ts +++ b/packages/backend/src/yahoo/client.ts @@ -1,16 +1,17 @@ import { DEFAULT_CHART_RANGE, - fuzzyIndexSearch, + fuzzyItemSearch, handleError, intervalForRange, methods, - now, + unixNow, unixTimestamp, validationError, YahooChartDataDecoder, YahooTickerSearchResultDecoder, type Action, type Ccy, + type ChartDataPoint, type ChartMeta, type ChartRange, type Fx, @@ -22,12 +23,16 @@ import { type YahooChartData, type YahooTickerSearchResult } from "@darkruby/assets-core"; -import { getUnixTime } from "date-fns"; import * as A from "fp-ts/lib/Array"; import { identity, pipe } from "fp-ts/lib/function"; import * as NEA from "fp-ts/lib/NonEmptyArray"; import * as TE from "fp-ts/lib/TaskEither"; +const fuzzyFindFxRecord = fuzzyItemSearch((item) => item.timestamp); +const fuzzyFindChartDataPoint = fuzzyItemSearch( + (item) => item.timestamp +); + export const getYahooApi = (methods: Methods) => { const SEARCH_URL = (term: string) => `https://query2.finance.yahoo.com/v1/finance/search?q=${term}`; @@ -121,8 +126,9 @@ export const getYahooApi = (methods: Methods) => { const fxRate = ( ccy: string, base: Ccy, - date: Optional = undefined /**no date means latest market rate */ + ts: Optional ): Action => { + ts ??= unixNow(); return pipe( fxRates(ccy, base), TE.map(({ rates }) => { @@ -131,21 +137,12 @@ export const getYahooApi = (methods: Methods) => { ccy, base, rate: rates[0].rate, - timestamp: getUnixTime(date ?? now()) as UnixDate + timestamp: ts }; } - const fuzzyFindIndex = fuzzyIndexSearch( - (item) => item.timestamp - ); - - const idx = pipe(rates, fuzzyFindIndex(getUnixTime(date ?? now()))); - return { - ccy, - base, - rate: rates[idx].rate, - timestamp: rates[idx].timestamp - }; + const { rate, timestamp } = pipe(rates, fuzzyFindFxRecord(ts)); + return { ccy, base, rate, timestamp }; }) ); }; @@ -164,9 +161,22 @@ export const getYahooApi = (methods: Methods) => { ); }; + const quote = ( + ticker: string, + ts: Optional + ): Action => { + ts ??= unixNow(); + const findDataPoint = fuzzyFindChartDataPoint(ts); + return pipe( + chart(ticker, "max"), + TE.map(({ chart }) => findDataPoint(chart)) + ); + }; + return { meta, chart, + quote, search, fxRate, fxRates, diff --git a/packages/core/package.json b/packages/core/package.json index e7856f55..dc7ed09e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@darkruby/assets-core", - "version": "1.8.1", + "version": "1.8.2", "main": "src/index.ts", "type": "module", "scripts": { diff --git a/packages/core/src/domain/tx.ts b/packages/core/src/domain/tx.ts index 423154e5..b586048d 100644 --- a/packages/core/src/domain/tx.ts +++ b/packages/core/src/domain/tx.ts @@ -36,20 +36,17 @@ export const byDateAsc = pipe( DateOrd, contramap((tx) => tx.date) ); +export const isBuy = (type: TxType) => type === "buy"; +export const isSell = (type: TxType) => !isBuy(type); -export const isBuy = ({ type }: T) => type == "buy"; -export const isSell = (tx: T) => !isBuy(tx); +export const txBuy = ({ type }: T) => isBuy(type); +export const txSell = (tx: T) => !txBuy(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, - price, - comments -}: PostTx): PostTx => ({ type, date: new Date(), quantity, price, comments }); +export const cloneTx = (tx: PostTx): PostTx => ({ ...tx, date: new Date() }); export const byDateDesc = pipe(byDateAsc, reverse); diff --git a/packages/core/src/http/api.ts b/packages/core/src/http/api.ts index ec4b29c1..4f4b8529 100644 --- a/packages/core/src/http/api.ts +++ b/packages/core/src/http/api.ts @@ -1,6 +1,7 @@ import { formatISO } from "date-fns"; import { pipe } from "fp-ts/lib/function"; import { + ChartDataPointDecoder, EnrichedAssetDecoder, EnrichedAssetsDecoder, EnrichedPortfolioDecoder, @@ -20,6 +21,7 @@ import { } from "../decoders"; import type { AssetId, + ChartDataPoint, Credentials, EnrichedAsset, EnrichedPortfolio, @@ -91,6 +93,10 @@ const getApi = (baseUrl: string) => (methods: rest.Methods) => { const url = `${API_URL}/lookup/fx/${base}/${ccy}`; return date ? `${url}/${formatISO(date)}` : url; }; + const QUOTE_URL = (ticker: string, date: Optional) => { + const url = `${API_URL}/lookup/quote/${ticker}`; + return date ? `${url}/${formatISO(date)}` : url; + }; const TXS_URL = (portfolioId: PortfolioId, assetId: AssetId) => `${API_URL}/portfolios/${portfolioId}/assets/${assetId}/tx`; const TX_URL = (portfolioId: PortfolioId, assetId: AssetId, txId: TxId) => @@ -234,9 +240,10 @@ const getApi = (baseUrl: string) => (methods: rest.Methods) => { TICKER_URL(ticker), YahooTickerSearchResultDecoder ); - const fxRate = (base: Ccy, ccy: string, date: Optional) => methods.get(FX_URL(base, ccy, date), FxDecoder); + const quote = (ticker: string, date: Optional) => + methods.get(QUOTE_URL(ticker, date), ChartDataPointDecoder); return { user: { @@ -287,6 +294,7 @@ const getApi = (baseUrl: string) => (methods: rest.Methods) => { refreshToken: getRefreshToken }, yahoo: { + quote, lookupTicker, fxRate } diff --git a/packages/core/src/validation/tx.ts b/packages/core/src/validation/tx.ts index 180e523b..f4bce694 100644 --- a/packages/core/src/validation/tx.ts +++ b/packages/core/src/validation/tx.ts @@ -3,22 +3,21 @@ import { pipe } from "fp-ts/lib/function"; import { nonEmptyArray } from "io-ts-types"; import { BooleanDecoder, - nonNegative, PostTxDecoder, PostTxsUploadDecoder } from "../decoders"; import { nonFuture } from "../decoders/date"; import { chainDecoder } from "../decoders/util"; import type { PostTxsUpload } from "../domain"; -import { createValidator } from "./util"; +import { createValidator, nonNegativeField } from "./util"; export const txValidator = pipe( PostTxDecoder, chainDecoder(({ price, quantity, date }) => pipe( E.Do, - E.apS("price", nonNegative.decode(price)), - E.apS("quantity", nonNegative.decode(quantity)), + E.apS("price", nonNegativeField("Price").decode(price)), + E.apS("quantity", nonNegativeField("Quantity").decode(quantity)), E.apS("date", nonFuture.decode(date)) ) ), diff --git a/packages/core/src/validation/util.ts b/packages/core/src/validation/util.ts index 625cbaae..a7c941e6 100644 --- a/packages/core/src/validation/util.ts +++ b/packages/core/src/validation/util.ts @@ -1,6 +1,7 @@ import * as E from "fp-ts/lib/Either"; import { pipe } from "fp-ts/lib/function"; import * as t from "io-ts"; +import { nonNegative } from "../decoders"; import { nonEmptyString } from "../decoders/string"; import { validationErr, withErrorMessage } from "../decoders/util"; @@ -50,3 +51,6 @@ export const alphaNumOnly = (str: string) => export const nonEmptyField = (fieldName: string) => pipe(nonEmptyString, withErrorMessage(`${fieldName} can't be empty`)); + +export const nonNegativeField = (fieldName: string) => + pipe(nonNegative, withErrorMessage(`${fieldName} can't be zero or less`)); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index a979c738..d6b6bfff 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,7 +3,6 @@ "types": [ "bun-types" ], - // Enable latest features "lib": [ "ESNext", "DOM", diff --git a/packages/web/package.json b/packages/web/package.json index 05d755d7..51a326ae 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,13 +1,13 @@ { "name": "@darkruby/assets-web", "private": true, - "version": "1.8.1", + "version": "1.8.2", "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", - "build": "tsc -b && vite build", + "dev": "bunx --bun vite --host 0.0.0.0", + "build": "tsc -b && bunx --bun vite build", "check": "tsc -noEmit", - "preview": "vite preview" + "preview": "bunx --bun vite preview" }, "dependencies": { "@darkruby/assets-core": "workspace:*", diff --git a/packages/web/src/components/Charts/AssetChart.tsx b/packages/web/src/components/Charts/AssetChart.tsx index 392f57b0..15b5347b 100644 --- a/packages/web/src/components/Charts/AssetChart.tsx +++ b/packages/web/src/components/Charts/AssetChart.tsx @@ -1,7 +1,7 @@ import { defined, - isBuy, - isSell, + txBuy, + txSell, type ChartData, type ChartDataPoint } from "@darkruby/assets-core"; @@ -130,13 +130,13 @@ const EventDot = ({ cx, cy, payload }: DotItemDotProps) => { const size = 2; switch (true) { - case isBuy(tx): + case txBuy(tx): return ( ); - case isSell(tx): + case txSell(tx): return ( diff --git a/packages/web/src/components/Form/MoneyEdit.tsx b/packages/web/src/components/Form/MoneyEdit.tsx new file mode 100644 index 00000000..539e0fb9 --- /dev/null +++ b/packages/web/src/components/Form/MoneyEdit.tsx @@ -0,0 +1,32 @@ +import { maybe, type Ccy } from "@darkruby/assets-core"; +import { InputGroup } from "react-bootstrap"; +import { useFormatters, usePrefs } from "../../hooks/prefs"; +import type { PropsOf } from "../../util/props"; +import { FormNumber } from "./NumberEdit"; + +type MoneyFieldProps = PropsOf & { + currency: Ccy; + toBase: (n: number) => number; +}; + +export const MoneyField: React.FC = ({ + currency, + toBase, + ...props +}) => { + const { money } = useFormatters(); + const { base_ccy } = usePrefs(); + const sameCcy = currency == base_ccy; + + const toBaseMaybe = maybe(toBase); + + return ( + + {currency} + + + + ); +}; diff --git a/packages/web/src/components/Form/Tabs.tsx b/packages/web/src/components/Form/Tabs.tsx index aba832ed..c3f3f42c 100644 --- a/packages/web/src/components/Form/Tabs.tsx +++ b/packages/web/src/components/Form/Tabs.tsx @@ -1,7 +1,10 @@ +import type { Optional } from "@darkruby/assets-core"; import { pipe, type Lazy } from "fp-ts/lib/function"; import { createContext, useContext, + useEffect, + useRef, useState, type PropsWithChildren } from "react"; @@ -10,14 +13,34 @@ import { withVisibility } from "../../decorators/nodata"; export type TabsProps = PropsWithChildren<{ tabs: readonly string[]; + onTabChange?: (idx: number) => void; + init?: number; hidden?: boolean; }>; const TabContext = createContext({ tab: 0 }); -export const Tabs: React.FC = ({ tabs, hidden, children }) => { - const [tab, setTab] = useState(0); - const handleTabClick = (idx: number) => () => setTab(idx); +export const Tabs: React.FC = ({ + tabs, + hidden, + onTabChange, + init = 0, + children +}) => { + const [tab, setTab] = useState(init >= 0 && init < tabs.length ? init : 0); + const lastCallbackTabRef = useRef>(undefined); + + const handleTabClick = (idx: number) => () => { + setTab(idx); + }; + + useEffect(() => { + if (lastCallbackTabRef.current !== tab) { + lastCallbackTabRef.current = tab; + onTabChange?.(tab); + } + }, [tab, onTabChange]); + return ( <>