├── bun.lockb ├── .migrations ├── 002_portfolio_view.down.sql ├── 004_prefs.down.sql ├── 003_user_lock.down.sql ├── 003_user_lock.up.sql ├── 001_init.down.sql ├── 002_portfolio_view.up.sql └── 004_prefs.up.sql ├── packages ├── backend │ ├── src │ │ ├── repository │ │ │ ├── sql │ │ │ │ ├── user │ │ │ │ │ ├── delete.sql │ │ │ │ │ ├── login-attempt.sql │ │ │ │ │ ├── reset.sql │ │ │ │ │ ├── insert.sql │ │ │ │ │ ├── get-many.sql │ │ │ │ │ ├── get.sql │ │ │ │ │ ├── update-profile-only.sql │ │ │ │ │ ├── update.sql │ │ │ │ │ └── get-unlocked.sql │ │ │ │ ├── prefs │ │ │ │ │ ├── update.sql │ │ │ │ │ └── get.sql │ │ │ │ ├── portfolio │ │ │ │ │ ├── delete.sql │ │ │ │ │ ├── insert.sql │ │ │ │ │ ├── get-many.sql │ │ │ │ │ ├── get.sql │ │ │ │ │ └── update.sql │ │ │ │ ├── asset │ │ │ │ │ ├── insert.sql │ │ │ │ │ ├── update.sql │ │ │ │ │ ├── delete.sql │ │ │ │ │ ├── get-many.sql │ │ │ │ │ └── get.sql │ │ │ │ └── tx │ │ │ │ │ ├── delete.sql │ │ │ │ │ ├── insert.sql │ │ │ │ │ ├── update.sql │ │ │ │ │ ├── get-many.sql │ │ │ │ │ └── get.sql │ │ │ ├── prefs.ts │ │ │ ├── index.ts │ │ │ ├── sql.ts │ │ │ └── database.ts │ │ ├── domain │ │ │ ├── paging.ts │ │ │ └── error.ts │ │ ├── decoders │ │ │ ├── util.ts │ │ │ └── params.ts │ │ ├── handlers │ │ │ ├── context.ts │ │ │ ├── summary.ts │ │ │ ├── yahoo.ts │ │ │ ├── prefs.ts │ │ │ ├── profile.ts │ │ │ └── user.ts │ │ └── services │ │ │ ├── prefs.ts │ │ │ ├── init.ts │ │ │ ├── yahoo.ts │ │ │ ├── env.ts │ │ │ ├── tx.ts │ │ │ ├── cache.ts │ │ │ └── index.ts │ ├── test │ │ ├── login.spec.ts │ │ ├── ticker.spec.ts │ │ ├── prefs.spec.ts │ │ └── summary.spec.ts │ ├── tsconfig.json │ └── package.json ├── web │ ├── src │ │ ├── util │ │ │ ├── yesno.ts │ │ │ ├── props.ts │ │ │ ├── promise.ts │ │ │ ├── date.ts │ │ │ ├── number.ts │ │ │ └── modal.tsx │ │ ├── components │ │ │ ├── Charts │ │ │ │ ├── Chart.scss │ │ │ │ └── RangesChart.tsx │ │ │ ├── Layout │ │ │ │ ├── AppLayout.scss │ │ │ │ ├── UnauthRouteWrapper.tsx │ │ │ │ ├── Stack.tsx │ │ │ │ ├── AppLayout.tsx │ │ │ │ └── AuthRouteWrapper.tsx │ │ │ ├── Totals │ │ │ │ ├── Totals.scss │ │ │ │ └── Totals.tsx │ │ │ ├── App.tsx │ │ │ ├── Form │ │ │ │ ├── FormErrors.tsx │ │ │ │ ├── Button.tsx │ │ │ │ ├── Alert.tsx │ │ │ │ ├── Password.tsx │ │ │ │ ├── Select.tsx │ │ │ │ ├── Tabs.tsx │ │ │ │ └── Form.tsx │ │ │ ├── Portfolio │ │ │ │ ├── Portfolio.scss │ │ │ │ ├── Menu.tsx │ │ │ │ └── PortfolioFields.tsx │ │ │ ├── Tx │ │ │ │ ├── Menu.tsx │ │ │ │ ├── TickerLookup.scss │ │ │ │ └── TickerLookup.tsx │ │ │ ├── Modals │ │ │ │ ├── Footer.tsx │ │ │ │ ├── Confirmation.tsx │ │ │ │ └── Modal.tsx │ │ │ ├── Asset │ │ │ │ ├── Menu.tsx │ │ │ │ ├── Asset.tsx │ │ │ │ └── AssetFields.tsx │ │ │ ├── Breadcrumb │ │ │ │ └── Breadcrumb.tsx │ │ │ ├── Profile │ │ │ │ ├── Prefs.tsx │ │ │ │ ├── ProfileDetails.tsx │ │ │ │ └── UserForm.tsx │ │ │ ├── Auth │ │ │ │ └── Login.tsx │ │ │ └── TopNav.tsx │ │ ├── vite-env.d.ts │ │ ├── hooks │ │ │ ├── store.ts │ │ │ ├── formData.ts │ │ │ └── prefs.ts │ │ ├── main.tsx │ │ ├── services │ │ │ ├── ticker.ts │ │ │ ├── summary.ts │ │ │ ├── prefs.ts │ │ │ ├── token.ts │ │ │ ├── env.ts │ │ │ ├── profile.ts │ │ │ ├── api.ts │ │ │ ├── users.ts │ │ │ ├── txs.ts │ │ │ ├── storage.ts │ │ │ ├── auth.ts │ │ │ ├── portfolios.ts │ │ │ └── assets.ts │ │ ├── decorators │ │ │ ├── fetching.tsx │ │ │ ├── admin.tsx │ │ │ └── nodata.tsx │ │ ├── screens │ │ │ ├── Logout.tsx │ │ │ ├── Users.tsx │ │ │ ├── Profile.tsx │ │ │ ├── Login.tsx │ │ │ ├── Portfolios.tsx │ │ │ ├── Test.tsx │ │ │ └── Asset.tsx │ │ ├── stores │ │ │ ├── prefs.ts │ │ │ ├── summary.ts │ │ │ ├── profile.ts │ │ │ ├── tx.ts │ │ │ ├── txs.ts │ │ │ ├── users.ts │ │ │ ├── base.ts │ │ │ ├── store.ts │ │ │ ├── auth.ts │ │ │ ├── portfolio.ts │ │ │ ├── portfolios.ts │ │ │ ├── assets.ts │ │ │ └── asset.ts │ │ └── App.scss │ ├── index.html │ ├── assets │ │ ├── barchart-selected 16x16.svg │ │ ├── arrow-down 16x16.svg │ │ ├── barchart-default 16x16.svg │ │ ├── linechart-selected 16x16.svg │ │ ├── linechart-default 16x16.svg │ │ ├── menu 16x16.svg │ │ ├── plus 16x16.svg │ │ ├── search 16x16.svg │ │ └── filter 16x16.svg │ ├── tsconfig.json │ ├── eslint.config.js │ ├── vite.config.ts │ └── package.json ├── assets-core │ ├── src │ │ ├── http │ │ │ ├── index.ts │ │ │ └── yahoo.ts │ │ ├── decoders │ │ │ ├── id.ts │ │ │ ├── yahoo │ │ │ │ ├── index.ts │ │ │ │ ├── ticker.ts │ │ │ │ └── period.ts │ │ │ ├── index.ts │ │ │ ├── token.ts │ │ │ ├── enum.ts │ │ │ ├── summary.ts │ │ │ ├── error.ts │ │ │ ├── transaction.ts │ │ │ ├── portfolio.ts │ │ │ ├── prefs.ts │ │ │ └── asset.ts │ │ ├── validation │ │ │ ├── index.ts │ │ │ ├── portfolio.ts │ │ │ ├── tx.ts │ │ │ ├── asset.ts │ │ │ ├── user.ts │ │ │ └── util.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ └── yahoo.ts │ │ ├── domain │ │ │ ├── id.ts │ │ │ ├── token.ts │ │ │ ├── prefs.ts │ │ │ ├── summary.ts │ │ │ ├── ticker.ts │ │ │ ├── index.ts │ │ │ ├── transaction.ts │ │ │ ├── asset.ts │ │ │ ├── portfolio.ts │ │ │ ├── user.ts │ │ │ └── error.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── date.ts │ │ │ ├── array.ts │ │ │ ├── finance.ts │ │ │ └── utils.ts │ ├── test │ │ ├── data │ │ │ ├── error │ │ │ │ └── NONEXIST.json │ │ │ ├── meta │ │ │ │ ├── 0P0000KSPA.L.json │ │ │ │ ├── 0P0001I2A0.L.json │ │ │ │ └── IE000I7E6HL0.SG.json │ │ │ ├── FUND1.json │ │ │ └── FUND3.json │ │ ├── metadecoder.spec.ts │ │ ├── helper.ts │ │ ├── chartdecoder.spec.ts │ │ └── validation │ │ │ └── user.spec.ts │ ├── package.json │ └── tsconfig.json └── fp-express │ ├── src │ ├── index.ts │ ├── log.ts │ ├── util.ts │ └── error.ts │ ├── package.json │ └── tsconfig.json ├── .dockerignore ├── docker-compose.yaml ├── .gitignore ├── Dockerfile.test ├── Dockerfile ├── .gitea └── workflows │ ├── dev-deploy.yaml │ ├── build-and-test.yaml │ └── build-docker-container.yaml ├── .github └── workflows │ ├── build-and-test.yaml │ └── build-docker-container.yaml └── package.json /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/venil7/assets/HEAD/bun.lockb -------------------------------------------------------------------------------- /.migrations/002_portfolio_view.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE transactions 2 | DROP COLUMN comments; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/delete.sql: -------------------------------------------------------------------------------- 1 | delete from users 2 | where id = $userId; -------------------------------------------------------------------------------- /packages/web/src/util/yesno.ts: -------------------------------------------------------------------------------- 1 | export const yesNo = (b: boolean) => (b ? "Yes" : "No"); 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | Dockerfile 4 | **/.db 5 | **/.env 6 | data/ 7 | .vscode -------------------------------------------------------------------------------- /packages/web/src/components/Charts/Chart.scss: -------------------------------------------------------------------------------- 1 | .range-chart { 2 | margin-bottom: 1em; 3 | } 4 | -------------------------------------------------------------------------------- /.migrations/004_prefs.down.sql: -------------------------------------------------------------------------------- 1 | drop table if exists prefs; 2 | 3 | drop trigger if exists insert_user_prefs; -------------------------------------------------------------------------------- /packages/web/src/util/props.ts: -------------------------------------------------------------------------------- 1 | export type PropsOf = Cmp extends React.FC ? T : never; 2 | -------------------------------------------------------------------------------- /packages/assets-core/src/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./rest"; 3 | export * from "./yahoo"; 4 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/prefs/update.sql: -------------------------------------------------------------------------------- 1 | update prefs 2 | set base_ccy = $base_ccy 3 | where user_id = $userId; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/prefs/get.sql: -------------------------------------------------------------------------------- 1 | select id, base_ccy 2 | from prefs p 3 | where p.user_id = $userId 4 | limit 1; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/portfolio/delete.sql: -------------------------------------------------------------------------------- 1 | delete from portfolios 2 | where id = $portfolioId 3 | AND user_id = $userId; -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/id.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | 3 | export const IdDecoder = t.type({ 4 | id: t.number, 5 | }); 6 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/asset/insert.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO assets (name, ticker, portfolio_id) 2 | VALUES ($name, $ticker, $portfolioId) -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/portfolio/insert.sql: -------------------------------------------------------------------------------- 1 | insert into portfolios(name, description, user_id) 2 | values($name, $description, $userId); -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/login-attempt.sql: -------------------------------------------------------------------------------- 1 | update users 2 | set login_attempts = login_attempts + 1 3 | where username = $username; -------------------------------------------------------------------------------- /packages/fp-express/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./error"; 2 | export * from "./handlers"; 3 | export * from "./log"; 4 | export * from "./util"; 5 | -------------------------------------------------------------------------------- /packages/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const VERSION: string; 3 | declare const BUILD_DATE: string; 4 | -------------------------------------------------------------------------------- /.migrations/003_user_lock.down.sql: -------------------------------------------------------------------------------- 1 | -- tables 2 | 3 | ALTER TABLE users 4 | DROP COLUMN login_attempts; 5 | 6 | ALTER TABLE users 7 | DROP COLUMN locked; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/reset.sql: -------------------------------------------------------------------------------- 1 | update users 2 | set login_attempts = 0 3 | where username = $username 4 | and login_attempts > 0; -------------------------------------------------------------------------------- /packages/web/src/util/promise.ts: -------------------------------------------------------------------------------- 1 | export const wait = (n: number) => 2 | new Promise((resolve) => { 3 | setTimeout(resolve, n * 1000); 4 | }); 5 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/portfolio/get-many.sql: -------------------------------------------------------------------------------- 1 | select p.* 2 | from portfolios_ext p 3 | where p.user_id = $userId 4 | limit $limit offset $offset; -------------------------------------------------------------------------------- /packages/assets-core/src/validation/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./asset"; 2 | export * from "./portfolio"; 3 | export * from "./tx"; 4 | export * from "./user"; 5 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/portfolio/get.sql: -------------------------------------------------------------------------------- 1 | select p.* 2 | from portfolios_ext p 3 | where p.id = $portfolioId 4 | and p.user_id = $userId 5 | limit 1; -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/yahoo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./chart"; 2 | export * from "./meta"; 3 | export * from "./period"; 4 | export * from "./ticker"; 5 | -------------------------------------------------------------------------------- /packages/assets-core/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./asset"; 2 | export * from "./portfolio"; 3 | export * from "./summary"; 4 | export * from "./yahoo"; 5 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/insert.sql: -------------------------------------------------------------------------------- 1 | insert into users(username, phash, psalt, admin, locked) 2 | values ($username, $phash, $psalt, $admin, $locked); -------------------------------------------------------------------------------- /packages/assets-core/src/domain/id.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import type { IdDecoder } from "../decoders"; 3 | 4 | export type Id = t.TypeOf; 5 | -------------------------------------------------------------------------------- /packages/backend/src/domain/paging.ts: -------------------------------------------------------------------------------- 1 | export type Paging = { limit: number; offset: number }; 2 | export const defaultPaging = (): Paging => ({ limit: 50, offset: 0 }); 3 | -------------------------------------------------------------------------------- /packages/web/src/util/date.ts: -------------------------------------------------------------------------------- 1 | import { formatISO } from "date-fns"; 2 | 3 | export const iso = (d: Date) => 4 | formatISO(d, { format: "extended", representation: "date" }); 5 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/token.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import type { TokenDecoder } from "../decoders/token"; 3 | 4 | export type Token = t.TypeOf; 5 | -------------------------------------------------------------------------------- /packages/backend/src/decoders/util.ts: -------------------------------------------------------------------------------- 1 | // export const liftTE = 2 | // (decoder: Decoder) => 3 | // (data: U) => 4 | // pipe(lift(decoder)(data), TE.mapLeft(toWebError)); 5 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/asset/update.sql: -------------------------------------------------------------------------------- 1 | UPDATE assets 2 | SET name = $name, 3 | ticker = $ticker, 4 | modified = CURRENT_TIMESTAMP 5 | WHERE id = $assetId 6 | and portfolio_id = $portfolioId -------------------------------------------------------------------------------- /.migrations/003_user_lock.up.sql: -------------------------------------------------------------------------------- 1 | -- tables 2 | 3 | ALTER TABLE users 4 | ADD COLUMN login_attempts integer DEFAULT 0; 5 | 6 | ALTER TABLE users 7 | ADD COLUMN locked integer DEFAULT 0 CHECK(locked IN (0, 1)); -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/portfolio/update.sql: -------------------------------------------------------------------------------- 1 | UPDATE portfolios 2 | SET name = $name, 3 | description = $description, 4 | modified = CURRENT_TIMESTAMP 5 | WHERE id = $portfolioId 6 | AND user_id = $userId; -------------------------------------------------------------------------------- /packages/assets-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./decoders"; 2 | export * from "./domain"; 3 | export * from "./http"; 4 | export * from "./services"; 5 | export * from "./utils/utils"; 6 | export * from "./validation"; 7 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/tx/delete.sql: -------------------------------------------------------------------------------- 1 | delete from transactions 2 | where id in ( 3 | select at.id 4 | from asset_transactions at 5 | where at.id = $txId 6 | and at.user_id = $userId 7 | limit 1 8 | ); -------------------------------------------------------------------------------- /packages/assets-core/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { max } from "date-fns"; 2 | import * as M from "fp-ts/lib/Monoid"; 3 | 4 | export const LatestDateMonoid: M.Monoid = { 5 | empty: new Date(0), 6 | concat: (a, b) => max([a, b]), 7 | }; 8 | -------------------------------------------------------------------------------- /packages/assets-core/test/data/error/NONEXIST.json: -------------------------------------------------------------------------------- 1 | { 2 | "chart": { 3 | "result": null, 4 | "error": { 5 | "code": "Not Found", 6 | "description": "No data found, symbol may be delisted" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/tx/insert.sql: -------------------------------------------------------------------------------- 1 | insert into transactions (asset_id, type, quantity, price, comments, date) 2 | values ( 3 | $assetId, 4 | $type, 5 | $quantity, 6 | $price, 7 | $comments, 8 | $date 9 | ); -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/get-many.sql: -------------------------------------------------------------------------------- 1 | select id, 2 | username, 3 | admin, 4 | login_attempts, 5 | locked, 6 | phash, 7 | psalt, 8 | created, 9 | modified 10 | from users u 11 | limit $limit offset $offset; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/get.sql: -------------------------------------------------------------------------------- 1 | select phash, 2 | psalt, 3 | created, 4 | modified, 5 | id, 6 | username, 7 | admin, 8 | login_attempts, 9 | locked 10 | from users u 11 | where u.id = $userId 12 | limit 1; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/update-profile-only.sql: -------------------------------------------------------------------------------- 1 | update users 2 | set username = $username, 3 | admin = $admin, 4 | login_attempts = $login_attempts, 5 | locked = $locked, 6 | modified = CURRENT_TIMESTAMP 7 | where id = $userId; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/tx/update.sql: -------------------------------------------------------------------------------- 1 | UPDATE transactions 2 | SET type = $type, 3 | quantity = $quantity, 4 | price = $price, 5 | comments = $comments, 6 | date = $date, 7 | modified = CURRENT_TIMESTAMP 8 | WHERE id = $txId 9 | and asset_id = $assetId; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/update.sql: -------------------------------------------------------------------------------- 1 | update users 2 | set username = $username, 3 | phash = $phash, 4 | psalt = $psalt, 5 | admin = $admin, 6 | login_attempts = $login_attempts, 7 | locked = $locked, 8 | modified = CURRENT_TIMESTAMP 9 | where id = $userId; -------------------------------------------------------------------------------- /packages/assets-core/src/domain/prefs.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { BASE_CCYS, type PrefsDecoder } from "../decoders"; 3 | 4 | export type Prefs = t.TypeOf; 5 | 6 | export const defaultPrefs = (): Prefs => ({ 7 | base_ccy: BASE_CCYS[0], 8 | }); 9 | -------------------------------------------------------------------------------- /packages/web/src/hooks/store.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { createStoreContext } from "../stores/store"; 3 | 4 | export const [store, StoreContext] = createStoreContext(); 5 | 6 | export const useStore = () => { 7 | return useContext(StoreContext); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/web/src/components/Layout/AppLayout.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | padding-top: 2em; 3 | } 4 | 5 | .footer { 6 | margin: 0.5em; 7 | color: darkslategray; 8 | /* position: sticky; */ 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: center; 12 | } 13 | -------------------------------------------------------------------------------- /packages/assets-core/src/utils/array.ts: -------------------------------------------------------------------------------- 1 | import * as NA from "fp-ts/lib/NonEmptyArray"; 2 | 3 | export const nonEmpty = 4 | (fallback: () => T) => 5 | (a: Array): NA.NonEmptyArray => { 6 | if (a.length > 0) return a as NA.NonEmptyArray; 7 | return [fallback()]; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/asset/delete.sql: -------------------------------------------------------------------------------- 1 | delete from assets 2 | where id in ( 3 | select A.id 4 | from assets A 5 | inner join portfolios P ON P.id = A.portfolio_id 6 | where A.id = $assetId 7 | and P.id = $portfolioId 8 | AND P.user_id = $userId 9 | limit 1 10 | ); -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/user/get-unlocked.sql: -------------------------------------------------------------------------------- 1 | select 2 | id, 3 | username, 4 | admin, 5 | psalt, 6 | phash, 7 | login_attempts, 8 | locked, 9 | created, 10 | modified 11 | from users u 12 | where u.username = $username 13 | and u.login_attempts < 3 14 | and u.locked < 1 15 | limit 1; -------------------------------------------------------------------------------- /packages/assets-core/src/domain/summary.ts: -------------------------------------------------------------------------------- 1 | import type { EnrichedPortfolio } from "./portfolio"; 2 | import type { ChartData, PeriodChanges, Totals } from "./yahoo"; 3 | 4 | export type Summary = { 5 | chart: ChartData; 6 | value: PeriodChanges; 7 | totals: Totals; 8 | meta: EnrichedPortfolio["meta"]; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/tx/get-many.sql: -------------------------------------------------------------------------------- 1 | select id, 2 | asset_id, 3 | type, 4 | quantity, 5 | price, 6 | date, 7 | created, 8 | modified, 9 | comments 10 | from asset_transactions at 11 | where at.asset_id = $assetId 12 | and at.user_id = $userId 13 | order by at.date desc 14 | limit $limit offset $offset; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/tx/get.sql: -------------------------------------------------------------------------------- 1 | select id, 2 | asset_id, 3 | type, 4 | quantity, 5 | price, 6 | date, 7 | created, 8 | modified, 9 | comments 10 | from asset_transactions at 11 | where at.id = $txId 12 | and at.asset_id = $assetId 13 | and at.user_id = $userId 14 | order by at.date desc 15 | limit 1; -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./asset"; 2 | export * from "./error"; 3 | export * from "./id"; 4 | export * from "./portfolio"; 5 | export * from "./prefs"; 6 | export * from "./summary"; 7 | export * from "./token"; 8 | export * from "./transaction"; 9 | export * from "./user"; 10 | export * from "./yahoo"; 11 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/ticker.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import type { 3 | YahooTickerDecoder, 4 | YahooTickerSearchResultDecoder, 5 | } from "../decoders"; 6 | 7 | export type Ticker = t.TypeOf; 8 | 9 | export type TickerSearchResult = t.TypeOf< 10 | typeof YahooTickerSearchResultDecoder 11 | >; 12 | -------------------------------------------------------------------------------- /packages/web/src/components/Totals/Totals.scss: -------------------------------------------------------------------------------- 1 | .totals { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | gap: 0.5em; 6 | .value { 7 | align-self: flex-start; 8 | } 9 | .change { 10 | display: flex; 11 | flex-direction: column; 12 | .value,.pct { 13 | align-self: flex-end; 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /packages/fp-express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@darkruby/fp-express", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "type": "module", 6 | "scripts": { 7 | "check": "tsc --noEmit" 8 | }, 9 | "dependencies": { 10 | "express": "^4.21.2" 11 | }, 12 | "devDependencies": { 13 | "@types/express": "^5.0.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "bootstrap/dist/css/bootstrap.min.css"; 2 | import { StrictMode } from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import "./App.scss"; 5 | import { App } from "./components/App"; 6 | 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/token.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import type { Nullable } from "../utils/utils"; 3 | import { nullableDecoder } from "./util"; 4 | 5 | const tokenTypes = { 6 | token: t.string, 7 | refreshBefore: nullableDecoder(t.number) as t.Type>, 8 | }; 9 | 10 | export const TokenDecoder = t.type(tokenTypes); 11 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./asset"; 2 | export * from "./error"; 3 | export * from "./id"; 4 | export * from "./portfolio"; 5 | export * from "./prefs"; 6 | export * from "./summary"; 7 | export * from "./ticker"; 8 | export * from "./token"; 9 | export * from "./transaction"; 10 | export * from "./user"; 11 | export * from "./yahoo"; 12 | -------------------------------------------------------------------------------- /packages/assets-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@darkruby/assets-core", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "type": "module", 6 | "scripts": { 7 | "test": "bun test", 8 | "check": "tsc --noEmit" 9 | }, 10 | "dependencies": { 11 | "heap-js": "^2.6.0", 12 | "io-ts": "^2.2.22", 13 | "io-ts-reporters": "^2.0.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/asset/get-many.sql: -------------------------------------------------------------------------------- 1 | SELECT id, 2 | portfolio_id, 3 | name, 4 | ticker, 5 | created, 6 | modified, 7 | holdings, 8 | invested, 9 | num_tx as num_txs, 10 | avg_price, 11 | portfolio_contribution 12 | FROM assets_contributions A 13 | WHERE A.portfolio_id = $portfolioId 14 | AND A.user_id = $userId 15 | LIMIT $limit OFFSET $offset; -------------------------------------------------------------------------------- /packages/backend/src/repository/sql/asset/get.sql: -------------------------------------------------------------------------------- 1 | SELECT id, 2 | portfolio_id, 3 | name, 4 | ticker, 5 | created, 6 | modified, 7 | holdings, 8 | invested, 9 | num_tx as num_txs, 10 | avg_price, 11 | portfolio_contribution 12 | FROM assets_contributions A 13 | WHERE A.portfolio_id = $portfolioId 14 | AND A.user_id = $userId 15 | AND A.id = $assetId 16 | LIMIT 1; -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | assets manager 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/context.ts: -------------------------------------------------------------------------------- 1 | import type { YahooApi } from "@darkruby/assets-core"; 2 | import type { Repository } from "../repository"; 3 | import type { WebService } from "../services"; 4 | import type { AppCache } from "../services/cache"; 5 | 6 | export type Context = { 7 | repo: Repository; 8 | service: WebService; 9 | cache: AppCache; 10 | yahooApi: YahooApi; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/fp-express/src/log.ts: -------------------------------------------------------------------------------- 1 | import { formatISO } from "date-fns"; 2 | 3 | export const createLogger = (name: string) => { 4 | const print = (s: string) => `[${formatISO(new Date())}] {${name}}: ${s}`; 5 | return { 6 | debug: (s: string) => console.debug(print(s)), 7 | info: (s: string) => console.info(print(s)), 8 | error: (s: string) => console.error(print(s)), 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/web/src/components/Layout/UnauthRouteWrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { Container } from "react-bootstrap"; 3 | import { Outlet } from "react-router"; 4 | import "./AppLayout.scss"; 5 | 6 | export const UnauthRouteWrapper: React.FC = ({ 7 | children, 8 | }) => { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/web/src/components/Layout/Stack.tsx: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import type { PropsWithChildren } from "react"; 3 | import { Stack, type StackProps } from "react-bootstrap"; 4 | import { withProps } from "../../decorators/props"; 5 | 6 | export const HorizontalStack = pipe( 7 | Stack, 8 | withProps({ direction: "horizontal" }) 9 | ) as React.FC & PropsWithChildren>; 10 | -------------------------------------------------------------------------------- /packages/web/src/services/ticker.ts: -------------------------------------------------------------------------------- 1 | import type { Action, TickerSearchResult } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import { apiFromToken } from "./api"; 5 | 6 | export const lookupTicker = (term: string): Action => { 7 | return pipe( 8 | apiFromToken, 9 | TE.chain(({ yahoo }) => yahoo.lookupTicker(term)) 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/web/assets/barchart-selected 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/web/assets/arrow-down 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.migrations/001_init.down.sql: -------------------------------------------------------------------------------- 1 | drop table if exists users; 2 | drop table if exists assets; 3 | drop table if exists portfolios; 4 | drop table if exists transactions; 5 | 6 | drop view if exists portfolios_ext; 7 | drop view if exists asset_transactions; 8 | drop view if exists asset_holdings; 9 | drop view if exists assets_contributions; 10 | 11 | drop trigger if exists check_holdings_before_insert_sell; 12 | drop trigger if exists check_holdings_before_update_sell; 13 | 14 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | assets: 3 | image: ghcr.io/venil7/assets:${TAG} 4 | environment: 5 | - ASSETS_DB=/data/assets.db 6 | - ASSETS_CACHE_TTL=${ASSETS_CACHE_TTL} 7 | - ASSETS_JWT_SECRET=${ASSETS_JWT_SECRET} 8 | - ASSETS_JWT_EXPIRES_IN=${ASSETS_JWT_EXPIRES_IN} 9 | - ASSETS_JWT_REFRESH_BEFORE=${ASSETS_JWT_REFRESH_BEFORE} 10 | volumes: 11 | - ./:/data 12 | ports: 13 | - 8084:4020 14 | restart: unless-stopped 15 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/enum.ts: -------------------------------------------------------------------------------- 1 | import * as A from "fp-ts/lib/Array"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as t from "io-ts"; 4 | 5 | export const EnumDecoder = (enumObj: { 6 | [k in string]: TEnum; 7 | }): t.Type => 8 | pipe( 9 | Object.values(enumObj) as string[], 10 | A.map((v: string) => t.literal(v) as t.Mixed), 11 | (codecs) => t.union(codecs as [t.Mixed, t.Mixed, ...t.Mixed[]]) 12 | ); 13 | -------------------------------------------------------------------------------- /packages/web/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { RouterProvider } from "react-router"; 3 | import { store, StoreContext } from "../hooks/store"; 4 | import { router } from "./Router"; 5 | 6 | export const App: React.FC = () => { 7 | useEffect(() => { 8 | store.auth.refresh(); 9 | }, []); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/assets-core/src/validation/portfolio.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import { PostPortfolioDecoder } from "../decoders"; 4 | import { mapDecoder, nonEmptyString } from "../decoders/util"; 5 | import { createValidator } from "./util"; 6 | 7 | export const portfolioValidator = pipe( 8 | mapDecoder(PostPortfolioDecoder, ({ name }) => 9 | pipe(E.Do, E.apS("name", nonEmptyString.decode(name))) 10 | ), 11 | createValidator 12 | ); 13 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/transaction.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import type { GetTxDecoder, PostTxDecoder } from "../decoders/transaction"; 3 | 4 | export type PostTx = t.TypeOf; 5 | export type GetTx = t.TypeOf; 6 | export type TxType = GetTx["type"]; 7 | export type TxId = GetTx["id"]; 8 | 9 | export const defaultBuyTx = (): PostTx => ({ 10 | date: new Date(), 11 | quantity: 0, 12 | price: 0, 13 | comments: "", 14 | type: "buy", 15 | }); 16 | -------------------------------------------------------------------------------- /packages/assets-core/src/utils/finance.ts: -------------------------------------------------------------------------------- 1 | import * as A from "fp-ts/lib/Array"; 2 | import * as M from "fp-ts/lib/Monoid"; 3 | 4 | export const changeInValue = (before: number) => (after: number) => 5 | after - before; 6 | export const changeInValuePct = (before: number) => (after: number) => 7 | before == 0 ? 0 : (after - before) / before; 8 | 9 | export const sumMonoid: M.Monoid = { 10 | empty: 0, 11 | concat: (a, b) => a + b, 12 | }; 13 | 14 | export const sum = A.foldMap(sumMonoid); 15 | -------------------------------------------------------------------------------- /packages/web/assets/barchart-default 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/web/src/services/summary.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type Summary } from "@darkruby/assets-core"; 2 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 3 | import { pipe } from "fp-ts/lib/function"; 4 | import * as TE from "fp-ts/lib/TaskEither"; 5 | import { apiFromToken } from "./api"; 6 | 7 | export const getSummary = (range?: ChartRange): Action => { 8 | return pipe( 9 | apiFromToken, 10 | TE.chain(({ summary }) => summary.get(range)) 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | */**/tsconfig.tsbuildinfo 11 | 12 | *.svg 13 | 14 | .bruno 15 | .vscode 16 | design 17 | 18 | node_modules 19 | *.db 20 | *.sqlite 21 | dist 22 | __debug* 23 | .env 24 | static 25 | dist-ssr 26 | *.local 27 | 28 | # Editor directories and files 29 | .vscode/* 30 | !.vscode/extensions.json 31 | .idea 32 | .DS_Store 33 | *.suo 34 | *.ntvs* 35 | *.njsproj 36 | *.sln 37 | *.sw? 38 | *.bak -------------------------------------------------------------------------------- /packages/backend/test/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@darkruby/assets-core"; 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; 3 | import { nonAdminApi, type TestApi } from "./helper"; 4 | 5 | let api: TestApi; 6 | beforeAll(async () => { 7 | api = await run(nonAdminApi()); 8 | }); 9 | afterAll(async () => { 10 | await run(api.profile.delete()); 11 | }); 12 | 13 | test("Get refresh token", async () => { 14 | const { token } = await run(api.auth.refreshToken()); 15 | expect(token).toBeString(); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/fp-express/src/util.ts: -------------------------------------------------------------------------------- 1 | import * as RTE from "fp-ts/ReaderTaskEither"; 2 | import * as TE from "fp-ts/TaskEither"; 3 | import { pipe } from "fp-ts/function"; 4 | import { generalError, type WebAppError } from "./error"; 5 | 6 | export const fromTryCatch = ( 7 | func: (r: Ctx) => Promise, 8 | onError: (e: unknown) => WebAppError = generalError 9 | ) => 10 | RTE.asksReaderTaskEither((ctx: Ctx) => 11 | pipe( 12 | TE.tryCatch(() => func(ctx), onError), 13 | RTE.fromTaskEither 14 | ) 15 | ); 16 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/summary.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { PortfolioMetaDecoder } from "./portfolio"; 3 | import { nonEmptyArray } from "./util"; 4 | import { ChartDataPointDecoder } from "./yahoo/chart"; 5 | import { PeriodChangesDecoder, TotalsDecoder } from "./yahoo/period"; 6 | 7 | const summaryTypes = { 8 | chart: nonEmptyArray(ChartDataPointDecoder), 9 | value: PeriodChangesDecoder, 10 | totals: TotalsDecoder, 11 | meta: PortfolioMetaDecoder, 12 | }; 13 | 14 | export const SummaryDecoder = t.type(summaryTypes); 15 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/FormErrors.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Danger } from "./Alert"; 3 | 4 | type FormErrorProps = { 5 | errors: string[]; 6 | valid: boolean; 7 | }; 8 | 9 | export const FormErrors: React.FC = ({ 10 | errors, 11 | valid, 12 | }: FormErrorProps) => { 13 | return ( 14 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Button.tsx: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import { Button, type ButtonProps } from "react-bootstrap"; 3 | import { withProps } from "../../decorators/props"; 4 | 5 | const PrimaryBtn = pipe( 6 | Button, 7 | withProps({ 8 | size: "sm", 9 | variant: "outline-primary", 10 | }) 11 | ) as React.FC; 12 | 13 | export const AddBtn: React.FC<{ label: string } & ButtonProps> = ({ 14 | label, 15 | ...props 16 | }) => { 17 | return ; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/web/src/services/prefs.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Prefs } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import { apiFromToken } from "./api"; 5 | 6 | export const getPrefs = (): Action => { 7 | return pipe( 8 | apiFromToken, 9 | TE.chain(({ prefs }) => prefs.get()) 10 | ); 11 | }; 12 | 13 | export const updatePrefs = (p: Prefs): Action => { 14 | return pipe( 15 | apiFromToken, 16 | TE.chain(({ prefs }) => prefs.update(p)) 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/assets-core/src/validation/tx.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import { PostTxDecoder } from "../decoders"; 4 | import { mapDecoder, nonNegative } from "../decoders/util"; 5 | import { createValidator } from "./util"; 6 | 7 | export const txValidator = pipe( 8 | mapDecoder(PostTxDecoder, ({ price, quantity }) => 9 | pipe( 10 | E.Do, 11 | E.apS("price", nonNegative.decode(price)), 12 | E.apS("quantity", nonNegative.decode(quantity)) 13 | ) 14 | ), 15 | createValidator 16 | ); 17 | -------------------------------------------------------------------------------- /packages/web/src/decorators/fetching.tsx: -------------------------------------------------------------------------------- 1 | import { type Identity } from "@darkruby/assets-core"; 2 | import React from "react"; 3 | import { Spinner } from "react-bootstrap"; 4 | 5 | export type Props = {}; 6 | 7 | export type WithFetching = Identity< 8 | TProps & { fetching: boolean } 9 | >; 10 | 11 | export function withFetching

( 12 | Component: React.FC

13 | ): React.FC> { 14 | return ({ fetching, ...rest }: WithFetching

) => 15 | fetching ? : ; 16 | } 17 | -------------------------------------------------------------------------------- /packages/backend/test/ticker.spec.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@darkruby/assets-core"; 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; 3 | import { type TestApi, nonAdminApi } from "./helper"; 4 | 5 | let api: TestApi; 6 | beforeAll(async () => { 7 | api = await run(nonAdminApi()); 8 | }); 9 | afterAll(async () => { 10 | await run(api.profile.delete()); 11 | }); 12 | 13 | test("Lookup ticker", async () => { 14 | const { quotes } = await run(api.yahoo.lookupTicker("MSFT")); 15 | expect(quotes).toBeArray(); 16 | expect(quotes[0].symbol).toBe("MSFT"); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/assets-core/src/validation/asset.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import { PostAssetDecoder } from "../decoders"; 4 | import { mapDecoder, nonEmptyString } from "../decoders/util"; 5 | import { createValidator } from "./util"; 6 | 7 | export const assetValidator = pipe( 8 | mapDecoder(PostAssetDecoder, ({ ticker, name }) => 9 | pipe( 10 | E.Do, 11 | E.apS("ticker", nonEmptyString.decode(ticker)), 12 | E.apS("name", nonEmptyString.decode(name)) 13 | ) 14 | ), 15 | createValidator 16 | ); 17 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/summary.ts: -------------------------------------------------------------------------------- 1 | import { 2 | summarize, 3 | type EnrichedPortfolio, 4 | type Summary, 5 | } from "@darkruby/assets-core"; 6 | import type { HandlerTask } from "@darkruby/fp-express"; 7 | import * as TE from "fp-ts/lib/TaskEither"; 8 | import { pipe } from "fp-ts/lib/function"; 9 | import type { Context } from "./context"; 10 | import { getPortfolios } from "./portfolio"; 11 | 12 | export const getSummary: HandlerTask = (ctx) => 13 | pipe( 14 | getPortfolios(ctx), 15 | TE.map((p) => summarize(p as EnrichedPortfolio[])) 16 | ); 17 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/yahoo/ticker.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import * as t from "io-ts"; 3 | import { nullableDecoder } from "../util"; 4 | 5 | const tickerTypes = { 6 | exchange: t.string, 7 | shortname: nullableDecoder(t.string), 8 | longname: nullableDecoder(t.string), 9 | quoteType: t.string, 10 | symbol: t.string, 11 | }; 12 | 13 | export const YahooTickerDecoder = pipe(t.type(tickerTypes), t.exact); 14 | export const YahooTickerSearchResultDecoder = pipe( 15 | t.type({ 16 | quotes: t.array(YahooTickerDecoder), 17 | }), 18 | t.exact 19 | ); 20 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/error.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { EnumDecoder } from "./enum"; 3 | 4 | export enum AppErrorType { 5 | General = "General", 6 | Validation = "Validation", 7 | Auth = "Auth", 8 | } 9 | 10 | const appErrorMessage = { 11 | message: t.string, 12 | }; 13 | 14 | const appErrorType = { 15 | type: EnumDecoder(AppErrorType), 16 | ...appErrorMessage, 17 | }; 18 | 19 | export const AppErrorMessageDecoder = t.type(appErrorMessage); 20 | export const AppErrorDecoder = t.type(appErrorType); 21 | 22 | export const AnyDecoder = t.record(t.string, t.any); 23 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import { Alert } from "react-bootstrap"; 3 | import { withVisibility } from "../../decorators/nodata"; 4 | import { withProps } from "../../decorators/props"; 5 | 6 | export const Warning = pipe( 7 | Alert, 8 | withProps({ variant: "warning" }), 9 | withVisibility() 10 | ); 11 | 12 | export const Info = pipe( 13 | Alert, 14 | withProps({ variant: "info" }), 15 | withVisibility() 16 | ); 17 | 18 | export const Danger = pipe( 19 | Alert, 20 | withProps({ variant: "danger" }), 21 | withVisibility() 22 | ); 23 | -------------------------------------------------------------------------------- /packages/web/src/components/Portfolio/Portfolio.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/scss/_functions"; 2 | @import "bootstrap/scss/_variables"; 3 | 4 | @mixin hover-effect { 5 | &:hover { 6 | background-color: darken($body-secondary-bg-dark, 5%); 7 | } 8 | } 9 | 10 | .portfolios, 11 | .portfolio-details, 12 | .asset-details, 13 | .users { 14 | .top-toolbar { 15 | display: flex; 16 | flex-direction: row; 17 | justify-content: space-between; 18 | align-items: flex-start; 19 | padding: 0.5em 0 0.5em 0; 20 | } 21 | .portfolio-link, 22 | .asset-link { 23 | @include hover-effect; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/src/components/Layout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { Container } from "react-bootstrap"; 3 | import { TopNav } from "../TopNav"; 4 | import "./AppLayout.scss"; 5 | 6 | export const AppLayout: React.FC = ({ children }) => { 7 | return ( 8 | <> 9 | 10 | 11 |

{children}
12 | 13 |
14 | 15 | version: {VERSION}, built: {BUILD_DATE} 16 | 17 |
18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/web/src/screens/Logout.tsx: -------------------------------------------------------------------------------- 1 | import { useSignals } from "@preact/signals-react/runtime"; 2 | import { useEffect } from "react"; 3 | import { Navigate /*, useNavigate*/ } from "react-router"; 4 | import { routes } from "../components/Router"; 5 | import { useStore } from "../hooks/store"; 6 | 7 | const RawLogoutScreen: React.FC = () => { 8 | useSignals(); 9 | const { auth } = useStore(); 10 | 11 | useEffect(() => { 12 | auth.logout(); 13 | }, []); 14 | 15 | return ( 16 | <> 17 | 18 | 19 | ); 20 | }; 21 | 22 | export { RawLogoutScreen as LogoutScreen }; 23 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/yahoo.ts: -------------------------------------------------------------------------------- 1 | import { type YahooTickerSearchResult } from "@darkruby/assets-core"; 2 | import { type HandlerTask } from "@darkruby/fp-express"; 3 | import * as TE from "fp-ts/TaskEither"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import { stringFromUrl } from "../decoders/params"; 6 | import { mapWebError } from "../domain/error"; 7 | import type { Context } from "./context"; 8 | 9 | export const yahooSearch: HandlerTask = ({ 10 | params: [req], 11 | context: { yahooApi }, 12 | }) => 13 | pipe(stringFromUrl(req.query.term), TE.chain(yahooApi.search), mapWebError); 14 | -------------------------------------------------------------------------------- /packages/web/assets/linechart-selected 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/assets-core/test/metadecoder.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import * as E from "fp-ts/lib/Either"; 3 | import { formatValidationErrors } from "io-ts-reporters"; 4 | import { ChartMetaDecoder } from "../src/decoders/yahoo/meta"; 5 | import { readJsonFiles } from "./helper"; 6 | 7 | const files = readJsonFiles("./data/meta"); 8 | 9 | files.forEach(([fname, json]) => { 10 | test(`Decode ${fname} meta`, async () => { 11 | const result = ChartMetaDecoder.decode(json); 12 | if (E.isLeft(result)) { 13 | console.error(formatValidationErrors(result.left)); 14 | } 15 | expect(E.isRight(result)).toBe(true); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/backend/src/decoders/params.ts: -------------------------------------------------------------------------------- 1 | import { UserIdDecoder } from "@darkruby/assets-core"; 2 | import { liftTE } from "@darkruby/assets-core/src/decoders/util"; 3 | import { RangeDecoder } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import * as t from "io-ts"; 6 | import { NumberFromString, withFallback } from "io-ts-types"; 7 | 8 | export const numberFromUrl = pipe(NumberFromString, liftTE); 9 | export const stringFromUrl = pipe(t.string, liftTE); 10 | export const rangeFromUrl = pipe(withFallback(RangeDecoder, "1d"), liftTE); 11 | 12 | export const userIdFromUrl = pipe(NumberFromString.pipe(UserIdDecoder), liftTE); 13 | -------------------------------------------------------------------------------- /packages/web/assets/linechart-default 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/web/src/hooks/formData.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export const usePartialState = >(data: T) => { 4 | const [state, setState] = useState(data); 5 | const setField = 6 | (key: K) => 7 | (val: T[K]) => 8 | setState({ ...state, [key]: val }); 9 | return [state, setField, setState] as const; 10 | }; 11 | 12 | export const usePartialChange = >( 13 | data: T, 14 | onChange: (t: T) => any 15 | ) => { 16 | const handlePartialChange = 17 | (key: K) => 18 | (val: T[K]) => 19 | onChange({ ...data, [key]: val }); 20 | return handlePartialChange; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/web/src/components/Tx/Menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dropdown, DropdownButton } from "react-bootstrap"; 3 | 4 | type TxMenuProps = { 5 | onEdit: () => void; 6 | onDelete: () => void; 7 | }; 8 | 9 | export const TxMenu: React.FC = ({ 10 | onDelete, 11 | onEdit, 12 | }: TxMenuProps) => { 13 | return ( 14 | ...}> 15 | 16 | Edit 17 | 18 | 19 | 20 | Delete 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/transaction.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { withFallback } from "io-ts-types"; 3 | import { dateDecoder } from "./util"; 4 | 5 | const baseTxTypes = { 6 | type: t.union([t.literal("buy"), t.literal("sell")]), 7 | quantity: t.number, 8 | price: t.number, 9 | date: dateDecoder, 10 | comments: withFallback(t.string, ""), 11 | }; 12 | 13 | const extTxTypes = { 14 | ...baseTxTypes, 15 | id: t.number, 16 | asset_id: t.number, 17 | created: dateDecoder, 18 | modified: dateDecoder, 19 | }; 20 | 21 | export const PostTxDecoder = t.type(baseTxTypes); 22 | export const GetTxDecoder = t.type(extTxTypes); 23 | export const GetTxsDecoder = t.array(GetTxDecoder); 24 | -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ModalFooter } from "react-bootstrap"; 3 | import { PrimaryButton, SecondaryButton } from "../Form/FormControl"; 4 | 5 | type ConfirmationModalFooterProps = { 6 | onOk: () => void; 7 | onCancel: () => void; 8 | disabled?: boolean; 9 | }; 10 | 11 | export const ConfirmationModalFooter: React.FC< 12 | ConfirmationModalFooterProps 13 | > = ({ onOk, onCancel, disabled }) => { 14 | return ( 15 | 16 | 17 | OK 18 | 19 | Cancel 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/backend/test/prefs.spec.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@darkruby/assets-core"; 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; 3 | import { fakePrefs, nonAdminApi, type TestApi } from "./helper"; 4 | 5 | let api: TestApi; 6 | beforeAll(async () => { 7 | api = await run(nonAdminApi()); 8 | }); 9 | afterAll(async () => { 10 | await run(api.profile.delete()); 11 | }); 12 | 13 | test("Get prefs", async () => { 14 | const { base_ccy } = await run(api.prefs.get()); 15 | expect(base_ccy).toBeString(); 16 | }); 17 | 18 | test("Update prefs", async () => { 19 | const prefs = fakePrefs(); 20 | const { base_ccy } = await run(api.prefs.update(prefs)); 21 | expect(base_ccy).toBe(prefs.base_ccy); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/web/src/screens/Users.tsx: -------------------------------------------------------------------------------- 1 | import { useSignals } from "@preact/signals-react/runtime"; 2 | import { useEffect } from "react"; 3 | import { Users } from "../components/Users/Users"; 4 | import { useStore } from "../hooks/store"; 5 | 6 | const RawUsersScreen: React.FC = () => { 7 | useSignals(); 8 | const { users } = useStore(); 9 | 10 | useEffect(() => { 11 | users.load(); 12 | }, [users]); 13 | 14 | return ( 15 | 23 | ); 24 | }; 25 | 26 | export { RawUsersScreen as UsersScreen }; 27 | -------------------------------------------------------------------------------- /packages/web/src/components/Portfolio/Menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dropdown, DropdownButton } from "react-bootstrap"; 3 | 4 | type PortfolioMenuProps = { 5 | onEdit: () => void; 6 | onDelete: () => void; 7 | }; 8 | 9 | export const PortfolioMenu: React.FC = ({ 10 | onDelete, 11 | onEdit, 12 | }: PortfolioMenuProps) => { 13 | return ( 14 | }> 15 | 16 | Edit 17 | 18 | 19 | 20 | Delete 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /.migrations/002_portfolio_view.up.sql: -------------------------------------------------------------------------------- 1 | -- tables 2 | ALTER TABLE transactions 3 | ADD COLUMN comments text DEFAULT ""; 4 | 5 | -- views 6 | drop view if exists portfolios_ext; 7 | CREATE VIEW portfolios_ext as 8 | select 9 | p.*, 10 | coalesce(a.total_invested, 0) as total_invested, 11 | coalesce(a.num_assets, 0) as num_assets, 12 | coalesce(total_invested / sum(total_invested) over (partition by p.user_id), 0) as contribution 13 | from 14 | portfolios p 15 | left join ( 16 | select 17 | portfolio_id, 18 | sum(invested) as total_invested, 19 | count(id) as num_assets 20 | from 21 | asset_holdings 22 | group by 23 | portfolio_id 24 | ) as a on p.id = a.portfolio_id; -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "target": "ESNext", 8 | "module": "ESNext", 9 | "moduleDetection": "force", 10 | "jsx": "react-jsx", 11 | "allowJs": true, 12 | // Bundler mode 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "verbatimModuleSyntax": true, 16 | "noEmit": true, 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // Some stricter flags (disabled by default) 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noPropertyAccessFromIndexSignature": false 25 | } 26 | } -------------------------------------------------------------------------------- /packages/backend/test/summary.spec.ts: -------------------------------------------------------------------------------- 1 | import { run } from "@darkruby/assets-core"; 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; 3 | import { fakePortfolio, nonAdminApi, type TestApi } from "./helper"; 4 | 5 | let api: TestApi; 6 | beforeAll(async () => { 7 | api = await run(nonAdminApi()); 8 | }); 9 | afterAll(async () => { 10 | await run(api.profile.delete()); 11 | }); 12 | 13 | test("Get Summary", async () => { 14 | await run(api.portfolio.create(fakePortfolio())); 15 | await run(api.portfolio.create(fakePortfolio())); 16 | const { chart, meta, totals, value } = await run(api.summary.get()); 17 | expect(chart).toBeArray(); 18 | expect(meta).toBeTruthy(); 19 | expect(totals).toBeTruthy(); 20 | expect(value).toBeTruthy(); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/fp-express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "target": "ESNext", 8 | "module": "ESNext", 9 | "moduleDetection": "force", 10 | "jsx": "react-jsx", 11 | "allowJs": true, 12 | // Bundler mode 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "verbatimModuleSyntax": true, 16 | "noEmit": true, 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // Some stricter flags (disabled by default) 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noPropertyAccessFromIndexSignature": false 25 | } 26 | } -------------------------------------------------------------------------------- /packages/backend/src/services/prefs.ts: -------------------------------------------------------------------------------- 1 | import { PrefsDecoder, type Prefs, type UserId } from "@darkruby/assets-core"; 2 | import { liftTE } from "@darkruby/assets-core/src/decoders/util"; 3 | import type { WebAction } from "@darkruby/fp-express"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import * as TE from "fp-ts/TaskEither"; 6 | import { mapWebError } from "../domain/error"; 7 | import type { Repository } from "../repository"; 8 | 9 | const prefsDecoder = liftTE(PrefsDecoder); 10 | 11 | export const updatePrefs = 12 | (repo: Repository) => 13 | (userId: UserId, payload: unknown): WebAction => { 14 | return pipe( 15 | prefsDecoder(payload), 16 | TE.chain((prefs) => repo.prefs.update(userId, prefs)), 17 | mapWebError 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext", 6 | "DOM" 7 | ], 8 | "target": "ESNext", 9 | "module": "ESNext", 10 | "moduleDetection": "force", 11 | "jsx": "react-jsx", 12 | "allowJs": true, 13 | // Bundler mode 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "verbatimModuleSyntax": true, 17 | "noEmit": true, 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } -------------------------------------------------------------------------------- /packages/assets-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext", 6 | "DOM", 7 | ], 8 | "target": "ESNext", 9 | "module": "ESNext", 10 | "moduleDetection": "force", 11 | "jsx": "react-jsx", 12 | "allowJs": true, 13 | // Bundler mode 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "verbatimModuleSyntax": true, 17 | "noEmit": true, 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/yahoo/period.ts: -------------------------------------------------------------------------------- 1 | import type { Refinement } from "fp-ts/lib/Refinement"; 2 | import * as t from "io-ts"; 3 | 4 | export const UnixDateDecoder = t.brand( 5 | t.number, 6 | ((a) => a >= 0 && a == Math.floor(a)) as Refinement< 7 | number, 8 | t.Branded 9 | >, 10 | "UnixDate" 11 | ); 12 | 13 | const periodChangesTypes = { 14 | beginning: t.number, 15 | current: t.number, 16 | change: t.number, 17 | changePct: t.number, 18 | start: UnixDateDecoder, 19 | end: UnixDateDecoder, 20 | }; 21 | 22 | const totalsTypes = { 23 | change: t.number, 24 | changePct: t.number, 25 | }; 26 | 27 | export const PeriodChangesDecoder = t.type(periodChangesTypes); 28 | export const TotalsDecoder = t.type(totalsTypes); 29 | -------------------------------------------------------------------------------- /packages/web/src/hooks/prefs.ts: -------------------------------------------------------------------------------- 1 | import { ccyToLocale, defaultPrefs } from "@darkruby/assets-core"; 2 | import { decimal, money, percent } from "../util/number"; 3 | import { useStore } from "./store"; 4 | 5 | export const usePrefs = () => { 6 | const { prefs } = useStore(); 7 | return prefs.data.value ?? defaultPrefs(); 8 | }; 9 | 10 | export const useFormatters = () => { 11 | const { base_ccy } = usePrefs(); 12 | const locale = ccyToLocale(base_ccy); 13 | const prefMoney = (n: number) => money(n, base_ccy, locale); 14 | const prefDecimal = (n: number, prec = 2) => decimal(n, prec, locale); 15 | const prefPercent = (n: number, prec = 2) => percent(n, prec, locale); 16 | return { 17 | money: prefMoney, 18 | decimal: prefDecimal, 19 | percent: prefPercent, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/web/src/services/token.ts: -------------------------------------------------------------------------------- 1 | import { 2 | authError, 3 | type Result, 4 | type Token, 5 | TokenDecoder, 6 | } from "@darkruby/assets-core"; 7 | import * as E from "fp-ts/lib/Either"; 8 | import { pipe } from "fp-ts/lib/function"; 9 | import { storage } from "./storage"; 10 | 11 | const TOKEN_KEY = "assets_token"; 12 | const tokenStorage = storage(TokenDecoder); 13 | 14 | export const readToken = (): Result => { 15 | return pipe( 16 | tokenStorage.read(TOKEN_KEY), 17 | E.mapLeft(() => authError(`Could not read token`)) 18 | ); 19 | }; 20 | export const writeToken = (token: Token): Result => { 21 | return tokenStorage.write(TOKEN_KEY, token); 22 | }; 23 | export const removeToken = (): Result => { 24 | return tokenStorage.remove(TOKEN_KEY); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/web/src/decorators/admin.tsx: -------------------------------------------------------------------------------- 1 | import { validationError } from "@darkruby/assets-core"; 2 | import React, { useEffect } from "react"; 3 | import { useStore } from "../hooks/store"; 4 | import { Error } from "./errors"; 5 | import type { Props } from "./fetching"; 6 | 7 | export function withAdminRestriction

( 8 | Component: React.FC

9 | ): React.FC

{ 10 | return (props: P) => { 11 | const { profile } = useStore(); 12 | useEffect(() => { 13 | profile.load(); 14 | }, [profile.data]); 15 | 16 | return profile.data.value?.admin ? ( 17 | 18 | ) : ( 19 | 24 | ); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | # docker buildx build -t assets-test -f ./Dockerfile.test . 2 | # docker run -it assets-test 3 | 4 | FROM golang:1.24 AS migrate 5 | WORKDIR /app 6 | COPY .migrations /app/.migrations 7 | RUN go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 8 | RUN /go/bin/migrate -path .migrations -database=sqlite3://assets.db up 9 | 10 | FROM oven/bun:1.3 AS builder 11 | WORKDIR /app 12 | COPY . . 13 | COPY --from=migrate /app/assets.db ./assets.db 14 | RUN bun install 15 | RUN bun run check 16 | 17 | ENV ASSETS_APP="./public" 18 | ENV ASSETS_PORT=4020 19 | ENV ASSETS_DB=/app/assets.db 20 | ENV ASSETS_JWT_SECRET=secret 21 | ENV ASSETS_JWT_EXPIRES_IN=7d 22 | ENV ASSETS_JWT_REFRESH_BEFORE=6d 23 | 24 | CMD ["sh", "-c", "bun run backend:dev & echo $! >/tmp/backend.pid && sleep 1 && bun test"] 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 AS migrate 2 | RUN go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 3 | 4 | FROM oven/bun:1.3 AS builder 5 | WORKDIR /app 6 | COPY . . 7 | RUN bun install 8 | RUN bun run check 9 | RUN bun run build 10 | 11 | FROM debian:bookworm-slim AS runner 12 | WORKDIR /app 13 | COPY --from=migrate /go/bin/migrate /usr/sbin/ 14 | COPY --from=builder /app/node_modules /app/node_modules 15 | COPY --from=builder /app/dist/public /app/public 16 | COPY --from=builder /app/dist/backend /app/backend 17 | COPY --from=builder /app/.migrations /app/.migrations 18 | RUN apt update 19 | RUN apt install -y ca-certificates 20 | 21 | ENV ASSETS_APP="./public" 22 | ENV ASSETS_PORT=4020 23 | 24 | CMD ["sh", "-c", "migrate -verbose -path .migrations -database=sqlite3://$ASSETS_DB up && ./backend"] 25 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/prefs.ts: -------------------------------------------------------------------------------- 1 | import { type Prefs } from "@darkruby/assets-core"; 2 | import { type HandlerTask } from "@darkruby/fp-express"; 3 | import * as TE from "fp-ts/TaskEither"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import { mapWebError } from "../domain/error"; 6 | import type { Context } from "./context"; 7 | 8 | export const getPrefs: HandlerTask = ({ 9 | params: [, res], 10 | context: { repo, service }, 11 | }) => 12 | pipe(service.auth.requireUserId(res), TE.chain(repo.prefs.get), mapWebError); 13 | 14 | export const updatePrefs: HandlerTask = ({ 15 | params: [req, res], 16 | context: { repo, service }, 17 | }) => 18 | pipe( 19 | service.auth.requireUserId(res), 20 | mapWebError, 21 | TE.chain((userId) => service.prefs.update(userId, req.body)) 22 | ); 23 | -------------------------------------------------------------------------------- /packages/web/src/stores/prefs.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | Identity, 4 | Nullable, 5 | Prefs, 6 | } from "@darkruby/assets-core"; 7 | import { signal } from "@preact/signals-react"; 8 | import { getPrefs, updatePrefs } from "../services/prefs"; 9 | import { type StoreBase, createStoreBase } from "./base"; 10 | 11 | export type PrefsStore = Identity< 12 | StoreBase> & { 13 | load: () => ActionResult>; 14 | update: (c: Prefs) => ActionResult>; 15 | } 16 | >; 17 | 18 | export const createPrefsStore = (): PrefsStore => { 19 | const data = signal>(null); 20 | const storeBase = createStoreBase(data); 21 | 22 | return { 23 | ...storeBase, 24 | load: () => storeBase.run(getPrefs()), 25 | update: (p: Prefs) => storeBase.run(updatePrefs(p)), 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/web/src/util/number.ts: -------------------------------------------------------------------------------- 1 | import type { Ccy } from "@darkruby/assets-core"; 2 | 3 | export const money = ( 4 | number: number, 5 | currency: Ccy, 6 | locale: Intl.LocalesArgument 7 | ): string => { 8 | return new Intl.NumberFormat(locale, { 9 | style: "currency", 10 | currency, 11 | }).format(number); 12 | }; 13 | 14 | export const decimal = ( 15 | value: number, 16 | prec = 2, 17 | locale: Intl.LocalesArgument 18 | ): string => { 19 | return Intl.NumberFormat(locale, { 20 | style: "decimal", 21 | maximumFractionDigits: prec, 22 | }).format(value); 23 | }; 24 | 25 | export const percent = ( 26 | value: number, 27 | prec = 2, 28 | locale: Intl.LocalesArgument 29 | ): string => { 30 | return Intl.NumberFormat(locale, { 31 | style: "percent", 32 | maximumFractionDigits: prec, 33 | }).format(value); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/web/assets/menu 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/asset.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import { Ord as ordNumber } from "fp-ts/lib/number"; 3 | import { contramap, reverse, type Ord } from "fp-ts/lib/Ord"; 4 | import * as t from "io-ts"; 5 | import type { 6 | EnrichedAssetDecoder, 7 | GetAssetDecoder, 8 | PostAssetDecoder, 9 | } from "../decoders/asset"; 10 | 11 | export type PostAsset = t.TypeOf; 12 | export type GetAsset = t.TypeOf; 13 | 14 | export type EnrichedAsset = t.TypeOf; 15 | 16 | export const defaultAsset = (): PostAsset => ({ name: "", ticker: "" }); 17 | 18 | export const byAssetChangePct: Ord = pipe( 19 | ordNumber, 20 | reverse, 21 | contramap((a) => a.value.ccy.changePct) 22 | ); 23 | 24 | export type AssetId = GetAsset["id"]; 25 | -------------------------------------------------------------------------------- /packages/web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /packages/web/src/components/Asset/Menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dropdown, DropdownButton } from "react-bootstrap"; 3 | 4 | type AssetMenuProps = { 5 | onEdit: () => void; 6 | onDelete: () => void; 7 | onAddTx: () => void; 8 | }; 9 | 10 | export const AssetMenu: React.FC = ({ 11 | onEdit, 12 | onDelete, 13 | onAddTx, 14 | }: AssetMenuProps) => { 15 | return ( 16 | }> 17 | 18 | Edit 19 | 20 | 21 | Add Transaction 22 | 23 | 24 | 25 | Delete 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/web/src/stores/summary.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | Identity, 4 | Nullable, 5 | Summary, 6 | } from "@darkruby/assets-core"; 7 | import { signal } from "@preact/signals-react"; 8 | 9 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 10 | import { getSummary } from "../services/summary"; 11 | import { type StoreBase, createStoreBase } from "./base"; 12 | 13 | export type SummaryStore = Identity< 14 | StoreBase> & { 15 | load: (range?: ChartRange) => ActionResult>; 16 | } 17 | >; 18 | 19 | export const createSummaryStore = (): SummaryStore => { 20 | const data = signal>(null); 21 | const storeBase = createStoreBase(data); 22 | 23 | return { 24 | ...storeBase, 25 | load: (range?: ChartRange) => storeBase.run(getSummary(range)), 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/web/src/components/Layout/AuthRouteWrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/Either"; 2 | import { Suspense, use } from "react"; 3 | import { Spinner } from "react-bootstrap"; 4 | import { Navigate, Outlet } from "react-router"; 5 | import { type Store } from "../../stores/store"; 6 | import { routes } from "../Router"; 7 | import { AppLayout } from "./AppLayout"; 8 | 9 | export const AuthRouteWrapper: React.FC<{ store: Store }> = ({ store }) => { 10 | const { auth } = store; 11 | const load = auth.load(); 12 | 13 | const SuspendedComponent = () => { 14 | const token = use(load); 15 | if (E.isRight(token)) return ; 16 | return ; 17 | }; 18 | 19 | return ( 20 | 21 | }> 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/assets-core/test/helper.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, readdirSync } from "node:fs"; 2 | import path from "node:path"; 3 | 4 | export const readJsonFiles = ( 5 | folderPath: string 6 | ): (readonly [fname: string, data: any])[] => { 7 | const folderFullPath = path.resolve(__dirname, folderPath); 8 | const files = readdirSync(folderFullPath); 9 | const jsonFiles = files.filter((file) => path.extname(file) === ".json"); 10 | const jsonObjects = jsonFiles.map((file) => { 11 | const filePath = path.join(folderFullPath, file); 12 | const jsonAsString = readFileSync(filePath).toString(); 13 | return [path.basename(filePath), JSON.parse(jsonAsString)] as const; 14 | }); 15 | return jsonObjects; 16 | }; 17 | 18 | export const readJson = (p: string): any => { 19 | const jsonAsString = readFileSync(path.resolve(__dirname, p)).toString(); 20 | return JSON.parse(jsonAsString); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/backend/src/domain/error.ts: -------------------------------------------------------------------------------- 1 | import { AppErrorType, type AppError } from "@darkruby/assets-core"; 2 | import { 3 | authError, 4 | badRequest, 5 | generalError, 6 | WebErrorType, 7 | type WebAppError, 8 | } from "@darkruby/fp-express"; 9 | import * as TE from "fp-ts/lib/TaskEither"; 10 | 11 | export const toWebError = (err: AppError): WebAppError => { 12 | switch (err.type) { 13 | case AppErrorType.Auth: 14 | return authError(err); 15 | case AppErrorType.Validation: 16 | return badRequest(err); 17 | case AppErrorType.General: 18 | default: 19 | return generalError(err); 20 | } 21 | }; 22 | 23 | export const mapWebError = TE.mapLeft(toWebError); 24 | 25 | export const handleWebError = 26 | (msg: string = "", type: WebErrorType = WebErrorType.General) => 27 | (e: unknown): WebAppError => ({ 28 | message: `${msg}: ${e}`, 29 | type, 30 | }); 31 | -------------------------------------------------------------------------------- /packages/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { format } from "date-fns"; 3 | import { readFileSync } from "node:fs"; 4 | import path from "node:path"; 5 | import { defineConfig } from "vite"; 6 | 7 | const ROOT = path.resolve(__dirname, "../../"); 8 | const PACKAGE_JSON = path.resolve(ROOT, "./package.json"); 9 | const NODE_MODULES = path.resolve(ROOT, "./node_modules/"); 10 | const OUTDIR = path.resolve(ROOT, "dist/public/"); 11 | 12 | const version = JSON.parse(readFileSync(PACKAGE_JSON).toString()).version; 13 | 14 | export default defineConfig({ 15 | plugins: [react()], 16 | base: "/app", 17 | build: { 18 | emptyOutDir: true, 19 | outDir: OUTDIR, 20 | assetsDir: "assets", 21 | }, 22 | resolve: { 23 | alias: { "~": NODE_MODULES }, 24 | }, 25 | define: { 26 | VERSION: JSON.stringify(version), 27 | BUILD_DATE: JSON.stringify(format(new Date(), "dd-MM-yyyy")), 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /packages/web/src/services/env.ts: -------------------------------------------------------------------------------- 1 | import { handleError, type Nullable } from "@darkruby/assets-core"; 2 | import * as E from "fp-ts/lib/Either"; 3 | import { pipe } from "fp-ts/lib/function"; 4 | 5 | export const env = (name: string, defaultValue?: string) => 6 | pipe( 7 | E.tryCatch(() => { 8 | const val = import.meta.env[name] ?? defaultValue; 9 | if (val !== undefined) return val; 10 | throw Error(`${name} is not defined`); 11 | }, handleError("env")) 12 | ); 13 | 14 | export const envNumber = ( 15 | name: string, 16 | defaultValue: Nullable = null 17 | ) => 18 | pipe( 19 | env(name, defaultValue?.toString()), 20 | E.chain((s) => E.tryCatch(() => parseFloat(s), handleError("parseFloat"))) 21 | ); 22 | 23 | export const envBoolean = ( 24 | name: string, 25 | defaultValue: Nullable = null 26 | ) => 27 | pipe( 28 | env(name, defaultValue?.toString()), 29 | E.map((s) => s.toLowerCase().trim() === "true") 30 | ); 31 | -------------------------------------------------------------------------------- /packages/web/src/components/Tx/TickerLookup.scss: -------------------------------------------------------------------------------- 1 | //

2 | //
3 | //
...
4 | //
...
5 | //
6 | //
7 | //
8 | //
...
9 | //
10 | //
11 | //
12 | 13 | @import "bootstrap/scss/_functions"; 14 | @import "bootstrap/scss/_variables"; 15 | 16 | .ticker-lookup-container { 17 | .ticker-lookup__control { 18 | .ticker-lookup__input-container, 19 | .ticker-lookup__single-value { 20 | color: lightgray; 21 | } 22 | background-color: $dropdown-dark-bg; 23 | } 24 | .ticker-lookup__option { 25 | background-color: $dropdown-dark-bg; 26 | &:hover { 27 | background-color: lighten($dropdown-dark-bg, 15%); 28 | } 29 | color: $dropdown-dark-color; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/web/src/services/profile.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Action, 3 | GetUser, 4 | Id, 5 | PasswordChange, 6 | PostUser, 7 | } from "@darkruby/assets-core"; 8 | import { pipe } from "fp-ts/lib/function"; 9 | import * as TE from "fp-ts/lib/TaskEither"; 10 | import { apiFromToken } from "./api"; 11 | 12 | export const getProfile = (): Action => { 13 | return pipe( 14 | apiFromToken, 15 | TE.chain(({ profile }) => profile.get()) 16 | ); 17 | }; 18 | 19 | export const deleteProfile = (): Action => { 20 | return pipe( 21 | apiFromToken, 22 | TE.chain(({ profile }) => profile.delete()) 23 | ); 24 | }; 25 | 26 | export const updateProfile = (usr: PostUser): Action => { 27 | return pipe( 28 | apiFromToken, 29 | TE.chain(({ profile }) => profile.update(usr)) 30 | ); 31 | }; 32 | 33 | export const updatePassword = (c: PasswordChange): Action => { 34 | return pipe( 35 | apiFromToken, 36 | TE.chain(({ profile }) => profile.password(c)) 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/web/src/App.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/scss/_functions"; 2 | @import "bootstrap/scss/_variables"; 3 | 4 | html, 5 | body { 6 | min-height: 100vh; 7 | width: 100%; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | $breakpoint-phone: 600px; 13 | 14 | @mixin flex-container-row { 15 | display: flex; 16 | flex-direction: row; 17 | align-items: flex-start; 18 | column-gap: 0.4em; 19 | } 20 | 21 | #root { 22 | padding-top: 1rem; 23 | & > .container { 24 | min-height: 100vh; 25 | } 26 | } 27 | 28 | a { 29 | text-decoration: none; 30 | color: $body-color-dark; 31 | } 32 | 33 | .spread-container { 34 | @include flex-container-row(); 35 | justify-content: space-between; 36 | .stick-left { 37 | @include flex-container-row(); 38 | justify-content: flex-start; 39 | } 40 | .stick-right { 41 | @include flex-container-row(); 42 | justify-content: flex-end; 43 | } 44 | } 45 | 46 | @media (max-width: $breakpoint-phone) { 47 | .description.h6 { 48 | font-size: 0.75rem; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/backend/src/services/init.ts: -------------------------------------------------------------------------------- 1 | import { authError } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import type { Repository } from "../repository"; 5 | import { env } from "./env"; 6 | import { toRawInUser } from "./user"; 7 | 8 | const defaultUser = pipe( 9 | TE.Do, 10 | TE.apS("username", env("ASSETS_USERNAME", "admin")), 11 | TE.apS("password", env("ASSETS_PASSWORD", "admin")), 12 | TE.apS("admin", TE.of(true)), 13 | TE.apS("locked", TE.of(false)), 14 | TE.chain(toRawInUser) 15 | ); 16 | 17 | export const initializeApp = (repo: Repository) => { 18 | // check to see if first user exists; 19 | // if not create with default values 20 | return pipe( 21 | TE.Do, 22 | TE.bind("users", () => repo.user.getAll()), 23 | TE.filterOrElse( 24 | ({ users }) => users.length > 0, 25 | () => authError("Admin user not found") 26 | ), 27 | TE.orElseW(() => pipe(defaultUser, TE.chain(repo.user.create))) 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/assets-core/test/chartdecoder.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import * as E from "fp-ts/lib/Either"; 3 | import { formatValidationErrors } from "io-ts-reporters"; 4 | import { YahooChartDataDecoder } from "../src/decoders/yahoo/chart"; 5 | import { readJsonFiles } from "./helper"; 6 | 7 | const files = readJsonFiles("./data"); 8 | 9 | files.forEach(([fname, json]) => { 10 | test(`Decode ${fname} chart`, async () => { 11 | const result = YahooChartDataDecoder.decode(json); 12 | if (E.isLeft(result)) { 13 | console.error(formatValidationErrors(result.left)); 14 | } 15 | expect(E.isRight(result)).toBe(true); 16 | }); 17 | }); 18 | 19 | const err = readJsonFiles("./data/error"); 20 | 21 | err.forEach(([fname, json]) => { 22 | test(`Decode ${fname} error chart`, async () => { 23 | const result = YahooChartDataDecoder.decode(json); 24 | expect(E.isLeft(result) && result.left[0].message).toBe( 25 | "Not Found - No data found, symbol may be delisted" 26 | ); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/portfolio.ts: -------------------------------------------------------------------------------- 1 | import { contramap, reverse, type Ord } from "fp-ts/lib/Ord"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import { Ord as ordNumber } from "fp-ts/lib/number"; 4 | import * as t from "io-ts"; 5 | import type { 6 | EnrichedPortfolioDecoder, 7 | GetPortfolioDecoder, 8 | PortfolioMetaDecoder, 9 | PostPortfolioDecoder, 10 | } from "../decoders/portfolio"; 11 | 12 | export type PostPortfolio = t.TypeOf; 13 | export type GetPortfolio = t.TypeOf; 14 | 15 | export type PortfolioMeta = t.TypeOf; 16 | 17 | export type EnrichedPortfolio = t.TypeOf; 18 | 19 | export const defaultPortfolio = (): PostPortfolio => ({ 20 | name: "", 21 | description: "", 22 | }); 23 | 24 | export const byPortfolioChangePct: Ord = pipe( 25 | ordNumber, 26 | reverse, 27 | contramap((p) => p.value.changePct) 28 | ); 29 | 30 | export type PortfolioId = GetPortfolio["id"]; 31 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@darkruby/assets-backend", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "type": "module", 6 | "scripts": { 7 | "test": "bun test", 8 | "dev": "bun --watch run src/index.ts", 9 | "check": "tsc --noEmit", 10 | "build": "bun build src/index.ts --compile --outfile=../../dist/backend" 11 | }, 12 | "dependencies": { 13 | "@darkruby/assets-core": "workspace:*", 14 | "@darkruby/fp-express": "workspace:*", 15 | "@types/ms": "^2.1.0", 16 | "bcrypt": "^5.1.1", 17 | "cors": "^2.8.5", 18 | "express": "~4.21.2", 19 | "io-ts": "~2.2.21", 20 | "jsonwebtoken": "^9.0.2", 21 | "lru-cache": "^11.0.2" 22 | }, 23 | "devDependencies": { 24 | "@types/cors": "^2.8.17", 25 | "@types/faker": "5", 26 | "@types/jsonwebtoken": "^9.0.9", 27 | "@types/lru-cache": "^7.10.10", 28 | "@types/bcrypt": "^5.0.2", 29 | "@types/express": "^5.0.3", 30 | "aws-sdk": "^2.1692.0", 31 | "faker": "5", 32 | "mock-aws-s3": "^4.0.2", 33 | "nock": "^14.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@darkruby/assets-ui", 3 | "private": true, 4 | "version": "1.3.2", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host 0.0.0.0", 8 | "build": "tsc -b && vite build", 9 | "check": "tsc -noEmit", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@darkruby/assets-core": "workspace:*", 14 | "@preact/signals-react": "^3.0.1", 15 | "bootstrap": "^5.3.3", 16 | "jose": "^6.0.11", 17 | "react": "^19.0.0", 18 | "react-bootstrap": "^2.10.9", 19 | "react-dom": "^19.0.0", 20 | "react-router": "^7.3.0", 21 | "react-select": "^5.10.1", 22 | "io-ts": "^2.2.22" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "^9.21.0", 26 | "@types/react": "^19.0.10", 27 | "@types/react-dom": "^19.0.4", 28 | "@vitejs/plugin-react": "^4.3.4", 29 | "eslint": "^9.21.0", 30 | "eslint-plugin-react-hooks": "^5.1.0", 31 | "eslint-plugin-react-refresh": "^0.4.19", 32 | "globals": "^15.15.0", 33 | "typescript-eslint": "^8.24.1", 34 | "vite": "^6.2.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/assets-core/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either"; 2 | import * as TE from "fp-ts/lib/TaskEither"; 3 | import { handleError, type AppError } from "../domain/error"; 4 | 5 | export type Nullable = T | null; 6 | export type Optional = Nullable | undefined; 7 | export type Identity = { [P in keyof T]: T[P] }; 8 | export type Replace = Identity< 9 | Omit & { [key in K]: R } 10 | >; 11 | 12 | export type Result = E.Either; 13 | export type ActionResult = Promise>; 14 | export type Action = TE.TaskEither; 15 | 16 | export const run = async
(test: Action) => { 17 | const result = await test(); 18 | if (E.isLeft(result)) { 19 | throw new Error(result.left.message); 20 | } 21 | return result.right; 22 | }; 23 | 24 | export type ArrayItem = A extends (infer X)[] ? X : never; 25 | 26 | export const trySync = (f: () => A) => 27 | TE.tryCatch(async () => f(), handleError()); 28 | 29 | export const tryAsync = (f: () => Promise) => 30 | TE.tryCatch(f, handleError()); 31 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Password.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import { Button, InputGroup } from "react-bootstrap"; 4 | import { FormEdit, FormPassword } from "./FormControl"; 5 | 6 | export type PasswordEditProps = { 7 | value: string; 8 | onChange: (s: string) => void; 9 | disabled?: boolean; 10 | }; 11 | 12 | export const PasswordEdit: React.FC = ({ 13 | value, 14 | onChange, 15 | disabled, 16 | }: PasswordEditProps) => { 17 | const [visible, setVisible] = useState(false); 18 | const flip = () => setVisible((x) => !x); 19 | return ( 20 | 21 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/assets-core/src/services/yahoo.ts: -------------------------------------------------------------------------------- 1 | import * as A from "fp-ts/lib/Array"; 2 | import * as O from "fp-ts/lib/Option"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import type { Ccy } from "../decoders"; 6 | import { generalError } from "../domain"; 7 | import { yahooApi } from "../http"; 8 | import type { Action } from "../utils/utils"; 9 | 10 | export const baseCcyConversionRate = ( 11 | ccy: string, 12 | base: Ccy 13 | ): Action => { 14 | if (ccy === base) return TE.of(1); 15 | const term = `${base}/${ccy}`; 16 | return pipe( 17 | yahooApi.search(term), //eg gbpusd 18 | TE.map((a) => A.head(a.quotes)), 19 | TE.chain((a) => { 20 | if (O.isSome(a)) return TE.of(a.value.symbol); 21 | return TE.left(generalError(`${term} is not convertible`)); 22 | }), 23 | TE.chain(yahooApi.chart), 24 | TE.map(({ meta }) => meta.regularMarketPrice), 25 | TE.map((price) => { 26 | // if price in pence adjust accordingly 27 | if (ccy === "GBp") return price * 100; 28 | return price; 29 | }) 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Confirmation.tsx: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import { Modal, ModalBody, ModalHeader } from "react-bootstrap"; 3 | import { 4 | createDialog, 5 | type DialogDrivenComponentProps, 6 | } from "../../util/modal"; 7 | import { ConfirmationModalFooter } from "./Footer"; 8 | 9 | export type ConfirmationModalProps = DialogDrivenComponentProps< 10 | boolean, 11 | { 12 | text: string; 13 | } 14 | >; 15 | 16 | export const ConfirmationModal: React.FC = ({ 17 | text, 18 | open, 19 | onSubmit, 20 | onClose, 21 | }) => { 22 | const handleOk = () => onSubmit(true); 23 | const handleCancel = () => onClose(); 24 | return ( 25 | 26 | Confirm 27 | {text} 28 | 29 | 30 | ); 31 | }; 32 | 33 | export const confirmationModal = (text: string) => 34 | pipe( 35 | { value: false, text }, 36 | createDialog(ConfirmationModal) 37 | ); 38 | -------------------------------------------------------------------------------- /packages/web/src/components/Totals/Totals.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | PeriodChanges, 3 | Totals as TotalsData, 4 | } from "@darkruby/assets-core"; 5 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 6 | import * as React from "react"; 7 | import { useFormatters } from "../../hooks/prefs"; 8 | import { MoneyAndChangeIndicator } from "../Badge/Badges"; 9 | import "./Totals.scss"; 10 | 11 | type TotalsProps = { 12 | totals: TotalsData; 13 | change: PeriodChanges; 14 | range: ChartRange; 15 | }; 16 | 17 | export const Totals: React.FC = ({ 18 | totals, 19 | change, 20 | range, 21 | }: TotalsProps) => { 22 | const { money } = useFormatters(); 23 | 24 | return ( 25 |
26 |

{money(change.current)}

27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/web/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Action, 3 | Api, 4 | AppError, 5 | Credentials, 6 | Token, 7 | } from "@darkruby/assets-core"; 8 | import { api as coreApi, login as coreLogin } from "@darkruby/assets-core"; 9 | import { pipe } from "fp-ts/lib/function"; 10 | import * as TE from "fp-ts/lib/TaskEither"; 11 | import { env } from "./env"; 12 | import { readToken, removeToken, writeToken } from "./token"; 13 | 14 | export const baseUrl = (): Action => { 15 | return pipe(env("VITE_ASSETS_URL", ""), TE.fromEither); 16 | }; 17 | 18 | export const login = (creds: Credentials): Action => { 19 | return pipe( 20 | baseUrl(), 21 | TE.chain((url) => coreLogin(url)(creds)), 22 | TE.tap((token) => pipe(writeToken(token), TE.fromEither)) 23 | ); 24 | }; 25 | 26 | export const logout = (): Action => { 27 | return pipe( 28 | removeToken(), 29 | TE.fromEither, 30 | TE.map(() => null) 31 | ); 32 | }; 33 | 34 | export const api = (token: Token): Action => 35 | pipe(baseUrl(), TE.map(coreApi), TE.ap(TE.of(token))); 36 | 37 | export const apiFromToken = pipe(readToken, TE.fromIOEither, TE.chain(api)); 38 | -------------------------------------------------------------------------------- /packages/web/src/screens/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { useSignals } from "@preact/signals-react/runtime"; 2 | import { use, useEffect } from "react"; 3 | import { UserProfile } from "../components/Profile/Profile"; 4 | import { StoreContext } from "../hooks/store"; 5 | 6 | const RawProfileScreen: React.FC = () => { 7 | useSignals(); 8 | const { profile, prefs, auth } = use(StoreContext); 9 | 10 | const handlePasswordUpdate = profile.password; 11 | const handlePrefsUpdate = prefs.update; 12 | const handleProfileDelete = () => { 13 | profile.delete(); 14 | auth.logout(); 15 | }; 16 | 17 | useEffect(() => { 18 | profile.load(); 19 | prefs.load(); 20 | }, [profile, prefs]); 21 | 22 | return ( 23 | 33 | ); 34 | }; 35 | 36 | export { RawProfileScreen as ProfileScreen }; 37 | -------------------------------------------------------------------------------- /packages/web/src/services/users.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Action, 3 | GetUser, 4 | Id, 5 | NewUser, 6 | PostUser, 7 | Profile, 8 | UserId, 9 | } from "@darkruby/assets-core"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { apiFromToken } from "./api"; 13 | 14 | export const getUsers = (): Action => { 15 | return pipe( 16 | apiFromToken, 17 | TE.chain(({ user }) => user.getMany()) 18 | ); 19 | }; 20 | 21 | export const getUser = (uid: UserId): Action => { 22 | return pipe( 23 | apiFromToken, 24 | TE.chain(({ user }) => user.get(uid)) 25 | ); 26 | }; 27 | 28 | export const createUser = (creds: NewUser): Action => { 29 | return pipe( 30 | apiFromToken, 31 | TE.chain(({ user }) => user.create(creds)) 32 | ); 33 | }; 34 | 35 | export const updateUser = (uid: UserId, creds: PostUser): Action => { 36 | return pipe( 37 | apiFromToken, 38 | TE.chain(({ user }) => user.update(uid, creds)) 39 | ); 40 | }; 41 | 42 | export const deleteUser = (uid: UserId): Action => { 43 | return pipe( 44 | apiFromToken, 45 | TE.chain(({ user }) => user.delete(uid)) 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /packages/web/src/services/txs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Action, 3 | type GetTx, 4 | type Id, 5 | type PostTx, 6 | } from "@darkruby/assets-core"; 7 | import { pipe } from "fp-ts/lib/function"; 8 | import * as TE from "fp-ts/lib/TaskEither"; 9 | import { apiFromToken } from "./api"; 10 | 11 | export const getTx = (aid: number, tid: number): Action => { 12 | return pipe( 13 | apiFromToken, 14 | TE.chain(({ tx: p }) => p.get(aid, tid)) 15 | ); 16 | }; 17 | export const getTxs = (aid: number): Action => { 18 | return pipe( 19 | apiFromToken, 20 | TE.chain(({ tx: p }) => p.getMany(aid)) 21 | ); 22 | }; 23 | 24 | export const createTx = (aid: number, t: PostTx): Action => { 25 | return pipe( 26 | apiFromToken, 27 | TE.chain(({ tx }) => tx.create(aid, t)) 28 | ); 29 | }; 30 | 31 | export const updateTx = ( 32 | aid: number, 33 | tid: number, 34 | t: PostTx 35 | ): Action => { 36 | return pipe( 37 | apiFromToken, 38 | TE.chain(({ tx }) => tx.update(tid, aid, t)) 39 | ); 40 | }; 41 | 42 | export const deleteTx = (aid: number, txId: number): Action => { 43 | return pipe( 44 | apiFromToken, 45 | TE.chain(({ tx }) => tx.delete(aid, txId)) 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /packages/web/src/components/Breadcrumb/Breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import type { GetAsset, GetPortfolio, Nullable } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import Breadcrumb from "react-bootstrap/Breadcrumb"; 4 | import { Link } from "react-router"; 5 | import { withVisibility } from "../../decorators/nodata"; 6 | import { routes } from "../Router"; 7 | 8 | type NavCrumbProps = { 9 | portfolio?: Nullable; 10 | asset?: Nullable; 11 | }; 12 | 13 | const NavCrumbItem = pipe(Breadcrumb.Item, withVisibility()); 14 | 15 | export const NavCrumb: React.FC = ({ portfolio, asset }) => { 16 | return ( 17 | 18 | ( 20 | {children} 21 | )} 22 | > 23 | Home 24 | 25 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /.gitea/workflows/dev-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: DEV DEPLOY 2 | run-name: ${{ gitea.actor }} is deploying code 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | env: 8 | description: "Environment to dispatch to" 9 | default: "raspi" 10 | 11 | jobs: 12 | build-and-test-code: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: SET UP SSH KEY 16 | run: | 17 | mkdir -p ~/.ssh 18 | echo "${{ secrets.DEPLOY_SSH_KEY }}" > /root/.ssh/id_ed25519 19 | chmod 600 /root/.ssh/id_ed25519 20 | ssh-keyscan -p 22 ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 21 | apt update 22 | apt install sshpass 23 | 24 | - name: DEPLOY TO SERVER 25 | run: | 26 | sshpass -p '${{ secrets.DEPLOY_PASSWORD }}' ssh -i /root/.ssh/id_ed25519 -v -o StrictHostKeyChecking=no ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} <<'ENDSSH' 27 | rm -rf ./repo ./service 28 | mkdir service 29 | git clone ${{ secrets.DEPLOY_REPO }} repo 30 | cp ./repo/docker-compose.yaml ./service/docker-compose.yaml 31 | cd ./service 32 | docker pull ${{ secrets.DEPLOY_IMAGE }}:latest 33 | docker compose down 34 | docker compose up -d 35 | ENDSSH 36 | -------------------------------------------------------------------------------- /.migrations/004_prefs.up.sql: -------------------------------------------------------------------------------- 1 | create table 2 | prefs ( 3 | id integer primary key autoincrement, 4 | user_id integer not null, 5 | -- prefs start here 6 | base_ccy text CHECK ( 7 | base_ccy IN ( 8 | 'USD', 9 | 'GBP', 10 | 'EUR', 11 | 'CAD', 12 | 'AUD', 13 | 'CHF', 14 | 'SEK', 15 | 'NOK', 16 | 'DKK', 17 | 'NZD', 18 | 'JPY' 19 | ) 20 | ) not null default 'USD', 21 | -- prefs end here 22 | created datetime default current_timestamp, 23 | modified datetime default current_timestamp, 24 | foreign key (user_id) references users (id) ON DELETE CASCADE, 25 | unique (user_id) 26 | ); 27 | 28 | --- insert defaults 29 | INSERT INTO 30 | prefs (user_id, base_ccy) 31 | SELECT 32 | id, 33 | 'USD' 34 | FROM 35 | users; 36 | 37 | -- triggers 38 | drop trigger if exists insert_user_prefs; 39 | 40 | CREATE TRIGGER insert_user_prefs AFTER INSERT ON users FOR EACH ROW BEGIN 41 | INSERT INTO 42 | prefs (user_id, base_ccy) 43 | VALUES 44 | (NEW.id, 'USD'); 45 | 46 | END; 47 | 48 | -- drop trigger if exists delete_user_prefs; 49 | -- create trigger delete_user_prefs after delete on users for each row BEGIN 50 | -- delete from prefs p 51 | -- where 52 | -- p.id = OLD.id; 53 | -- END; -------------------------------------------------------------------------------- /packages/assets-core/test/data/meta/0P0000KSPA.L.json: -------------------------------------------------------------------------------- 1 | { 2 | "currency": "GBP", 3 | "symbol": "0P0000KSPA.L", 4 | "exchangeName": "LSE", 5 | "fullExchangeName": "LSE", 6 | "instrumentType": "MUTUALFUND", 7 | "firstTradeDate": 1388649600, 8 | "regularMarketTime": 1743710400, 9 | "hasPrePostMarketData": false, 10 | "gmtoffset": 3600, 11 | "timezone": "BST", 12 | "exchangeTimezoneName": "Europe/London", 13 | "regularMarketPrice": 908.01, 14 | "fiftyTwoWeekHigh": 1108.192, 15 | "fiftyTwoWeekLow": 883.902, 16 | "longName": "Vanguard U.S. Eq Idx £ Acc", 17 | "shortName": "Vanguard U.S. Equity Index Fund", 18 | "chartPreviousClose": 971.705, 19 | "priceHint": 2, 20 | "currentTradingPeriod": { 21 | "pre": { 22 | "timezone": "BST", 23 | "start": 1743747300, 24 | "end": 1743750000, 25 | "gmtoffset": 3600 26 | }, 27 | "regular": { 28 | "timezone": "BST", 29 | "start": 1743750000, 30 | "end": 1743780600, 31 | "gmtoffset": 3600 32 | }, 33 | "post": { 34 | "timezone": "BST", 35 | "start": 1743780600, 36 | "end": 1743783300, 37 | "gmtoffset": 3600 38 | } 39 | }, 40 | "dataGranularity": "1d", 41 | "range": "1d", 42 | "validRanges": ["1mo", "3mo", "6mo", "ytd", "1y", "2y", "5y", "10y", "max"] 43 | } 44 | -------------------------------------------------------------------------------- /packages/assets-core/test/data/meta/0P0001I2A0.L.json: -------------------------------------------------------------------------------- 1 | { 2 | "currency": "GBP", 3 | "symbol": "0P0001I2A0.L", 4 | "exchangeName": "LSE", 5 | "fullExchangeName": "LSE", 6 | "instrumentType": "MUTUALFUND", 7 | "firstTradeDate": 1563346800, 8 | "regularMarketTime": 1743796800, 9 | "hasPrePostMarketData": false, 10 | "gmtoffset": 3600, 11 | "timezone": "BST", 12 | "exchangeTimezoneName": "Europe/London", 13 | "regularMarketPrice": 1.0004, 14 | "fiftyTwoWeekHigh": 1.004, 15 | "fiftyTwoWeekLow": 0.9998, 16 | "longName": "Vanguard Stlg S/T Mny Mkts A GBP Inc", 17 | "shortName": "Vanguard Investments Money Mark", 18 | "chartPreviousClose": 1.0001, 19 | "priceHint": 4, 20 | "currentTradingPeriod": { 21 | "pre": { 22 | "timezone": "BST", 23 | "start": 1743747300, 24 | "end": 1743750000, 25 | "gmtoffset": 3600 26 | }, 27 | "regular": { 28 | "timezone": "BST", 29 | "start": 1743750000, 30 | "end": 1743780600, 31 | "gmtoffset": 3600 32 | }, 33 | "post": { 34 | "timezone": "BST", 35 | "start": 1743780600, 36 | "end": 1743783300, 37 | "gmtoffset": 3600 38 | } 39 | }, 40 | "dataGranularity": "1d", 41 | "range": "1d", 42 | "validRanges": ["1mo", "3mo", "6mo", "ytd", "1y", "2y", "5y", "10y", "max"] 43 | } 44 | -------------------------------------------------------------------------------- /.gitea/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: CHECKS-AND-INTEGRATION-TESTS 2 | run-name: Tests for ${{ gitea.sha }} 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | - feature/* 9 | 10 | jobs: 11 | run-migrations: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: CHECK-OUT 15 | uses: actions/checkout@v4 16 | - name: SETUP GO 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.23.x" 20 | - name: RUN MIGRATION 21 | run: | 22 | go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 23 | ~/go/bin/migrate -path ./.migrations -database=sqlite3://assets.db up 24 | - name: INSTALL BUN 25 | uses: oven-sh/setup-bun@v2 26 | - name: INSTALL DEPENDENCIES 27 | run: bun install 28 | - name: RUN STATIC CHECKS 29 | run: bun run check 30 | - name: RUN INTEGRATION TESTS 31 | env: 32 | URL: localhost:4020 33 | ASSETS_DB: "../../assets.db" 34 | ASSETS_JWT_SECRET: S0meS3cret 35 | ASSETS_APP: /public 36 | ASSETS_PORT: 4020 37 | run: | 38 | bun run backend:dev & echo $! >/tmp/backend.pid 39 | sleep 1 40 | bun test 41 | kill -9 $(cat /tmp/backend.pid) 42 | -------------------------------------------------------------------------------- /packages/web/src/screens/Login.tsx: -------------------------------------------------------------------------------- 1 | import type { Action, Credentials } from "@darkruby/assets-core"; 2 | import { useSignals } from "@preact/signals-react/runtime"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import { Col, Row } from "react-bootstrap"; 5 | import { useNavigate } from "react-router"; 6 | import { Login } from "../components/Auth/Login"; 7 | import { routes } from "../components/Router"; 8 | import { Error } from "../decorators/errors"; 9 | import { useStore } from "../hooks/store"; 10 | 11 | const RawLoginScreen: React.FC = () => { 12 | useSignals(); 13 | const navigate = useNavigate(); 14 | const { auth } = useStore(); 15 | 16 | const handleLogin = (creds: Credentials) => { 17 | const onSuccess: Action = TE.fromTask(() => { 18 | const navigateHome = async () => { 19 | await navigate(routes.portfolios()); 20 | }; 21 | return navigateHome(); 22 | }); 23 | auth.login(creds, onSuccess); 24 | }; 25 | 26 | return ( 27 | <> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export { RawLoginScreen as LoginScreen }; 41 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: CHECKS-AND-INTEGRATION-TESTS 2 | run-name: Run tests for ${{ github.ref_name }} 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | - feature/* 9 | 10 | jobs: 11 | checks-and-integration-tests: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: CHECK-OUT 15 | uses: actions/checkout@v4 16 | - name: SETUP GO 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.23.x" 20 | - name: RUN MIGRATION 21 | run: | 22 | go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest 23 | ~/go/bin/migrate -path ./.migrations -database=sqlite3://assets.db up 24 | - name: INSTALL BUN 25 | uses: oven-sh/setup-bun@v2 26 | - name: INSTALL DEPENDENCIES 27 | run: bun install 28 | - name: RUN STATIC CHECKS 29 | run: bun run check 30 | - name: RUN INTEGRATION TESTS 31 | env: 32 | URL: localhost:4020 33 | ASSETS_DB: "../../assets.db" 34 | ASSETS_JWT_SECRET: S0meS3cret 35 | ASSETS_APP: /public 36 | ASSETS_PORT: 4020 37 | run: | 38 | bun run backend:dev & echo $! >/tmp/backend.pid 39 | sleep 1 40 | bun test 41 | kill -9 $(cat /tmp/backend.pid) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@darkruby/assets", 3 | "private": true, 4 | "version": "1.5.0", 5 | "type": "module", 6 | "dependencies": { 7 | "date-fns": "^4.1.0", 8 | "fp-ts": "^2.16.11", 9 | "io-ts-types": "^0.5.19", 10 | "ms": "^2.1.3", 11 | "prettier": "^3.6.2", 12 | "recharts": "^2.15.4" 13 | }, 14 | "devDependencies": { 15 | "@types/bun": "1.2.20", 16 | "bun-types": "^1.3.1", 17 | "sass-embedded": "^1.93.2", 18 | "typescript": "~5.9.3" 19 | }, 20 | "scripts": { 21 | "web:dev": "cd packages/web && bun run dev", 22 | "web:check": "cd packages/web && bun run check", 23 | "web:build": "cd packages/web && bun run build", 24 | "backend:dev": "cd packages/backend && bun run dev", 25 | "backend:test": "cd packages/backend && bun test", 26 | "backend:check": "cd packages/backend && bun run check", 27 | "backend:build": "cd packages/backend && bun run build", 28 | "assets-core:check": "cd packages/assets-core && bun run check", 29 | "assets-core:test": "cd packages/assets-core && bun run test", 30 | "fp-express:check": "cd packages/fp-express && bun run check", 31 | "check": "bun run assets-core:check && bun run fp-express:check && bun run backend:check && bun run web:check", 32 | "build": "bun run web:build && bun run backend:build" 33 | }, 34 | "workspaces": [ 35 | "packages/*" 36 | ] 37 | } -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/portfolio.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { dateDecoder, nonEmptyArray } from "./util"; 3 | import { ChartDataPointDecoder } from "./yahoo/chart"; 4 | import { RangeDecoder } from "./yahoo/meta"; 5 | import { PeriodChangesDecoder, TotalsDecoder } from "./yahoo/period"; 6 | 7 | const basePortfolioTypes = { 8 | name: t.string, 9 | description: t.string, 10 | }; 11 | 12 | const extPortfolioTypes = { 13 | id: t.number, 14 | user_id: t.number, 15 | ...basePortfolioTypes, 16 | created: dateDecoder, 17 | modified: dateDecoder, 18 | total_invested: t.number, 19 | num_assets: t.number, 20 | contribution: t.number, 21 | }; 22 | 23 | export const PostPortfolioDecoder = t.type(basePortfolioTypes); 24 | export const GetPortfolioDecoder = t.type(extPortfolioTypes); 25 | export const GetPortfoliosDecoder = t.array(GetPortfolioDecoder); 26 | 27 | export const PortfolioMetaDecoder = t.type({ 28 | range: RangeDecoder, 29 | validRanges: t.array(RangeDecoder), 30 | }); 31 | 32 | export const EnrichedPortfolioDecoder = t.type({ 33 | ...extPortfolioTypes, 34 | chart: nonEmptyArray(ChartDataPointDecoder), 35 | value: PeriodChangesDecoder, 36 | weight: t.number, 37 | investedBase: t.number, 38 | totals: TotalsDecoder, 39 | meta: PortfolioMetaDecoder, 40 | }); 41 | 42 | export const EnrichedPortfoliosDecoder = t.array(EnrichedPortfolioDecoder); 43 | -------------------------------------------------------------------------------- /packages/web/src/services/storage.ts: -------------------------------------------------------------------------------- 1 | import { handleError, type Result } from "@darkruby/assets-core"; 2 | import { liftE } from "@darkruby/assets-core/src/decoders/util"; 3 | import * as E from "fp-ts/lib/Either"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import * as t from "io-ts"; 6 | import { Json, JsonFromString } from "io-ts-types"; 7 | 8 | const reader = 9 | (decoder: t.Decoder) => 10 | (key: string): Result => 11 | pipe( 12 | E.tryCatch( 13 | () => localStorage.getItem(key) ?? "", 14 | handleError("localStorage.getItem") 15 | ), 16 | E.chain(liftE(decoder)) 17 | ); 18 | 19 | const writer = 20 | (decoder: t.Encoder) => 21 | (key: string, value: T): Result => 22 | pipe( 23 | E.tryCatch( 24 | () => localStorage.setItem(key, decoder.encode(value)), 25 | handleError("localStorage.setItem") 26 | ) 27 | ); 28 | 29 | const remover = (key: string): Result => 30 | pipe( 31 | E.tryCatch( 32 | () => localStorage.removeItem(key), 33 | handleError("localStorage.removeItem") 34 | ) 35 | ); 36 | 37 | export const storage = (decoder: t.Type) => { 38 | const dec = JsonFromString.pipe(decoder); 39 | const read = reader(dec); 40 | const write = writer(dec); 41 | const remove = remover; 42 | return { read, write, remove }; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/web/src/components/Profile/Prefs.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BASE_CCYS, 3 | type Ccy, 4 | type Prefs as PrefsData, 5 | } from "@darkruby/assets-core"; 6 | import { pipe } from "fp-ts/lib/function"; 7 | import * as React from "react"; 8 | import { Form } from "react-bootstrap"; 9 | import { withFetching } from "../../decorators/fetching"; 10 | import { withProps } from "../../decorators/props"; 11 | import { usePartialState } from "../../hooks/formData"; 12 | import { PrimaryButton } from "../Form/FormControl"; 13 | import { Select } from "../Form/Select"; 14 | 15 | const CcySelect = pipe(Select, withProps({ options: BASE_CCYS })); 16 | 17 | type PrefsProps = { 18 | prefs: PrefsData; 19 | onUpdate: (p: PrefsData) => void; 20 | }; 21 | 22 | const RawPrefs: React.FC = ({ prefs, onUpdate }) => { 23 | const [prf, setField] = usePartialState(prefs); 24 | const handleSubmit = () => onUpdate(prf); 25 | const handleBaseCcy = setField("base_ccy"); 26 | return ( 27 | <> 28 |
29 | 30 | Base currency 31 | 32 | 33 | Submit 34 |
35 | 36 | ); 37 | }; 38 | 39 | export const Prefs = pipe(RawPrefs, withFetching); 40 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/user.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import type { 3 | CredenatialsDecoder, 4 | GetUserDecoder, 5 | NewUserDecoder, 6 | PasswordChangeDecoder, 7 | PostUserDecoder, 8 | ProfileDecoder, 9 | RawInUserDecoder, 10 | RawOutUserDecoder, 11 | UserIdDecoder, 12 | } from "../decoders/user"; 13 | 14 | export type Credentials = t.TypeOf; 15 | export type PasswordChange = t.TypeOf; 16 | export type RawInUser = t.TypeOf; 17 | export type RawOutUser = t.TypeOf; 18 | export type NewUser = t.TypeOf; 19 | export type GetUser = t.TypeOf; 20 | export type PostUser = t.TypeOf; 21 | export type Profile = t.TypeOf; 22 | export type UserId = t.TypeOf; 23 | 24 | export const profile = ({ id, username, admin }: GetUser): Profile => ({ 25 | id, 26 | username, 27 | admin, 28 | }); 29 | 30 | export const defaultCredentials = (): Credentials => ({ 31 | username: "", 32 | password: "", 33 | }); 34 | 35 | export const defaultNewUser = (): NewUser => ({ 36 | ...defaultCredentials(), 37 | admin: false, 38 | locked: false, 39 | }); 40 | 41 | export const defaultPasswordChange = (): PasswordChange => ({ 42 | oldPassword: "", 43 | newPassword: "", 44 | repeat: "", 45 | }); 46 | -------------------------------------------------------------------------------- /packages/web/src/components/Tx/TickerLookup.tsx: -------------------------------------------------------------------------------- 1 | import type { Ticker } from "@darkruby/assets-core"; 2 | import * as A from "fp-ts/lib/Array"; 3 | import { pipe } from "fp-ts/lib/function"; 4 | import * as TE from "fp-ts/lib/TaskEither"; 5 | import * as React from "react"; 6 | import AsyncSelect from "react-select/async"; 7 | import { lookupTicker } from "../../services/ticker"; 8 | import "./TickerLookup.scss"; 9 | 10 | type SelectOption = { 11 | label: string; 12 | value: T; 13 | }; 14 | 15 | const toOptions = (ticker: Ticker): SelectOption => ({ 16 | label: `(${ticker.symbol}) ${ticker.shortname} - ${ticker.quoteType} - ${ticker.exchange}`, 17 | value: ticker, 18 | }); 19 | 20 | const lookup = (s: string) => 21 | pipe( 22 | lookupTicker(s), 23 | TE.map((x) => A.map(toOptions)(x.quotes)), 24 | TE.getOrElse(() => () => Promise.resolve[]>([])) 25 | )(); 26 | 27 | export type TickerLookupProps = { 28 | onSelect: (t: Ticker) => void; 29 | disabled?: boolean; 30 | }; 31 | 32 | export const TickerLookup: React.FC = ({ 33 | onSelect, 34 | disabled, 35 | }) => { 36 | return ( 37 | onSelect(x?.value as Ticker)} 44 | /> 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/backend/src/repository/prefs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | handleError, 3 | PrefsDecoder, 4 | type Action, 5 | type Prefs, 6 | } from "@darkruby/assets-core"; 7 | import { liftTE } from "@darkruby/assets-core/src/decoders/util"; 8 | import type { UserId } from "@darkruby/assets-core/src/domain/user"; 9 | import type Database from "bun:sqlite"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as ID from "fp-ts/lib/Identity"; 12 | import * as TE from "fp-ts/lib/TaskEither"; 13 | import { execute, queryOne } from "./database"; 14 | import { getPrefsSql, updatePrefsSql } from "./sql" with { type: "macro" }; 15 | 16 | const sql = { 17 | prefs: { 18 | get: TE.of(getPrefsSql()), 19 | update: TE.of(updatePrefsSql()), 20 | }, 21 | }; 22 | 23 | export const getPrefs = 24 | (db: Database) => 25 | (userId: UserId): Action => 26 | pipe( 27 | queryOne({ userId }), 28 | ID.ap(sql.prefs.get), 29 | ID.ap(db), 30 | TE.chain(liftTE(PrefsDecoder)) 31 | ); 32 | 33 | export const updatePrefs = 34 | (db: Database) => 35 | (userId: UserId, prefs: Prefs): Action => { 36 | return pipe( 37 | execute({ ...prefs, userId }), 38 | ID.ap(sql.prefs.update), 39 | ID.ap(db), 40 | TE.chain(() => getPrefs(db)(userId)), 41 | TE.filterOrElse( 42 | (p): p is Prefs => Boolean(p), 43 | handleError("Failed to create asset") 44 | ) 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/web/src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Action, 3 | AppErrorType, 4 | generalError, 5 | handleError, 6 | type Result, 7 | type Token, 8 | } from "@darkruby/assets-core"; 9 | import * as E from "fp-ts/lib/Either"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import * as jose from "jose"; 13 | 14 | import { differenceInSeconds } from "date-fns"; 15 | import { apiFromToken } from "./api"; 16 | import { readToken, writeToken } from "./token"; 17 | 18 | const refreshToken = (): Action => { 19 | return pipe( 20 | apiFromToken, 21 | TE.chain(({ auth }) => auth.refreshToken()), 22 | TE.chainFirst(TE.fromEitherK(writeToken)) 23 | ); 24 | }; 25 | 26 | const belowThreshold = (token: Token): Result => 27 | pipe( 28 | E.tryCatch( 29 | () => jose.decodeJwt(token.token), 30 | handleError("Cant parse token", AppErrorType.Auth) 31 | ), 32 | E.filterOrElseW( 33 | ({ exp = 0 }) => 34 | differenceInSeconds(new Date(exp * 1000), new Date()) > 35 | (token.refreshBefore ?? 0), 36 | () => generalError("needs refresh") 37 | ), 38 | E.map(() => token) 39 | ); 40 | 41 | export const refreshWhenCloseToExpiry = (): Action => { 42 | return pipe( 43 | readToken(), // read best before here 44 | E.chain(belowThreshold), 45 | TE.fromEither, 46 | TE.altW(refreshToken) 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /packages/web/assets/plus 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/web/src/stores/profile.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | GetUser, 4 | Identity, 5 | Nullable, 6 | PasswordChange, 7 | PostUser, 8 | } from "@darkruby/assets-core"; 9 | import { signal } from "@preact/signals-react"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { 13 | deleteProfile, 14 | getProfile, 15 | updatePassword, 16 | updateProfile, 17 | } from "../services/profile"; 18 | import { type StoreBase, createStoreBase } from "./base"; 19 | 20 | export type ProfileStore = Identity< 21 | StoreBase> & { 22 | load: () => ActionResult>; 23 | update: (c: PostUser) => ActionResult>; 24 | password: (c: PasswordChange) => ActionResult>; 25 | delete: () => ActionResult>; 26 | } 27 | >; 28 | 29 | export const createProfileStore = (): ProfileStore => { 30 | const data = signal>(null); 31 | const storeBase = createStoreBase(data); 32 | 33 | return { 34 | ...storeBase, 35 | load: () => storeBase.run(getProfile()), 36 | update: (c: PostUser) => storeBase.run(updateProfile(c)), 37 | password: (c: PasswordChange) => storeBase.run(updatePassword(c)), 38 | delete: () => 39 | storeBase.run( 40 | pipe( 41 | deleteProfile(), 42 | TE.map(() => null) 43 | ) 44 | ), 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/assets-core/test/data/meta/IE000I7E6HL0.SG.json: -------------------------------------------------------------------------------- 1 | { 2 | "currency": "EUR", 3 | "symbol": "IE000I7E6HL0.SG", 4 | "exchangeName": "STU", 5 | "fullExchangeName": "Stuttgart", 6 | "instrumentType": "MUTUALFUND", 7 | "firstTradeDate": null, 8 | "regularMarketTime": 1761335732, 9 | "hasPrePostMarketData": false, 10 | "gmtoffset": 7200, 11 | "timezone": "CEST", 12 | "exchangeTimezoneName": "Europe/Berlin", 13 | "regularMarketPrice": 9.803, 14 | "fiftyTwoWeekHigh": 7.772, 15 | "fiftyTwoWeekLow": 7.445, 16 | "regularMarketDayHigh": 7.772, 17 | "regularMarketDayLow": 7.445, 18 | "regularMarketVolume": 8927, 19 | "shortName": "HANetf Future of European Defen", 20 | "chartPreviousClose": 9.845, 21 | "priceHint": 2, 22 | "currentTradingPeriod": { 23 | "pre": { 24 | "timezone": "CEST", 25 | "end": 1761285600, 26 | "start": 1761285600, 27 | "gmtoffset": 7200 28 | }, 29 | "regular": { 30 | "timezone": "CEST", 31 | "end": 1761336000, 32 | "start": 1761285600, 33 | "gmtoffset": 7200 34 | }, 35 | "post": { 36 | "timezone": "CEST", 37 | "end": 1761336000, 38 | "start": 1761336000, 39 | "gmtoffset": 7200 40 | } 41 | }, 42 | "dataGranularity": "1d", 43 | "range": "1d", 44 | "validRanges": [ 45 | "1mo", 46 | "3mo", 47 | "6mo", 48 | "ytd", 49 | "1y", 50 | "2y", 51 | "5y", 52 | "10y", 53 | "max" 54 | ] 55 | } -------------------------------------------------------------------------------- /packages/web/src/components/Modals/Modal.tsx: -------------------------------------------------------------------------------- 1 | import type { Validator } from "@darkruby/assets-core/src/validation/util"; 2 | import { useState } from "react"; 3 | import { Modal, ModalBody, ModalHeader } from "react-bootstrap"; 4 | import type { DialogDrivenComponentProps } from "../../util/modal"; 5 | import type { FieldsProps } from "../Form/Form"; 6 | import { FormErrors } from "../Form/FormErrors"; 7 | import { ConfirmationModalFooter } from "./Footer"; 8 | 9 | export function createModal = FieldsProps>( 10 | Fields: React.FC, 11 | validator: Validator, 12 | title = "Edit" 13 | ): React.FC> { 14 | return ({ 15 | onClose, 16 | onSubmit, 17 | open, 18 | value, 19 | ...rest 20 | }: DialogDrivenComponentProps) => { 21 | const [data, setData] = useState(value!); 22 | const handleOk = () => onSubmit(data); 23 | const { valid, errors } = validator(data); 24 | const fieldProps = { ...rest, data: data, onChange: setData } as FP; 25 | 26 | return ( 27 | 28 | {title} 29 | 30 | 31 | 32 | 33 | 38 | 39 | ); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /packages/fp-express/src/error.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from "express"; 2 | 3 | export enum WebErrorType { 4 | Auth = "Auth", 5 | NotFound = "NotFound", 6 | BadRequest = "BadRequest", 7 | General = "General", 8 | Next = "Next", 9 | } 10 | 11 | export type WebAppError = { 12 | type: WebErrorType; 13 | message: string; 14 | }; 15 | 16 | const stringifyUnknownError = (reason: unknown) => { 17 | if (reason) { 18 | if ((reason as Error).message) { 19 | return (reason as Error).message; 20 | } 21 | return JSON.stringify(reason); 22 | } 23 | return "Unknown error"; 24 | }; 25 | 26 | export const notFound = (reason: E): WebAppError => ({ 27 | type: WebErrorType.NotFound, 28 | message: "Not Found", 29 | }); 30 | 31 | export const next = (): WebAppError => ({ 32 | type: WebErrorType.Next, 33 | message: "", 34 | }); 35 | 36 | export const authError = (reason: E): WebAppError => ({ 37 | type: WebErrorType.Auth, 38 | message: stringifyUnknownError(reason), 39 | }); 40 | 41 | export const generalError = (reason: E): WebAppError => ({ 42 | type: WebErrorType.General, 43 | message: stringifyUnknownError(reason), 44 | }); 45 | 46 | export const badRequest = (reason: E): WebAppError => ({ 47 | type: WebErrorType.BadRequest, 48 | message: stringifyUnknownError(reason), 49 | }); 50 | 51 | export type HandlerContext = { 52 | params: Parameters; 53 | context: Ctx; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Select.tsx: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@darkruby/assets-core"; 2 | import { type Eq, fromEquals } from "fp-ts/lib/Eq"; 3 | import { useCallback } from "react"; 4 | import Form from "react-bootstrap/Form"; 5 | 6 | export type SelectProps = { 7 | eq?: Eq; 8 | options: readonly T[]; 9 | disabled?: boolean; 10 | value?: Nullable; 11 | toValue?: (t: T) => string; 12 | toLabel?: (t: T) => string; 13 | onSelect: (option: T) => void; 14 | }; 15 | 16 | export function Select({ 17 | options, 18 | onSelect, 19 | disabled = false, 20 | toValue = String, 21 | toLabel = String, 22 | eq = fromEquals((a, b) => a === b), 23 | value, 24 | }: SelectProps): ReturnType>> { 25 | const handleSelect = useCallback( 26 | (e: React.ChangeEvent) => { 27 | const sel = options.find((opt) => 28 | eq.equals(opt, e.currentTarget.value as T) 29 | ); 30 | if (sel) { 31 | onSelect?.(sel); 32 | } 33 | }, 34 | [onSelect, eq, options] 35 | ); 36 | 37 | return ( 38 | 44 | {options.map((opt) => ( 45 | 48 | ))} 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/prefs.ts: -------------------------------------------------------------------------------- 1 | import * as A from "fp-ts/lib/Array"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as t from "io-ts"; 4 | 5 | export const BASE_CCYS = [ 6 | "USD", 7 | "GBP", 8 | "EUR", 9 | "CAD", 10 | "AUD", 11 | "CHF", 12 | "SEK", 13 | "NOK", 14 | "DKK", 15 | "NZD", 16 | "JPY", 17 | ] as const; 18 | 19 | export type Ccy = (typeof BASE_CCYS)[number]; 20 | 21 | export const CcyDecoder = pipe( 22 | BASE_CCYS as unknown as string[], 23 | A.map((v: string) => t.literal(v) as t.LiteralC), 24 | (codecs) => 25 | t.union( 26 | codecs as [ 27 | t.LiteralC, 28 | t.LiteralC, 29 | ...t.LiteralC[], 30 | ] 31 | ) 32 | ) as t.Type; 33 | 34 | const prefsTypes = { 35 | base_ccy: CcyDecoder, 36 | }; 37 | 38 | export const PrefsDecoder = t.type(prefsTypes); 39 | 40 | export const ccyToLocale = (ccy: Ccy): string => { 41 | switch (ccy) { 42 | case "GBP": 43 | return "en-GB"; 44 | case "EUR": 45 | return "de-DE"; 46 | case "CAD": 47 | return "en-CA"; 48 | case "AUD": 49 | return "en-AU"; 50 | case "CHF": 51 | return "de-CH"; 52 | case "SEK": 53 | return "sv-SE"; 54 | case "NOK": 55 | return "no-NO"; 56 | case "DKK": 57 | return "da-DK"; 58 | case "NZD": 59 | return "en-NZ"; 60 | case "JPY": 61 | return "ja-JP"; 62 | case "USD": 63 | default: 64 | return "en-US"; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /packages/assets-core/src/domain/error.ts: -------------------------------------------------------------------------------- 1 | import * as A from "fp-ts/lib/Array"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as O from "fp-ts/lib/Option"; 4 | import * as t from "io-ts"; 5 | import { type ValidationError, type Errors as ValidationErrors } from "io-ts"; 6 | import { formatValidationErrors } from "io-ts-reporters"; 7 | import { AppErrorDecoder, AppErrorType } from "../decoders/error"; 8 | 9 | export type AppError = t.TypeOf; 10 | 11 | export const authError = (message: string): AppError => ({ 12 | message, 13 | type: AppErrorType.Auth, 14 | }); 15 | 16 | export const generalError = (message: string): AppError => ({ 17 | message, 18 | type: AppErrorType.General, 19 | }); 20 | 21 | export const validationError = (message: string): AppError => ({ 22 | message, 23 | type: AppErrorType.Validation, 24 | }); 25 | 26 | export const handleError = 27 | (msg: string = "", type: AppErrorType = AppErrorType.General) => 28 | (e: unknown): AppError => ({ 29 | message: `${msg}: ${e}`, 30 | type, 31 | }); 32 | 33 | export const fromValidationError = ( 34 | val: ValidationError, 35 | fallbackMessage = "validation error" 36 | ): AppError => { 37 | return { 38 | type: AppErrorType.Validation, 39 | message: pipe( 40 | formatValidationErrors([val]), 41 | A.head, 42 | O.getOrElse(() => fallbackMessage) 43 | ), 44 | }; 45 | }; 46 | 47 | export const validationErrors = (vals: ValidationErrors): AppError => 48 | fromValidationError(vals[0]); 49 | -------------------------------------------------------------------------------- /packages/web/src/screens/Portfolios.tsx: -------------------------------------------------------------------------------- 1 | import type { PostPortfolio } from "@darkruby/assets-core"; 2 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 3 | import { useSignals } from "@preact/signals-react/runtime"; 4 | import { useEffect } from "react"; 5 | import { Portfolios } from "../components/Portfolio/Portfolios"; 6 | import { useStore } from "../hooks/store"; 7 | 8 | const RawPortfoliosScreen: React.FC = () => { 9 | useSignals(); 10 | const { portfolios, portfolio, asset, summary } = useStore(); 11 | 12 | useEffect(() => { 13 | summary.load(); 14 | portfolios.load(); 15 | 16 | portfolio.reset(); 17 | asset.reset(); 18 | }, [portfolios]); 19 | 20 | const handleAdd = (p: PostPortfolio) => portfolios.create(p); 21 | const handleUpdate = (pid: number, p: PostPortfolio) => 22 | portfolios.update(pid, p); 23 | const handleDelete = (pid: number) => portfolios.delete(pid); 24 | 25 | const handleRange = (range: ChartRange) => { 26 | portfolios.load(range); 27 | summary.load(range); 28 | }; 29 | return ( 30 | 40 | ); 41 | }; 42 | 43 | export { RawPortfoliosScreen as PortfoliosScreen }; 44 | -------------------------------------------------------------------------------- /packages/web/src/stores/tx.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | GetTx, 4 | Identity, 5 | Nullable, 6 | PostTx, 7 | } from "@darkruby/assets-core"; 8 | import { signal } from "@preact/signals-react"; 9 | import * as TE from "fp-ts/lib/TaskEither"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import { createTx, deleteTx, getTx, updateTx } from "../services/txs"; 12 | import { type StoreBase, createStoreBase } from "./base"; 13 | 14 | export type TxStore = Identity< 15 | StoreBase> & { 16 | load: (aid: number, tid: number) => ActionResult>; 17 | create: (aid: number, t: PostTx) => ActionResult>; 18 | update: ( 19 | aid: number, 20 | tid: number, 21 | t: PostTx 22 | ) => ActionResult>; 23 | delete: (aid: number, tid: number) => ActionResult>; 24 | } 25 | >; 26 | 27 | export const createTxStore = (): TxStore => { 28 | const data = signal>(null); 29 | const storeBase = createStoreBase(data); 30 | 31 | return { 32 | ...storeBase, 33 | load: (aid: number, tid: number) => storeBase.run(getTx(aid, tid)), 34 | create: (aid: number, t: PostTx) => storeBase.run(createTx(aid, t)), 35 | update: (aid: number, tid: number, p: PostTx) => 36 | storeBase.run(updateTx(aid, tid, p)), 37 | delete: (aid: number, tid: number) => 38 | storeBase.run( 39 | pipe( 40 | deleteTx(aid, tid), 41 | TE.map(() => null) 42 | ) 43 | ), 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /packages/web/src/components/Auth/Login.tsx: -------------------------------------------------------------------------------- 1 | import { defaultCredentials, type Credentials } from "@darkruby/assets-core"; 2 | import { CredenatialsDecoder } from "@darkruby/assets-core/src/decoders/user"; 3 | import { createValidator } from "@darkruby/assets-core/src/validation/util"; 4 | import { Form } from "react-bootstrap"; 5 | import { usePartialState } from "../../hooks/formData"; 6 | import { FormEdit, FormPassword, PrimaryButton } from "../Form/FormControl"; 7 | 8 | export type LoginProps = { 9 | onLogin: (c: Credentials) => void; 10 | }; 11 | 12 | const credValidator = createValidator(CredenatialsDecoder); 13 | 14 | export const Login: React.FC = ({ onLogin }) => { 15 | const [creds, setField] = usePartialState(defaultCredentials()); 16 | const handleSubmit = () => onLogin(creds); 17 | 18 | const { valid } = credValidator(creds); 19 | const handleUsername = setField("username") as (x: string) => void; 20 | const handlePassword = setField("password") as (x: string) => void; 21 | 22 | return ( 23 |
24 | 25 | Login 26 | 27 | 28 | 29 | Password 30 | 31 | 32 | 33 | Submit 34 | 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import { 3 | createContext, 4 | useContext, 5 | useState, 6 | type PropsWithChildren, 7 | } from "react"; 8 | import { Nav } from "react-bootstrap"; 9 | import { withVisibility } from "../../decorators/nodata"; 10 | 11 | export type TabsProps = PropsWithChildren<{ 12 | tabs: readonly string[]; 13 | }>; 14 | 15 | const TabContext = createContext({ tab: 0 }); 16 | 17 | export const Tabs: React.FC = ({ tabs, children }) => { 18 | const [tab, setTab] = useState(0); 19 | const handleTabClick = (idx: number) => () => setTab(idx); 20 | return ( 21 | <> 22 | 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | export const TabContent: React.FC = ({ 37 | tab, 38 | children, 39 | }) => { 40 | const Div: React.FC = (p) => ( 41 |
{p.children}
42 | ); 43 | const TabDiv = pipe(Div, withVisibility()); 44 | const { tab: selectedTab } = useContext(TabContext); 45 | return ; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/assets-core/src/decoders/asset.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { dateDecoder, nonEmptyArray, nullableDecoder } from "./util"; 3 | 4 | import { ChartDataPointDecoder } from "./yahoo/chart"; 5 | import { ChartMetaDecoder } from "./yahoo/meta"; 6 | import { PeriodChangesDecoder, TotalsDecoder } from "./yahoo/period"; 7 | 8 | const baseAssetTypes = { 9 | ticker: t.string, 10 | name: t.string, 11 | }; 12 | 13 | const extAssetTypes = { 14 | id: t.number, 15 | portfolio_id: t.number, 16 | ...baseAssetTypes, 17 | created: dateDecoder, 18 | modified: dateDecoder, 19 | holdings: t.number, 20 | invested: t.number, 21 | num_txs: t.number, 22 | avg_price: nullableDecoder(t.number), 23 | portfolio_contribution: t.number, 24 | }; 25 | 26 | export const PostAssetDecoder = t.type(baseAssetTypes); 27 | export const GetAssetDecoder = PostAssetDecoder.pipe(t.type(extAssetTypes)); 28 | export const GetAssetsDecoder = t.array(GetAssetDecoder); 29 | 30 | export const EnrichedAssetDecoder = t.type({ 31 | ...extAssetTypes, 32 | meta: ChartMetaDecoder, 33 | chart: t.type({ 34 | ccy: nonEmptyArray(ChartDataPointDecoder), 35 | base: nonEmptyArray(ChartDataPointDecoder), 36 | }), 37 | investedBase: t.number, 38 | value: t.type({ 39 | ccy: PeriodChangesDecoder, 40 | base: PeriodChangesDecoder, 41 | weight: t.number, 42 | baseRate: t.number, 43 | }), 44 | totals: t.type({ 45 | ccy: TotalsDecoder, 46 | base: TotalsDecoder, 47 | }), 48 | }); 49 | 50 | export const EnrichedAssetsDecoder = t.array(EnrichedAssetDecoder); 51 | -------------------------------------------------------------------------------- /packages/assets-core/src/validation/user.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either"; 2 | import { flow, pipe } from "fp-ts/lib/function"; 3 | import { 4 | NewUserDecoder, 5 | PasswordChangeDecoder, 6 | PostUserDecoder, 7 | } from "../decoders/user"; 8 | import { mapDecoder } from "../decoders/util"; 9 | import { 10 | alphaNumOnly, 11 | createValidator, 12 | length, 13 | match, 14 | noWhiteSpace, 15 | } from "../validation/util"; 16 | 17 | const length5 = length(5); 18 | 19 | export const shortPassword = (pwd: string) => 20 | flow(length5(pwd), noWhiteSpace(pwd)); 21 | 22 | export const shortUsername = (pwd: string) => 23 | flow(length5(pwd), alphaNumOnly(pwd), noWhiteSpace(pwd)); 24 | 25 | export const postUserValidator = pipe( 26 | mapDecoder(PostUserDecoder, (user) => 27 | pipe( 28 | E.Do, 29 | shortUsername(user.username), 30 | E.map(() => user) 31 | ) 32 | ), 33 | createValidator 34 | ); 35 | 36 | export const newUserValidator = pipe( 37 | mapDecoder(NewUserDecoder, (newUser) => 38 | pipe( 39 | E.Do, 40 | shortUsername(newUser.username), 41 | shortPassword(newUser.password), 42 | E.map(() => newUser) 43 | ) 44 | ), 45 | createValidator 46 | ); 47 | 48 | export const passwordChangeValidator = pipe( 49 | mapDecoder(PasswordChangeDecoder, ({ oldPassword, newPassword, repeat }) => 50 | pipe( 51 | E.Do, 52 | match(newPassword, repeat), 53 | shortPassword(newPassword), 54 | E.map(() => ({ oldPassword, newPassword, repeat })) 55 | ) 56 | ), 57 | createValidator 58 | ); 59 | -------------------------------------------------------------------------------- /packages/web/src/components/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | import type { Identity } from "@darkruby/assets-core"; 2 | import type { Validator } from "@darkruby/assets-core/src/validation/util"; 3 | import { useState } from "react"; 4 | import { PrimaryButton } from "./FormControl"; 5 | import { FormErrors } from "./FormErrors"; 6 | 7 | export type FieldsProps = { 8 | data: T; 9 | onChange: (t: T) => void; 10 | disabled?: boolean; 11 | }; 12 | 13 | export type FormProps = 14 | FieldProps extends FieldsProps 15 | ? Identity< 16 | Omit & { 17 | onSubmit: (t: T) => void; 18 | } 19 | > 20 | : never; 21 | 22 | export type Form = React.FC>>; 23 | 24 | export function createForm = FieldsProps>( 25 | Fields: React.FC, 26 | validator: Validator 27 | ): Form { 28 | return (({ data, disabled, onSubmit, ...rest }: FormProps) => { 29 | const [inner, setInner] = useState(data); 30 | const { valid, errors } = validator(inner); 31 | const handleSubmit = () => onSubmit(inner); 32 | const fpProps = { 33 | ...rest, 34 | data: inner, 35 | onChange: setInner, 36 | disabled: disabled, 37 | } as unknown as FP; 38 | return ( 39 | <> 40 | 41 | 42 | 43 | Submit 44 | 45 | 46 | ); 47 | }) as Form; 48 | } 49 | -------------------------------------------------------------------------------- /packages/backend/src/repository/index.ts: -------------------------------------------------------------------------------- 1 | import type Database from "bun:sqlite"; 2 | import * as asset from "./asset"; 3 | import * as portfolio from "./portfolio"; 4 | import * as prefs from "./prefs"; 5 | import * as tx from "./transaction"; 6 | import * as user from "./user"; 7 | 8 | export type Repository = ReturnType; 9 | 10 | export const createRepository = (db: Database) => { 11 | return { 12 | asset: { 13 | get: asset.getAsset(db), 14 | getAll: asset.getAssets(db), 15 | create: asset.createAsset(db), 16 | delete: asset.deleteAsset(db), 17 | update: asset.updateAsset(db), 18 | }, 19 | tx: { 20 | get: tx.getTx(db), 21 | getAll: tx.getTxs(db), 22 | create: tx.createTx(db), 23 | delete: tx.deleteTx(db), 24 | update: tx.updateTx(db), 25 | }, 26 | portfolio: { 27 | get: portfolio.getPortfolio(db), 28 | getAll: portfolio.getPortfolios(db), 29 | create: portfolio.createPortfolio(db), 30 | delete: portfolio.deletePortfolio(db), 31 | update: portfolio.updatePortfolio(db), 32 | }, 33 | user: { 34 | get: user.getUser(db), 35 | getAll: user.getUsers(db), 36 | create: user.createUser(db), 37 | update: user.updateUser(db), 38 | delete: user.deleteUser(db), 39 | updateProfileOnly: user.updateProfileOnly(db), 40 | loginAttempt: user.loginAttempt(db), 41 | resetAttempts: user.resetAttempts(db), 42 | }, 43 | prefs: { 44 | get: prefs.getPrefs(db), 45 | update: prefs.updatePrefs(db), 46 | }, 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/web/src/services/portfolios.ts: -------------------------------------------------------------------------------- 1 | import { 2 | byPortfolioChangePct, 3 | type Action, 4 | type EnrichedPortfolio, 5 | type Id, 6 | type PostPortfolio, 7 | } from "@darkruby/assets-core"; 8 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 9 | import * as A from "fp-ts/lib/Array"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { apiFromToken } from "./api"; 13 | 14 | export const getPortfolio = ( 15 | pid: number, 16 | range?: ChartRange 17 | ): Action => { 18 | return pipe( 19 | apiFromToken, 20 | TE.chain(({ portfolio }) => portfolio.get(pid, range)) 21 | ); 22 | }; 23 | 24 | export const getPortfolios = ( 25 | range?: ChartRange 26 | ): Action => { 27 | return pipe( 28 | apiFromToken, 29 | TE.chain(({ portfolio: p }) => p.getMany(range)), 30 | TE.map(A.sort(byPortfolioChangePct)) 31 | ); 32 | }; 33 | 34 | export const updatePortfolio = ( 35 | pid: number, 36 | p: PostPortfolio 37 | ): Action => { 38 | return pipe( 39 | apiFromToken, 40 | TE.chain(({ portfolio }) => portfolio.update(pid, p)) 41 | ); 42 | }; 43 | 44 | export const createPortfolio = ( 45 | p: PostPortfolio 46 | ): Action => { 47 | return pipe( 48 | apiFromToken, 49 | TE.chain(({ portfolio }) => portfolio.create(p)) 50 | ); 51 | }; 52 | 53 | export const deletePortfolio = (portfolioId: number): Action => { 54 | return pipe( 55 | apiFromToken, 56 | TE.chain(({ portfolio }) => portfolio.delete(portfolioId)) 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /packages/web/src/stores/txs.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | GetTx, 4 | Identity, 5 | PostTx, 6 | } from "@darkruby/assets-core"; 7 | import { signal } from "@preact/signals-react"; 8 | import { pipe } from "fp-ts/lib/function"; 9 | import * as TE from "fp-ts/lib/TaskEither"; 10 | import { createTx, deleteTx, getTxs, updateTx } from "../services/txs"; 11 | import { type StoreBase, createStoreBase } from "./base"; 12 | 13 | export type TxsStore = Identity< 14 | StoreBase & { 15 | load: (aid: number) => ActionResult; 16 | create: (aid: number, p: PostTx) => ActionResult; 17 | update: (aid: number, tid: number, p: PostTx) => ActionResult; 18 | delete: (aid: number, tid: number) => ActionResult; 19 | } 20 | >; 21 | 22 | export const createTxsStore = (): TxsStore => { 23 | const data = signal([]); 24 | const storeBase = createStoreBase(data, () => []); 25 | 26 | return { 27 | ...storeBase, 28 | load: (aid: number) => storeBase.run(getTxs(aid)), 29 | create: (aid: number, p: PostTx) => 30 | storeBase.run( 31 | pipe( 32 | createTx(aid, p), 33 | TE.chain(() => getTxs(aid)) 34 | ) 35 | ), 36 | update: (aid: number, tid: number, p: PostTx) => 37 | storeBase.run( 38 | pipe( 39 | updateTx(aid, tid, p), 40 | TE.chain(() => getTxs(aid)) 41 | ) 42 | ), 43 | delete: (aid: number, tid: number) => 44 | storeBase.run( 45 | pipe( 46 | deleteTx(aid, tid), 47 | TE.chain(() => getTxs(aid)) 48 | ) 49 | ), 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/web/assets/search 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/web/src/services/assets.ts: -------------------------------------------------------------------------------- 1 | import { 2 | byAssetChangePct, 3 | type Action, 4 | type EnrichedAsset, 5 | type Id, 6 | type PostAsset, 7 | } from "@darkruby/assets-core"; 8 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 9 | import * as A from "fp-ts/lib/Array"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { apiFromToken } from "./api"; 13 | 14 | export const getAssets = ( 15 | pid: number, 16 | range?: ChartRange 17 | ): Action => { 18 | return pipe( 19 | apiFromToken, 20 | TE.chain(({ asset }) => asset.getMany(pid, range)), 21 | TE.map(A.sort(byAssetChangePct)) 22 | ); 23 | }; 24 | 25 | export const getAsset = ( 26 | pid: number, 27 | aid: number, 28 | range?: ChartRange 29 | ): Action => { 30 | return pipe( 31 | apiFromToken, 32 | TE.chain(({ asset }) => asset.get(pid, aid, range)) 33 | ); 34 | }; 35 | 36 | export const deleteAsset = (pid: number, aid: number): Action => { 37 | return pipe( 38 | apiFromToken, 39 | TE.chain(({ asset }) => asset.delete(pid, aid)) 40 | ); 41 | }; 42 | 43 | export const createAsset = ( 44 | pid: number, 45 | a: PostAsset 46 | ): Action => { 47 | return pipe( 48 | apiFromToken, 49 | TE.chain(({ asset }) => asset.create(pid, a)) 50 | ); 51 | }; 52 | 53 | export const updateAsset = ( 54 | pid: number, 55 | aid: number, 56 | a: PostAsset 57 | ): Action => { 58 | return pipe( 59 | apiFromToken, 60 | TE.chain(({ asset }) => asset.update(aid, pid, a)) 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/web/src/stores/users.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | GetUser, 4 | Identity, 5 | NewUser, 6 | PostUser, 7 | UserId, 8 | } from "@darkruby/assets-core"; 9 | import { signal } from "@preact/signals-react"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { 13 | createUser, 14 | deleteUser, 15 | getUsers, 16 | updateUser, 17 | } from "../services/users"; 18 | import { type StoreBase, createStoreBase } from "./base"; 19 | 20 | export type UsersStore = Identity< 21 | StoreBase & { 22 | load: () => ActionResult; 23 | create: (creds: NewUser) => ActionResult; 24 | update: (uid: UserId, credes: PostUser) => ActionResult; 25 | delete: (uid: UserId) => ActionResult; 26 | } 27 | >; 28 | 29 | export const createUsersStore = (): UsersStore => { 30 | const data = signal([]); 31 | const storeBase = createStoreBase(data); 32 | 33 | return { 34 | ...storeBase, 35 | load: () => storeBase.run(getUsers()), 36 | create: (creds: NewUser) => 37 | storeBase.run( 38 | pipe( 39 | createUser(creds), 40 | TE.chain(() => getUsers()) 41 | ) 42 | ), 43 | update: (uid: UserId, creds: PostUser) => 44 | storeBase.run( 45 | pipe( 46 | updateUser(uid, creds), 47 | TE.chain(() => getUsers()) 48 | ) 49 | ), 50 | delete: (uid: UserId) => 51 | storeBase.run( 52 | pipe( 53 | deleteUser(uid), 54 | TE.chain(() => getUsers()) 55 | ) 56 | ), 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /packages/assets-core/test/validation/user.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { 3 | newUserValidator, 4 | passwordChangeValidator, 5 | postUserValidator, 6 | type NewUser, 7 | type PasswordChange, 8 | type PostUser, 9 | } from "../../src"; 10 | 11 | const validNewUser: NewUser = { 12 | admin: false, 13 | locked: false, 14 | username: "abc123", 15 | password: "abc123", 16 | }; 17 | 18 | const validPwdChange: PasswordChange = { 19 | oldPassword: "abc123", 20 | newPassword: "mickey", 21 | repeat: "mickey", 22 | }; 23 | 24 | test("passes NewUser validation", () => { 25 | const { valid } = newUserValidator(validNewUser); 26 | expect(valid).toBeTrue(); 27 | }); 28 | 29 | test("fails NewUser username validation", () => { 30 | const newUser: NewUser = { 31 | ...validNewUser, 32 | username: "abc", 33 | }; 34 | const { valid } = newUserValidator(newUser); 35 | expect(valid).toBeFalse(); 36 | }); 37 | 38 | test("fails PostUser username validation", () => { 39 | const postUser: PostUser = { 40 | ...validNewUser, 41 | username: "123", 42 | login_attempts: 0, 43 | }; 44 | const { valid } = postUserValidator(postUser); 45 | expect(valid).toBeFalse(); 46 | }); 47 | 48 | test("passes change-password", () => { 49 | const { valid } = passwordChangeValidator(validPwdChange); 50 | expect(valid).toBeTrue(); 51 | }); 52 | 53 | test("fails change-password different passwords", () => { 54 | const pwdChange: PasswordChange = { 55 | ...validPwdChange, 56 | repeat: "mouse", 57 | }; 58 | const { valid } = passwordChangeValidator(pwdChange); 59 | expect(valid).toBeFalse(); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/web/src/stores/base.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Nullable, Result } from "@darkruby/assets-core"; 2 | import { Signal, signal } from "@preact/signals-react"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import { type AppError } from "../../../assets-core/src/domain/error"; 6 | 7 | export type StoreBase = { 8 | error: Signal>; 9 | fetching: Signal; 10 | data: Signal; 11 | 12 | run: (action: Action) => Promise>; 13 | reset: () => void; 14 | }; 15 | 16 | export const createStoreBase = ( 17 | data: Signal, 18 | defaultValue: () => T = () => null as T 19 | ): StoreBase => { 20 | const error = signal>(null); 21 | const fetching = signal(false); 22 | 23 | const reset = (): void => { 24 | data.value = defaultValue(); 25 | }; 26 | 27 | const run = async (action: Action): Promise> => { 28 | return pipe( 29 | TE.fromIO(() => { 30 | fetching.value = true; 31 | error.value = null; 32 | }), 33 | TE.chain(() => action), 34 | TE.chainFirstIOK((value) => () => { 35 | // console.info("ok", value); 36 | fetching.value = false; 37 | data.value = value; 38 | }), 39 | TE.orElseW((err: AppError) => { 40 | // console.error(err); 41 | return pipe( 42 | TE.fromIO(() => { 43 | fetching.value = false; 44 | error.value = err; 45 | return err; 46 | }), 47 | TE.swap 48 | ); 49 | }) 50 | )(); 51 | }; 52 | 53 | return { data, error, fetching, run, reset }; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/profile.ts: -------------------------------------------------------------------------------- 1 | import { type GetUser, type Id, type Optional } from "@darkruby/assets-core"; 2 | import { type HandlerTask } from "@darkruby/fp-express"; 3 | import * as TE from "fp-ts/TaskEither"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import { mapWebError } from "../domain/error"; 6 | import type { Context } from "./context"; 7 | 8 | export const getProfile: HandlerTask = ({ 9 | params: [, res], 10 | context: { repo, service }, 11 | }) => 12 | pipe( 13 | service.auth.requireUserId(res), 14 | mapWebError, 15 | TE.chain(service.user.get) 16 | ); 17 | 18 | export const updateProfile: HandlerTask = ({ 19 | params: [req, res], 20 | context: { repo, service }, 21 | }) => 22 | pipe( 23 | TE.Do, 24 | TE.bind("userId", () => service.auth.requireUserId(res)), 25 | mapWebError, 26 | TE.chain(({ userId }) => 27 | service.user.updateOwnProfileOnly(userId, req.body) 28 | ) 29 | ); 30 | 31 | export const updatePassword: HandlerTask = ({ 32 | params: [req, res], 33 | context: { repo, service }, 34 | }) => 35 | pipe( 36 | TE.Do, 37 | TE.bind("profile", () => service.auth.requireProfile(res)), 38 | mapWebError, 39 | TE.chain(({ profile }) => 40 | service.user.updateOwnPasswordOnly(profile, req.body) 41 | ) 42 | ); 43 | 44 | export const deleteProfile: HandlerTask, Context> = ({ 45 | params: [, res], 46 | context: { repo, service }, 47 | }) => 48 | pipe( 49 | TE.Do, 50 | TE.bind("userId", () => service.auth.requireUserId(res)), 51 | mapWebError, 52 | TE.chain(({ userId }) => service.user.delete(userId)) 53 | ); 54 | -------------------------------------------------------------------------------- /packages/web/assets/filter 16x16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/assets-core/src/validation/util.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either"; 2 | import * as t from "io-ts"; 3 | import { validationErr } from "../decoders/util"; 4 | 5 | export type Validator = ReturnType; 6 | 7 | export const createValidator = 8 | (decoder: t.Decoder) => 9 | (value: unknown) => { 10 | const v = decoder.decode(value); 11 | return { 12 | get errors() { 13 | if (E.isLeft(v)) { 14 | const errors = v.left.map((e) => e.message ?? "Validation error"); 15 | return errors; 16 | } 17 | return []; 18 | }, 19 | get valid() { 20 | return E.isRight(v); 21 | }, 22 | }; 23 | }; 24 | 25 | export const filter = (predicate: () => boolean, message: string) => 26 | E.filterOrElse(predicate, () => [validationErr(message)]); 27 | 28 | export const match = (pwd: string, pwd2: string) => 29 | filter(() => pwd == pwd2, `Passwords do not match`); 30 | export const length = (n: number) => (pwd: string) => 31 | filter(() => pwd.length >= n, `Length must be >=${n}`); 32 | export const upper = (pwd: string) => 33 | filter(() => /[A-Z]/.test(pwd), `Uppercase characters missing`); 34 | export const lower = (pwd: string) => 35 | filter(() => /[a-z]/.test(pwd), `Lower characters missing`); 36 | export const numbers = (pwd: string) => 37 | filter(() => /\d/.test(pwd), `Number characters missing`); 38 | export const special = (pwd: string) => 39 | filter(() => /\W/.test(pwd), `Special characters missing`); 40 | export const noWhiteSpace = (pwd: string) => 41 | filter(() => !/\s/.test(pwd), `No whitespace`); 42 | export const alphaNumOnly = (str: string) => 43 | filter(() => /^[a-zA-Z0-9]*$/.test(str), `Alpa numeric only`); 44 | -------------------------------------------------------------------------------- /packages/web/src/components/Profile/ProfileDetails.tsx: -------------------------------------------------------------------------------- 1 | import type { Profile } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import * as React from "react"; 5 | import { Form, InputGroup } from "react-bootstrap"; 6 | import { withFetching } from "../../decorators/fetching"; 7 | import { yesNo } from "../../util/yesno"; 8 | import { DangerButton } from "../Form/FormControl"; 9 | import { confirmationModal } from "../Modals/Confirmation"; 10 | 11 | type ProfileDetailsProps = { 12 | profile: Profile; 13 | onDelete: () => void; 14 | }; 15 | 16 | const RawProfileDetails: React.FC = ({ 17 | profile, 18 | onDelete, 19 | }) => { 20 | const handleDelete = () => 21 | pipe( 22 | () => 23 | confirmationModal( 24 | `Delete this profile? This action can not be undone.` 25 | ), 26 | TE.map(onDelete) 27 | )(); 28 | 29 | return ( 30 | <> 31 | 32 | Id 33 | 34 | 35 | 36 | Username 37 | 38 | 39 | 40 | Admin 41 | 42 | 43 | Delete Profile 44 | 45 | ); 46 | }; 47 | 48 | export const ProfileDetails = pipe(RawProfileDetails, withFetching); 49 | -------------------------------------------------------------------------------- /packages/assets-core/src/http/yahoo.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import { YahooChartDataDecoder } from "../decoders/yahoo/chart"; 3 | import { DEFAULT_CHART_RANGE, type ChartRange } from "../decoders/yahoo/meta"; 4 | import { YahooTickerSearchResultDecoder } from "../decoders/yahoo/ticker"; 5 | import { 6 | intervalForRange, 7 | type YahooChartData, 8 | type YahooTickerSearchResult, 9 | } from "../domain/yahoo"; 10 | import type { Action } from "../utils/utils"; 11 | import { methods, type Methods } from "./rest"; 12 | 13 | export const getYahooApi = (methods: Methods) => { 14 | const SEARCH_URL = (term: string) => 15 | `https://query2.finance.yahoo.com/v1/finance/search?q=${term}`; 16 | const CHART_URL = ( 17 | symbol: string, 18 | range: ChartRange = DEFAULT_CHART_RANGE 19 | ) => { 20 | const interval = intervalForRange(range); 21 | return `https://query1.finance.yahoo.com/v8/finance/chart/${symbol}?range=${range}&interval=${interval}`; 22 | }; 23 | 24 | const search = (term: string): Action => { 25 | return pipe( 26 | methods.get( 27 | SEARCH_URL(term), 28 | YahooTickerSearchResultDecoder 29 | ) 30 | ); 31 | }; 32 | 33 | const chart = ( 34 | symbol: string, 35 | range?: ChartRange 36 | ): Action => { 37 | return methods.get( 38 | CHART_URL(symbol, range), 39 | YahooChartDataDecoder 40 | ); 41 | }; 42 | 43 | return { search, chart }; 44 | }; 45 | 46 | export type YahooApi = ReturnType; 47 | 48 | export const createYahooApi = () => { 49 | return pipe(methods(), getYahooApi); 50 | }; 51 | 52 | export const yahooApi = createYahooApi(); 53 | -------------------------------------------------------------------------------- /packages/web/src/stores/store.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { createAssetStore, type AssetStore } from "./asset"; 3 | import { createAssetsStore, type AssetsStore } from "./assets"; 4 | import { createAuthStore, type AuthStore } from "./auth"; 5 | import { createPortfolioStore, type PortfolioStore } from "./portfolio"; 6 | import { createPortfoliosStore, type PortfoliosStore } from "./portfolios"; 7 | import { createPrefsStore, type PrefsStore } from "./prefs"; 8 | import { createProfileStore, type ProfileStore } from "./profile"; 9 | import { createSummaryStore, type SummaryStore } from "./summary"; 10 | import { createTxStore, type TxStore } from "./tx"; 11 | import { createTxsStore, type TxsStore } from "./txs"; 12 | import { createUsersStore, type UsersStore } from "./users"; 13 | 14 | export type Store = { 15 | tx: TxStore; 16 | txs: TxsStore; 17 | auth: AuthStore; 18 | asset: AssetStore; 19 | users: UsersStore; 20 | assets: AssetsStore; 21 | summary: SummaryStore; 22 | profile: ProfileStore; 23 | portfolio: PortfolioStore; 24 | portfolios: PortfoliosStore; 25 | prefs: PrefsStore; 26 | }; 27 | 28 | export const createStore = (): Store => ({ 29 | tx: createTxStore(), 30 | txs: createTxsStore(), 31 | auth: createAuthStore(), 32 | users: createUsersStore(), 33 | asset: createAssetStore(), 34 | assets: createAssetsStore(), 35 | summary: createSummaryStore(), 36 | profile: createProfileStore(), 37 | portfolio: createPortfolioStore(), 38 | portfolios: createPortfoliosStore(), 39 | prefs: createPrefsStore(), 40 | }); 41 | 42 | export const createStoreContext = (): [Store, React.Context] => { 43 | const store = createStore(); 44 | const context = createContext(store); 45 | return [store, context]; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/backend/src/services/yahoo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | yahooApi as rawYahooApi, 3 | validationError, 4 | type Action, 5 | type YahooApi, 6 | } from "@darkruby/assets-core"; 7 | import { 8 | DEFAULT_CHART_RANGE, 9 | type ChartRange, 10 | } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 11 | import { createLogger } from "@darkruby/fp-express"; 12 | import * as A from "fp-ts/lib/Array"; 13 | import { identity, pipe } from "fp-ts/lib/function"; 14 | import * as TE from "fp-ts/lib/TaskEither"; 15 | import type { AppCache } from "./cache"; 16 | 17 | const logger = createLogger("cached yahoo"); 18 | 19 | export const cachedYahooApi = (cache: AppCache): YahooApi => { 20 | const CHART_TTL = 1000 * 60 * 1; // 1 minutes 21 | const SEARCH_TTL = 1000 * 60 * 10; // 10 minutes 22 | return { 23 | search: (term: string) => 24 | cache.cachedAction( 25 | `yahoo-search-${term}`, 26 | () => rawYahooApi.search(term), 27 | SEARCH_TTL 28 | ), 29 | chart: (symbol: string, range?: ChartRange) => 30 | cache.cachedAction( 31 | `yahoo-chart-${symbol}-${range ?? DEFAULT_CHART_RANGE}`, 32 | () => rawYahooApi.chart(symbol, range), 33 | CHART_TTL 34 | ), 35 | }; 36 | }; 37 | 38 | export const checkTickerExists = 39 | (yahooApi: YahooApi) => 40 | (ticker: string): Action => { 41 | logger.info(`checking symbol: ${ticker}`); 42 | return pipe( 43 | yahooApi.search(ticker), 44 | TE.map((a) => a.quotes), 45 | TE.map(A.map((q) => q.symbol)), 46 | TE.map( 47 | A.exists((s) => s.toLocaleUpperCase() == ticker.toLocaleUpperCase()) 48 | ), 49 | TE.filterOrElse(identity, () => 50 | validationError(`Symbol '${ticker}' cannot be added`) 51 | ) 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/web/src/components/TopNav.tsx: -------------------------------------------------------------------------------- 1 | import type { Profile } from "@darkruby/assets-core"; 2 | import { useSignals } from "@preact/signals-react/runtime"; 3 | import { pipe } from "fp-ts/lib/function"; 4 | import { useEffect } from "react"; 5 | import { Container, Dropdown, DropdownButton, Navbar } from "react-bootstrap"; 6 | import { Link } from "react-router"; 7 | import { withNoData } from "../decorators/nodata"; 8 | import { useStore } from "../hooks/store"; 9 | import { NavCrumb } from "./Breadcrumb/Breadcrumb"; 10 | 11 | export const TopNav = () => { 12 | useSignals(); 13 | const { profile, portfolio, prefs, asset } = useStore(); 14 | 15 | useEffect(() => { 16 | profile.load(); 17 | prefs.load(); 18 | }, [profile]); 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | const RawProfileLink: React.FC<{ profile: Profile }> = ({ profile }) => { 33 | return ( 34 | 35 | 36 | Profile 37 | 38 | 41 | 42 | 43 | Logout 44 | 45 | 46 | ); 47 | }; 48 | 49 | const ProfileLink = pipe( 50 | RawProfileLink, 51 | withNoData((p) => p.profile, null) 52 | ); 53 | -------------------------------------------------------------------------------- /packages/web/src/stores/auth.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Action, 3 | ActionResult, 4 | Credentials, 5 | Identity, 6 | Nullable, 7 | Token, 8 | } from "@darkruby/assets-core"; 9 | import { computed, type ReadonlySignal, signal } from "@preact/signals-react"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { login, logout } from "../services/api"; 13 | import { refreshWhenCloseToExpiry } from "../services/auth"; 14 | import { readToken } from "../services/token"; 15 | import { createStoreBase, type StoreBase } from "./base"; 16 | 17 | export type AuthStore = Identity< 18 | StoreBase> & { 19 | load: () => ActionResult>; 20 | login: ( 21 | creds: Credentials, 22 | onSuccess: Action 23 | ) => ActionResult>; 24 | logout: () => ActionResult>; 25 | tokenExists: ReadonlySignal; 26 | refresh: () => ActionResult>; 27 | } 28 | >; 29 | 30 | export const createAuthStore = (): AuthStore => { 31 | const data = signal>(null); 32 | const storeBase = createStoreBase(data); 33 | 34 | const tokenExists = computed(() => data.value != null); 35 | 36 | const getToken: Action> = pipe( 37 | TE.fromEither(readToken()), 38 | TE.orElseW((e) => TE.of(null)) 39 | ); 40 | 41 | return { 42 | ...storeBase, 43 | tokenExists, 44 | load: () => storeBase.run(getToken), 45 | logout: () => storeBase.run(logout()), 46 | login: (creds, onSuccess) => 47 | storeBase.run( 48 | pipe( 49 | login(creds), 50 | TE.tap(() => onSuccess) 51 | ) 52 | ), 53 | refresh: () => storeBase.run(refreshWhenCloseToExpiry()), 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/web/src/decorators/nodata.tsx: -------------------------------------------------------------------------------- 1 | import type { Identity, Nullable, Optional } from "@darkruby/assets-core"; 2 | import React from "react"; 3 | import { Alert } from "react-bootstrap"; 4 | import type { Props } from "./fetching"; 5 | 6 | export type WithNoData = Identity< 7 | { [k in K]: Nullable } & Omit 8 | >; 9 | 10 | export function withNoData

( 11 | getter: (p: WithNoData) => Optional, 12 | fallback: React.ReactNode = No data to show.. 13 | ) { 14 | return function (Component: React.FC

): React.FC> { 15 | return (props: WithNoData) => { 16 | const r = getter(props); 17 | return r === null || r == undefined ? ( 18 | fallback 19 | ) : ( 20 | 21 | ); 22 | }; 23 | }; 24 | } 25 | 26 | export function withCondition

( 27 | condition: (p: P) => boolean, 28 | onConditions: (p: P) => React.ReactNode 29 | ) { 30 | return function (Component: React.FC

): React.FC

{ 31 | return (props: P) => { 32 | const c = condition(props); 33 | if (!c) { 34 | return onConditions(props as P); 35 | } 36 | return ; 37 | }; 38 | }; 39 | } 40 | 41 | export type WithVisibility = Identity< 42 | TProps & { hidden?: boolean } 43 | >; 44 | export function withVisibility

() { 45 | return function (Component: React.FC

): React.FC> { 46 | return ({ hidden, ...props }: WithVisibility

) => { 47 | if (hidden) { 48 | return null; 49 | } 50 | return ; 51 | }; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /packages/web/src/stores/portfolio.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | EnrichedPortfolio, 4 | Identity, 5 | Nullable, 6 | PostPortfolio, 7 | } from "@darkruby/assets-core"; 8 | import { signal } from "@preact/signals-react"; 9 | import { pipe } from "fp-ts/lib/function"; 10 | 11 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 12 | import * as TE from "fp-ts/lib/TaskEither"; 13 | import { 14 | createPortfolio, 15 | deletePortfolio, 16 | getPortfolio, 17 | updatePortfolio, 18 | } from "../services/portfolios"; 19 | import { type StoreBase, createStoreBase } from "./base"; 20 | 21 | export type PortfolioStore = Identity< 22 | StoreBase> & { 23 | load: ( 24 | portfolioId: number, 25 | range?: ChartRange 26 | ) => ActionResult>; 27 | update: ( 28 | pid: number, 29 | p: PostPortfolio 30 | ) => ActionResult>; 31 | create: (a: PostPortfolio) => ActionResult>; 32 | delete: (pid: number) => ActionResult>; 33 | } 34 | >; 35 | 36 | export const createPortfolioStore = (): PortfolioStore => { 37 | const data = signal>(null); 38 | const storeBase = createStoreBase(data); 39 | 40 | return { 41 | ...storeBase, 42 | load: (portfolioId: number, range?: ChartRange) => 43 | storeBase.run(getPortfolio(portfolioId, range)), 44 | update: (pid: number, p: PostPortfolio) => 45 | storeBase.run(updatePortfolio(pid, p)), 46 | create: (p: PostPortfolio) => storeBase.run(createPortfolio(p)), 47 | 48 | delete: (pid: number) => 49 | storeBase.run( 50 | pipe( 51 | deletePortfolio(pid), 52 | TE.map(() => null) 53 | ) 54 | ), 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/web/src/util/modal.tsx: -------------------------------------------------------------------------------- 1 | import { generalError, type Result } from "@darkruby/assets-core"; 2 | import * as E from "fp-ts/lib/Either"; 3 | import { createRoot } from "react-dom/client"; 4 | 5 | export type DialogDrivenComponentProps< 6 | T, 7 | PP extends Record = {} 8 | > = { 9 | value?: T; 10 | onSubmit: (t: T) => void; 11 | onClose: () => void; 12 | open: boolean; 13 | } & PP; 14 | 15 | export type Dialog> = React.FC

; 16 | 17 | export const createDialog = 18 | >(Component: Dialog) => 19 | (props: Omit): Promise> => { 20 | const containerElement = document.createElement("div", {}); 21 | containerElement.id = "dialog-root"; 22 | const root = document.getElementById("root"); 23 | root?.appendChild(containerElement); 24 | const container = createRoot(containerElement); 25 | 26 | const render = (p: P): Promise => { 27 | const newProps = { ...props, ...p } as P; 28 | 29 | return Promise.resolve( 30 | container.render() 31 | ); 32 | }; 33 | 34 | const confirmation = new Promise>((resolve, reject) => { 35 | const p = { 36 | ...props, 37 | onSubmit: (r: T) => resolve(E.of(r)), 38 | onClose: () => resolve(E.left(generalError("modal cacnelled"))), 39 | open: true, 40 | } as unknown as P; 41 | 42 | return render(p); 43 | }); 44 | 45 | return confirmation.finally(() => 46 | render({ 47 | ...props, 48 | onSubmit: () => void 0, 49 | onClose: () => void 0, 50 | open: false, 51 | } as unknown as P).then(() => { 52 | containerElement?.remove(); 53 | }) 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/backend/src/services/env.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generalError, 3 | handleError, 4 | type Action, 5 | type Nullable, 6 | } from "@darkruby/assets-core"; 7 | import { flow, pipe } from "fp-ts/lib/function"; 8 | import * as IO from "fp-ts/lib/IOEither"; 9 | import * as TE from "fp-ts/lib/TaskEither"; 10 | import ms from "ms"; 11 | 12 | export const env = ( 13 | name: string, 14 | defaultValue: Nullable = null 15 | ): Action => 16 | pipe( 17 | IO.tryCatch(() => { 18 | const val = process.env[name] ?? defaultValue; 19 | if (val) return val; 20 | throw Error(`${name} is not defined`); 21 | }, handleError("Environment variable")), 22 | TE.fromIOEither 23 | ); 24 | 25 | export const envNumber = ( 26 | name: string, 27 | defaultValue: Nullable = null 28 | ): Action => 29 | pipe( 30 | env(name, defaultValue?.toString()), 31 | TE.chain((s) => 32 | pipe( 33 | IO.tryCatch(() => parseFloat(s), handleError("parseFloat")), 34 | TE.fromIOEither 35 | ) 36 | ) 37 | ); 38 | 39 | // reads ms.StringValue fron env, returns number of milliseconds 40 | export const envDurationMsec = ( 41 | name: string, 42 | defaultValue: ms.StringValue 43 | ): Action => 44 | pipe( 45 | env(name, defaultValue), 46 | TE.map((s) => ms(s as ms.StringValue)), 47 | TE.filterOrElseW( 48 | (n) => n != undefined, 49 | () => generalError(`not an ms.StringValue`) 50 | ), 51 | TE.orElse(() => TE.of(ms(defaultValue))) 52 | ); 53 | 54 | export const envDurationSec = flow( 55 | envDurationMsec, 56 | TE.map((x) => x / 1000) 57 | ); 58 | 59 | export const envBoolean = ( 60 | name: string, 61 | defaultValue: Nullable = null 62 | ): Action => 63 | pipe( 64 | env(name, defaultValue?.toString()), 65 | TE.map((s) => s.toLowerCase().trim() === "true") 66 | ); 67 | -------------------------------------------------------------------------------- /packages/backend/src/repository/sql.ts: -------------------------------------------------------------------------------- 1 | import type { LazyArg } from "fp-ts/lib/function"; 2 | import { readFileSync } from "node:fs"; 3 | import { normalize } from "node:path"; 4 | 5 | export const getSql = (fileName: string): LazyArg => { 6 | const fname = `${__dirname}/sql/${normalize(fileName)}.sql`; 7 | const sql = readFileSync(fname).toString(); 8 | return () => sql; 9 | }; 10 | 11 | export const getAssetSql = getSql("asset/get"); 12 | export const getAssetsSql = getSql("asset/get-many"); 13 | export const insertAssetSql = getSql("asset/insert"); 14 | export const updateAssetSql = getSql("asset/update"); 15 | export const deleteAssetSql = getSql("asset/delete"); 16 | 17 | export const getPortfolioSql = getSql("portfolio/get"); 18 | export const getPortfoliosSql = getSql("portfolio/get-many"); 19 | export const insertPortfolioSql = getSql("portfolio/insert"); 20 | export const updatePortfolioSql = getSql("portfolio/update"); 21 | export const deletePortfolioSql = getSql("portfolio/delete"); 22 | 23 | export const getTxSql = getSql("tx/get"); 24 | export const getTxsSql = getSql("tx/get-many"); 25 | export const insertTxSql = getSql("tx/insert"); 26 | export const updateTxSql = getSql("tx/update"); 27 | export const deleteTxSql = getSql("tx/delete"); 28 | 29 | export const getUserSql = getSql("user/get"); 30 | export const getUsersSql = getSql("user/get-many"); 31 | export const resetAttemptsSql = getSql("user/reset"); 32 | export const insertUserSql = getSql("user/insert"); 33 | export const updateUserSql = getSql("user/update"); 34 | export const deleteUserSql = getSql("user/delete"); 35 | export const getUnlockedUserSql = getSql("user/get-unlocked"); 36 | export const loginAttemptUserSql = getSql("user/login-attempt"); 37 | export const updateProfileOnlySql = getSql("user/update-profile-only"); 38 | 39 | export const getPrefsSql = getSql("prefs/get"); 40 | export const updatePrefsSql = getSql("prefs/update"); 41 | -------------------------------------------------------------------------------- /packages/web/src/screens/Test.tsx: -------------------------------------------------------------------------------- 1 | import { defaultPortfolio } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as TE from "fp-ts/lib/TaskEither"; 4 | import { PrimaryButton, SecondaryButton } from "../components/Form/FormControl"; 5 | import { TabContent, Tabs } from "../components/Form/Tabs"; 6 | import { confirmationModal } from "../components/Modals/Confirmation"; 7 | import { PortfolioMenu } from "../components/Portfolio/Menu"; 8 | import { portfolioModal } from "../components/Portfolio/PortfolioFields"; 9 | import { TickerLookup } from "../components/Tx/TickerLookup"; 10 | import { decimal, money, percent } from "../util/number"; 11 | 12 | const RawTestScreen: React.FC = () => { 13 | const handler1 = () => { 14 | return pipe( 15 | () => portfolioModal(defaultPortfolio()), 16 | TE.tapIO((p) => () => console.log(p)) 17 | )(); 18 | }; 19 | 20 | const handler2 = () => { 21 | return pipe( 22 | () => confirmationModal("yes or maybe not"), 23 | TE.tapIO((p) => () => console.log(p)) 24 | )(); 25 | }; 26 | 27 | return ( 28 | <> 29 | 30 | 31 | click 32 | click 33 | 34 | 35 | 36 | 37 | 38 | 39 |

    40 |
  • {money(40123, "AUD", "fr-FR")}
  • 41 |
  • {decimal(0.012, 2, "de-DE")}
  • 42 |
  • {percent(0.012, 2, "de-DE")}
  • 43 |
44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | const TestScreen = RawTestScreen; 51 | export { TestScreen }; 52 | -------------------------------------------------------------------------------- /packages/web/src/stores/portfolios.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | EnrichedPortfolio, 4 | Identity, 5 | PostPortfolio, 6 | } from "@darkruby/assets-core"; 7 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 8 | import { signal } from "@preact/signals-react"; 9 | import * as TE from "fp-ts/lib/TaskEither"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import { 12 | createPortfolio, 13 | deletePortfolio, 14 | getPortfolios, 15 | updatePortfolio, 16 | } from "../services/portfolios"; 17 | import { type StoreBase, createStoreBase } from "./base"; 18 | 19 | export type PortfoliosStore = Identity< 20 | StoreBase & { 21 | load: (range?: ChartRange) => ActionResult; 22 | create: (p: PostPortfolio) => ActionResult; 23 | update: ( 24 | pid: number, 25 | p: PostPortfolio 26 | ) => ActionResult; 27 | delete: (pid: number) => ActionResult; 28 | } 29 | >; 30 | 31 | export const createPortfoliosStore = (): PortfoliosStore => { 32 | const data = signal([]); 33 | const storeBase = createStoreBase(data, () => []); 34 | 35 | return { 36 | ...storeBase, 37 | load: (range?: ChartRange) => storeBase.run(getPortfolios(range)), 38 | create: (p: PostPortfolio) => 39 | storeBase.run( 40 | pipe( 41 | createPortfolio(p), 42 | TE.chain(() => getPortfolios()) 43 | ) 44 | ), 45 | update: (pid: number, p: PostPortfolio) => 46 | storeBase.run( 47 | pipe( 48 | updatePortfolio(pid, p), 49 | TE.chain(() => getPortfolios()) 50 | ) 51 | ), 52 | delete: (pid: number) => 53 | storeBase.run( 54 | pipe( 55 | deletePortfolio(pid), 56 | TE.chain(() => getPortfolios()) 57 | ) 58 | ), 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /packages/backend/src/repository/database.ts: -------------------------------------------------------------------------------- 1 | import { handleError, type Action, type Nullable } from "@darkruby/assets-core"; 2 | import { Database, type SQLQueryBindings } from "bun:sqlite"; 3 | import { pipe } from "fp-ts/lib/function"; 4 | import * as TE from "fp-ts/lib/TaskEither"; 5 | 6 | export type ExecutionResult = [lastId: number, rows: number]; 7 | 8 | export const queryMany = 9 | (bindings: SQLQueryBindings = null) => 10 | (sql: Action) => 11 | (db: Database): Action => { 12 | return pipe( 13 | sql, 14 | TE.chain((sql) => 15 | TE.tryCatch( 16 | async () => db.query(sql).all(bindings), 17 | handleError() 18 | ) 19 | ) 20 | ); 21 | }; 22 | 23 | export const queryOne = 24 | (bindings: SQLQueryBindings = null) => 25 | (sql: Action) => 26 | (db: Database): Action> => { 27 | return pipe( 28 | sql, 29 | TE.chain((sql) => 30 | TE.tryCatch( 31 | async () => db.query(sql).get(bindings), 32 | handleError() 33 | ) 34 | ) 35 | ); 36 | }; 37 | 38 | export const transaction = 39 | (insideTransaction: () => R) => 40 | (db: Database): Action> => { 41 | const transact = db.transaction(insideTransaction); 42 | return pipe(TE.tryCatch(async () => transact(), handleError())); 43 | }; 44 | 45 | export const execute = 46 | (bindings: SQLQueryBindings = null) => 47 | (sql: Action) => 48 | (db: Database): Action => { 49 | return pipe( 50 | sql, 51 | TE.chain((sql) => 52 | TE.tryCatch( 53 | async () => db.query(sql).run(bindings), 54 | handleError() 55 | ) 56 | ), 57 | TE.map( 58 | ({ changes, lastInsertRowid }) => 59 | [lastInsertRowid as number, changes] as const 60 | ) 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/web/src/stores/assets.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | EnrichedAsset, 4 | Identity, 5 | PostAsset, 6 | } from "@darkruby/assets-core"; 7 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 8 | import { signal } from "@preact/signals-react"; 9 | import { pipe } from "fp-ts/lib/function"; 10 | import * as TE from "fp-ts/lib/TaskEither"; 11 | import { 12 | createAsset, 13 | deleteAsset, 14 | getAssets, 15 | updateAsset, 16 | } from "../services/assets"; 17 | import { type StoreBase, createStoreBase } from "./base"; 18 | 19 | export type AssetsStore = Identity< 20 | StoreBase & { 21 | load: (pid: number, range?: ChartRange) => ActionResult; 22 | create: (pid: number, a: PostAsset) => ActionResult; 23 | update: ( 24 | pid: number, 25 | aid: number, 26 | a: PostAsset 27 | ) => ActionResult; 28 | delete: (pid: number, aid: number) => ActionResult; 29 | } 30 | >; 31 | 32 | export const createAssetsStore = (): AssetsStore => { 33 | const data = signal([]); 34 | const storeBase = createStoreBase(data); 35 | 36 | return { 37 | ...storeBase, 38 | load: (pid: number, range?: ChartRange) => 39 | storeBase.run(getAssets(pid, range)), 40 | create: (pid: number, a: PostAsset) => 41 | storeBase.run( 42 | pipe( 43 | createAsset(pid, a), 44 | TE.chain(() => getAssets(pid)) 45 | ) 46 | ), 47 | update: (pid: number, aid: number, a: PostAsset) => 48 | storeBase.run( 49 | pipe( 50 | updateAsset(pid, aid, a), 51 | TE.chain(() => getAssets(pid)) 52 | ) 53 | ), 54 | delete: (pid: number, aid: number) => 55 | storeBase.run( 56 | pipe( 57 | deleteAsset(pid, aid), 58 | TE.chain(() => getAssets(pid)) 59 | ) 60 | ), 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/backend/src/handlers/user.ts: -------------------------------------------------------------------------------- 1 | import { type GetUser, type Id, type Optional } from "@darkruby/assets-core"; 2 | import { type HandlerTask } from "@darkruby/fp-express"; 3 | import * as TE from "fp-ts/TaskEither"; 4 | import { pipe } from "fp-ts/lib/function"; 5 | import { userIdFromUrl } from "../decoders/params"; 6 | import { mapWebError } from "../domain/error"; 7 | import type { Context } from "./context"; 8 | 9 | export const deleteUser: HandlerTask, Context> = ({ 10 | params: [req, res], 11 | context: { repo, service }, 12 | }) => 13 | pipe( 14 | service.auth.requireAdminProfile(res), 15 | TE.chain(() => userIdFromUrl(req.params.id)), 16 | mapWebError, 17 | TE.chain(service.user.delete) 18 | ); 19 | 20 | export const getUsers: HandlerTask = ({ 21 | params: [, res], 22 | context: { repo, service }, 23 | }) => 24 | pipe( 25 | service.auth.requireAdminProfile(res), 26 | mapWebError, 27 | TE.chain(() => service.user.getMany()) 28 | ); 29 | 30 | export const getUser: HandlerTask = ({ 31 | params: [req, res], 32 | context: { repo, service }, 33 | }) => 34 | pipe( 35 | service.auth.requireAdminProfile(res), 36 | TE.chain(() => userIdFromUrl(req.params.id)), 37 | mapWebError, 38 | TE.chain(service.user.get) 39 | ); 40 | 41 | export const createUser: HandlerTask = ({ 42 | params: [req, res], 43 | context: { repo, service }, 44 | }) => 45 | pipe( 46 | service.auth.requireAdminProfile(res), 47 | mapWebError, 48 | TE.chain(() => service.user.create(req.body)) 49 | ); 50 | 51 | export const updateUser: HandlerTask = ({ 52 | params: [req, res], 53 | context: { repo, service }, 54 | }) => 55 | pipe( 56 | service.auth.requireAdminProfile(res), 57 | TE.chain(() => userIdFromUrl(req.params.id)), 58 | mapWebError, 59 | TE.chain((id) => service.user.updateProfileOnly(id, req.body)) 60 | ); 61 | -------------------------------------------------------------------------------- /packages/web/src/stores/asset.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionResult, 3 | EnrichedAsset, 4 | Identity, 5 | Nullable, 6 | PostAsset, 7 | } from "@darkruby/assets-core"; 8 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 9 | import { signal } from "@preact/signals-react"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import * as TE from "fp-ts/lib/TaskEither"; 12 | import { 13 | createAsset, 14 | deleteAsset, 15 | getAsset, 16 | updateAsset, 17 | } from "../services/assets"; 18 | import { type StoreBase, createStoreBase } from "./base"; 19 | 20 | export type AssetStore = Identity< 21 | StoreBase> & { 22 | load: ( 23 | portfolioId: number, 24 | assetId: number, 25 | range?: ChartRange 26 | ) => ActionResult>; 27 | create: ( 28 | portfolioId: number, 29 | asset: PostAsset 30 | ) => ActionResult>; 31 | update: ( 32 | portfolioId: number, 33 | assetId: number, 34 | asset: PostAsset 35 | ) => ActionResult>; 36 | delete: ( 37 | portfolioId: number, 38 | assetId: number 39 | ) => ActionResult>; 40 | } 41 | >; 42 | 43 | export const createAssetStore = (): AssetStore => { 44 | const data = signal>(null); 45 | const storeBase = createStoreBase(data); 46 | 47 | return { 48 | ...storeBase, 49 | load: (pid: number, aid: number, range?: ChartRange) => 50 | storeBase.run(getAsset(pid, aid, range)), 51 | create: (pid: number, a: PostAsset) => storeBase.run(createAsset(pid, a)), 52 | update: (pid: number, aid: number, a: PostAsset) => 53 | storeBase.run(updateAsset(pid, aid, a)), 54 | delete: (pid: number, aid: number) => 55 | storeBase.run( 56 | pipe( 57 | deleteAsset(pid, aid), 58 | TE.map(() => null) 59 | ) 60 | ), 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/web/src/components/Portfolio/PortfolioFields.tsx: -------------------------------------------------------------------------------- 1 | import { portfolioValidator, type PostPortfolio } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import { Form } from "react-bootstrap"; 4 | import { usePartialChange } from "../../hooks/formData"; 5 | import { createDialog } from "../../util/modal"; 6 | import type { PropsOf } from "../../util/props"; 7 | import { createForm, type FieldsProps } from "../Form/Form"; 8 | import { FormEdit } from "../Form/FormControl"; 9 | import { createModal } from "../Modals/Modal"; 10 | 11 | type PortfolioFieldsProps = FieldsProps; /*{ 12 | data: PostPortfolio; 13 | onChange: (p: PostPortfolio) => void; 14 | disabled?: boolean; 15 | };*/ 16 | 17 | const PortfolioFields: React.FC = ({ 18 | data, 19 | onChange, 20 | disabled, 21 | }) => { 22 | const setField = usePartialChange(data, onChange); 23 | 24 | return ( 25 |
26 | 27 | Name 28 | 33 | 34 | 35 | Description 36 | 41 | 42 |
43 | ); 44 | }; 45 | 46 | export const PortfolioForm = createForm( 47 | PortfolioFields, 48 | portfolioValidator 49 | ); 50 | 51 | export const PortfolioModal = createModal( 52 | PortfolioFields, 53 | portfolioValidator, 54 | "Portfolio" 55 | ); 56 | 57 | export const portfolioModal = (value: PostPortfolio) => 58 | pipe( 59 | { value }, 60 | createDialog>(PortfolioModal) 61 | ); 62 | -------------------------------------------------------------------------------- /packages/assets-core/test/data/FUND1.json: -------------------------------------------------------------------------------- 1 | { 2 | "chart": { 3 | "result": [ 4 | { 5 | "meta": { 6 | "currency": "GBP", 7 | "symbol": "0P0000KSPA.L", 8 | "exchangeName": "LSE", 9 | "fullExchangeName": "LSE", 10 | "instrumentType": "MUTUALFUND", 11 | "firstTradeDate": 1388649600, 12 | "regularMarketTime": 1743710400, 13 | "hasPrePostMarketData": false, 14 | "gmtoffset": 3600, 15 | "timezone": "BST", 16 | "exchangeTimezoneName": "Europe/London", 17 | "regularMarketPrice": 908.01, 18 | "fiftyTwoWeekHigh": 1108.192, 19 | "fiftyTwoWeekLow": 883.902, 20 | "longName": "Vanguard U.S. Eq Idx £ Acc", 21 | "shortName": "Vanguard U.S. Equity Index Fund", 22 | "chartPreviousClose": 971.705, 23 | "priceHint": 2, 24 | "currentTradingPeriod": { 25 | "pre": { 26 | "timezone": "BST", 27 | "start": 1743747300, 28 | "end": 1743750000, 29 | "gmtoffset": 3600 30 | }, 31 | "regular": { 32 | "timezone": "BST", 33 | "start": 1743750000, 34 | "end": 1743780600, 35 | "gmtoffset": 3600 36 | }, 37 | "post": { 38 | "timezone": "BST", 39 | "start": 1743780600, 40 | "end": 1743783300, 41 | "gmtoffset": 3600 42 | } 43 | }, 44 | "dataGranularity": "1d", 45 | "range": "1d", 46 | "validRanges": [ 47 | "1mo", 48 | "3mo", 49 | "6mo", 50 | "ytd", 51 | "1y", 52 | "2y", 53 | "5y", 54 | "10y", 55 | "max" 56 | ] 57 | }, 58 | "indicators": { "quote": [{}], "adjclose": [{}] } 59 | } 60 | ], 61 | "error": null 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/web/src/screens/Asset.tsx: -------------------------------------------------------------------------------- 1 | import type { PostAsset, PostTx } from "@darkruby/assets-core"; 2 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 3 | import { useSignals } from "@preact/signals-react/runtime"; 4 | import { use, useEffect } from "react"; 5 | import { useParams } from "react-router"; 6 | import { Asset } from "../components/Asset/Asset"; 7 | import { StoreContext } from "../hooks/store"; 8 | 9 | const RawAssetScreen: React.FC = () => { 10 | useSignals(); 11 | const { asset, txs, portfolio } = use(StoreContext); 12 | const { assetId, portfolioId } = useParams<{ 13 | assetId: string; 14 | portfolioId: string; 15 | }>(); 16 | 17 | useEffect(() => { 18 | portfolio.load(+portfolioId!); 19 | asset.load(+portfolioId!, +assetId!); 20 | txs.load(+assetId!); 21 | }, [asset]); 22 | 23 | const handleEdit = (a: PostAsset) => 24 | asset.update(+portfolioId!, +assetId!, a); 25 | 26 | const handleAddTx = async (tx: PostTx) => { 27 | await txs.create(+assetId!, tx); 28 | asset.load(+portfolioId!, +assetId!); 29 | }; 30 | const handleEditTx = async (txid: number, tx: PostTx) => { 31 | await txs.update(+assetId!, txid, tx); 32 | asset.load(+portfolioId!, +assetId!); 33 | }; 34 | 35 | const handleDeleteTx = async (txid: number) => { 36 | await txs.delete(+assetId!, txid); 37 | asset.load(+portfolioId!, +assetId!); 38 | }; 39 | 40 | const handleRange = (rng: ChartRange) => 41 | asset.load(+portfolioId!, +assetId!, rng); 42 | 43 | return ( 44 | <> 45 | 56 | 57 | ); 58 | }; 59 | 60 | export { RawAssetScreen as AssetScreen }; 61 | -------------------------------------------------------------------------------- /packages/backend/src/services/tx.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PostTxDecoder, 3 | type AssetId, 4 | type GetTx, 5 | type Id, 6 | type Optional, 7 | type TxId, 8 | type UserId, 9 | } from "@darkruby/assets-core"; 10 | import { liftTE } from "@darkruby/assets-core/src/decoders/util"; 11 | import type { WebAction } from "@darkruby/fp-express"; 12 | import { pipe } from "fp-ts/lib/function"; 13 | import * as TE from "fp-ts/TaskEither"; 14 | import { mapWebError } from "../domain/error"; 15 | import type { Repository } from "../repository"; 16 | 17 | const txDecoder = liftTE(PostTxDecoder); 18 | 19 | export const getTx = 20 | (repo: Repository) => 21 | ( 22 | txId: TxId, 23 | assetId: AssetId, 24 | userId: UserId 25 | ): WebAction> => { 26 | return pipe(repo.tx.get(txId, assetId, userId), mapWebError); 27 | }; 28 | 29 | export const getTxs = 30 | (repo: Repository) => 31 | (assetId: AssetId, userId: UserId): WebAction => { 32 | return pipe(repo.tx.getAll(assetId, userId), mapWebError); 33 | }; 34 | 35 | export const createTx = 36 | (repo: Repository) => 37 | (assetId: AssetId, userId: UserId, payload: unknown): WebAction => { 38 | return pipe( 39 | txDecoder(payload), 40 | TE.chain((tx) => repo.tx.create(tx, assetId, userId)), 41 | mapWebError 42 | ); 43 | }; 44 | 45 | export const updateTx = 46 | (repo: Repository) => 47 | ( 48 | txId: TxId, 49 | assetId: AssetId, 50 | userId: UserId, 51 | payload: unknown 52 | ): WebAction => { 53 | return pipe( 54 | txDecoder(payload), 55 | TE.chain((tx) => repo.tx.update(txId, tx, assetId, userId)), 56 | mapWebError 57 | ); 58 | }; 59 | 60 | export const deleteTx = 61 | (repo: Repository) => 62 | (txId: TxId, userId: UserId): WebAction> => { 63 | return pipe( 64 | repo.tx.delete(txId, userId), 65 | TE.map(([id, rows]) => (rows > 0 ? { id } : null)), 66 | mapWebError 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /packages/web/src/components/Profile/UserForm.tsx: -------------------------------------------------------------------------------- 1 | import { postUserValidator, type PostUser } from "@darkruby/assets-core"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | import * as React from "react"; 4 | import { Form } from "react-bootstrap"; 5 | import { usePartialChange } from "../../hooks/formData"; 6 | import { createDialog } from "../../util/modal"; 7 | import type { PropsOf } from "../../util/props"; 8 | import { createForm, type FieldsProps } from "../Form/Form"; 9 | import { CheckBox, FormEdit } from "../Form/FormControl"; 10 | import { createModal } from "../Modals/Modal"; 11 | 12 | type UserFieldsProps = FieldsProps; 13 | 14 | export const UserFields: React.FC = ({ 15 | data, 16 | onChange, 17 | disabled, 18 | }: UserFieldsProps) => { 19 | const setField = usePartialChange(data, onChange); 20 | return ( 21 |
22 | 23 | Username 24 | 29 | 30 | 31 | Admin   32 | 38 | Locked   39 | 45 | 46 |
47 | ); 48 | }; 49 | 50 | export const UserForm = createForm(UserFields, postUserValidator); 51 | 52 | export const UserModal = createModal( 53 | UserFields, 54 | postUserValidator, 55 | "User" 56 | ); 57 | 58 | export const userModal = (value: PostUser) => 59 | pipe({ value }, createDialog>(UserModal)); 60 | -------------------------------------------------------------------------------- /packages/web/src/components/Charts/RangesChart.tsx: -------------------------------------------------------------------------------- 1 | import { tfForRange, type ChartData } from "@darkruby/assets-core"; 2 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 3 | import { pipe } from "fp-ts/lib/function"; 4 | import * as React from "react"; 5 | import { useMemo } from "react"; 6 | import { Button, ButtonGroup } from "react-bootstrap"; 7 | import { withVisibility } from "../../decorators/nodata"; 8 | import { Chart } from "./Chart"; 9 | import "./Chart.scss"; 10 | 11 | type RangeChartProps = { 12 | data: ChartData; 13 | range: ChartRange; 14 | ranges: ChartRange[]; 15 | onChange: (r: ChartRange) => void; 16 | }; 17 | 18 | const RawRangeChart: React.FC = ({ 19 | data, 20 | range, 21 | ranges, 22 | onChange, 23 | }) => { 24 | const timeFormatter = useMemo(() => tfForRange(range), [range]); 25 | return ( 26 |
27 | 28 |
29 |
 
30 |
31 | 32 |
33 |
34 |
35 | ); 36 | }; 37 | 38 | type RangeButtonsProps = { 39 | ranges: ChartRange[]; 40 | range: ChartRange; 41 | className?: string; 42 | onChange: (r: ChartRange) => void; 43 | }; 44 | const RangeButtons: React.FC = ({ 45 | ranges, 46 | range, 47 | className, 48 | onChange, 49 | }) => { 50 | return ( 51 | 52 | {ranges.map((rng) => { 53 | const variant = rng == range ? "primary" : "secondary"; 54 | return ( 55 | 58 | ); 59 | })} 60 | 61 | ); 62 | }; 63 | 64 | export const RangeChart = pipe(RawRangeChart, withVisibility()); 65 | -------------------------------------------------------------------------------- /packages/backend/src/services/cache.ts: -------------------------------------------------------------------------------- 1 | import { type Action } from "@darkruby/assets-core"; 2 | import { createLogger } from "@darkruby/fp-express"; 3 | import * as O from "fp-ts/lib/Option"; 4 | import * as TE from "fp-ts/lib/TaskEither"; 5 | import { pipe } from "fp-ts/lib/function"; 6 | import { type LRUCache } from "lru-cache"; 7 | import { createHash } from "node:crypto"; 8 | 9 | export type Stringifiable = string | number | Buffer | boolean; 10 | export type Cache = LRUCache; 11 | 12 | const toKey = (...k: Stringifiable[]) => 13 | createHash("md5").update(k.map(String).join("")).digest("hex"); 14 | 15 | const log = createLogger("cache"); 16 | 17 | const has = (cache: Cache) => (key: string) => cache.has(toKey(key)); 18 | 19 | const getter = 20 | (cache: Cache) => 21 | (key: string): O.Option => { 22 | return pipe( 23 | O.tryCatch(() => cache.get(toKey(key))), 24 | O.filter((x) => x !== null && x !== undefined) 25 | ); 26 | }; 27 | 28 | const setter = 29 | (cache: Cache) => 30 | (key: string, val: T, ttl?: number): O.Option => { 31 | return pipe( 32 | O.of(val), 33 | O.chain(() => O.tryCatch(() => cache.set(toKey(key), val, { ttl }))), 34 | O.map(() => val) 35 | ); 36 | }; 37 | 38 | const cachedAction = 39 | (cache: Cache) => 40 | (key: string, f: () => Action, ttl?: number): Action => { 41 | const get = getter(cache); 42 | const res = get(key); 43 | if (O.isSome(res)) { 44 | log.debug(`hit for ${key}`); 45 | return TE.of(res.value as T); 46 | } 47 | log.debug(`miss for ${key}`); 48 | const set = setter(cache); 49 | 50 | return pipe( 51 | f(), 52 | TE.tapIO((res) => () => set(key, res, ttl)) 53 | ); 54 | }; 55 | 56 | export type AppCache = ReturnType; 57 | 58 | export const createCache = (cache: Cache) => { 59 | return { 60 | has: has(cache), 61 | getter: getter(cache), 62 | setter: setter(cache), 63 | cachedAction: cachedAction(cache), 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /packages/web/src/components/Asset/Asset.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type EnrichedAsset, 3 | type GetAsset, 4 | type GetTx, 5 | type PostTx, 6 | } from "@darkruby/assets-core"; 7 | import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; 8 | import { pipe } from "fp-ts/lib/function"; 9 | import * as React from "react"; 10 | import { withError } from "../../decorators/errors"; 11 | import { withFetching } from "../../decorators/fetching"; 12 | import { withNoData } from "../../decorators/nodata"; 13 | import { RangeChart } from "../Charts/RangesChart"; 14 | import { HorizontalStack } from "../Layout/Stack"; 15 | import { Totals } from "../Totals/Totals"; 16 | import { TxList } from "../Tx/TxList"; 17 | 18 | type AssetProps = { 19 | txs: GetTx[]; 20 | asset: EnrichedAsset; 21 | onEdit: (a: GetAsset) => void; 22 | onAddTx: (tx: PostTx) => void; 23 | onEditTx: (txid: number, tx: PostTx) => void; 24 | onDeleteTx: (txid: number) => void; 25 | onRange: (rng: ChartRange) => void; 26 | }; 27 | 28 | const RawAsset: React.FC = ({ 29 | asset, 30 | txs, 31 | onEditTx, 32 | onDeleteTx, 33 | onAddTx, 34 | onRange, 35 | }: AssetProps) => { 36 | return ( 37 |
38 | 39 |

40 | {asset.name} ({asset.ticker}) 41 |

42 | 47 |
48 | 49 | 55 | 62 |
63 | ); 64 | }; 65 | 66 | export const Asset = pipe( 67 | RawAsset, 68 | withNoData((p) => p.asset), 69 | withError, 70 | withFetching 71 | ); 72 | -------------------------------------------------------------------------------- /packages/backend/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import type { YahooApi } from "@darkruby/assets-core"; 2 | import type { Repository } from "../repository"; 3 | import * as asset from "./asset"; 4 | import * as auth from "./auth"; 5 | import * as portfolio from "./portfolio"; 6 | import * as prefs from "./prefs"; 7 | import * as tx from "./tx"; 8 | import * as user from "./user"; 9 | 10 | export type WebService = ReturnType; 11 | 12 | export const createWebService = (repo: Repository, yahooApi: YahooApi) => { 13 | return { 14 | auth: { 15 | createToken: auth.createToken, 16 | verifyBearer: auth.verifyBearer, 17 | requireUserId: auth.requireUserId, 18 | verifyPassword: auth.verifyPassword, 19 | requireProfile: auth.requireProfile, 20 | requireAdminProfile: auth.requireAdminProfile, 21 | }, 22 | user: { 23 | get: user.getUser(repo), 24 | getMany: user.getUsers(repo), 25 | create: user.createUser(repo), 26 | delete: user.deleteUser(repo), 27 | updateProfileOnly: user.updateProfileOnly(repo), 28 | updateOwnProfileOnly: user.updateOwnProfileOnly(repo), 29 | updateOwnPasswordOnly: user.updateOwnPasswordOnly(repo), 30 | }, 31 | assets: { 32 | get: asset.getAsset(repo, yahooApi), 33 | getMany: asset.getAssets(repo, yahooApi), 34 | delete: asset.deleteAsset(repo), 35 | create: asset.createAsset(repo, yahooApi), 36 | update: asset.updateAsset(repo, yahooApi), 37 | }, 38 | portfolio: { 39 | get: portfolio.getPortfolio(repo, yahooApi), 40 | getMany: portfolio.getPortfolios(repo, yahooApi), 41 | delete: portfolio.deletePortfolio(repo), 42 | create: portfolio.createPortfolio(repo, yahooApi), 43 | update: portfolio.updatePortfolio(repo, yahooApi), 44 | }, 45 | tx: { 46 | get: tx.getTx(repo), 47 | getMany: tx.getTxs(repo), 48 | delete: tx.deleteTx(repo), 49 | create: tx.createTx(repo), 50 | update: tx.updateTx(repo), 51 | }, 52 | prefs: { 53 | update: prefs.updatePrefs(repo), 54 | }, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /.gitea/workflows/build-docker-container.yaml: -------------------------------------------------------------------------------- 1 | name: RELEASE-DOCKER-CONTAINER 2 | run-name: ${{ gitea.actor }} building and pushing docker container 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build-docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: CHECK-OUT 14 | uses: actions/checkout@v4 15 | - run: git branch --show-current 16 | 17 | - name: CHECK RELEASE or FEATURE 18 | id: release 19 | run: | 20 | if [ "${GITHUB_REF##*/}" == "master" ]; then 21 | echo "RELEASE=true" >> $GITHUB_ENV 22 | else 23 | echo "RELEASE=false" >> $GITHUB_ENV 24 | echo "BUILD_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV 25 | fi 26 | 27 | - name: EXTRACT VERSION 28 | uses: sergeysova/jq-action@v2 29 | id: version 30 | with: 31 | cmd: "jq .version package.json -r" 32 | 33 | - name: LOGIN TO GITHUB DOCKER 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: venil7 38 | password: ${{ secrets.GTHB_CR_TOKEN }} 39 | 40 | # - name: Set up QEMU 41 | # uses: docker/setup-qemu-action@v3 42 | 43 | # - name: Set up Docker Buildx 44 | # uses: docker/setup-buildx-action@v3 45 | 46 | # builds feature image, pushes under feature tag 47 | - name: BUILD FEATURE DOCKER-IMAGE 48 | if: ${{ env.RELEASE == 'false' }} 49 | uses: docker/build-push-action@v6 50 | with: 51 | push: true 52 | tags: | 53 | ghcr.io/venil7/assets:${{ env.BUILD_TAG }} 54 | 55 | # builds release image, pushes under version and latest tags 56 | - name: BUILD RELEASE DOCKER-IMAGE 57 | if: ${{ env.RELEASE == 'true' }} 58 | uses: docker/build-push-action@v6 59 | with: 60 | # platforms: linux/arm64 61 | push: true 62 | tags: | 63 | ghcr.io/venil7/assets:latest 64 | ghcr.io/venil7/assets:${{ steps.version.outputs.value }} 65 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-container.yaml: -------------------------------------------------------------------------------- 1 | name: RELEASE-DOCKER-CONTAINER 2 | run-name: Build container for ${{ github.ref_name }} 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | release-docker-container: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: CHECK-OUT 14 | uses: actions/checkout@v4 15 | - run: git branch --show-current 16 | 17 | - name: CHECK RELEASE or FEATURE 18 | id: release 19 | run: | 20 | if [ "${GITHUB_REF##*/}" == "master" ]; then 21 | echo "RELEASE=true" >> $GITHUB_ENV 22 | else 23 | echo "RELEASE=false" >> $GITHUB_ENV 24 | echo "BUILD_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV 25 | fi 26 | 27 | - name: EXTRACT VERSION 28 | uses: sergeysova/jq-action@v2 29 | id: version 30 | with: 31 | cmd: "jq .version package.json -r" 32 | 33 | - name: LOGIN TO GITHUB DOCKER 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: venil7 38 | password: ${{ secrets.GTHB_CR_TOKEN }} 39 | 40 | # - name: Set up QEMU 41 | # uses: docker/setup-qemu-action@v3 42 | 43 | # - name: Set up Docker Buildx 44 | # uses: docker/setup-buildx-action@v3 45 | 46 | # builds feature image, pushes under feature tag 47 | - name: BUILD FEATURE DOCKER-IMAGE 48 | if: ${{ env.RELEASE == 'false' }} 49 | uses: docker/build-push-action@v6 50 | with: 51 | push: true 52 | tags: | 53 | ghcr.io/venil7/assets:${{ env.BUILD_TAG }} 54 | 55 | # builds release image, pushes under version and latest tags 56 | - name: BUILD RELEASE DOCKER-IMAGE 57 | if: ${{ env.RELEASE == 'true' }} 58 | uses: docker/build-push-action@v6 59 | with: 60 | # platforms: linux/arm64 61 | push: true 62 | tags: | 63 | ghcr.io/venil7/assets:latest 64 | ghcr.io/venil7/assets:${{ steps.version.outputs.value }} 65 | -------------------------------------------------------------------------------- /packages/web/src/components/Asset/AssetFields.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | assetValidator, 3 | type PostAsset, 4 | type Ticker, 5 | } from "@darkruby/assets-core"; 6 | import { pipe } from "fp-ts/lib/function"; 7 | import { Form } from "react-bootstrap"; 8 | import { usePartialChange } from "../../hooks/formData"; 9 | import { createDialog } from "../../util/modal"; 10 | import type { PropsOf } from "../../util/props"; 11 | import { createForm, type FieldsProps } from "../Form/Form"; 12 | import { FormEdit } from "../Form/FormControl"; 13 | import { createModal } from "../Modals/Modal"; 14 | import { TickerLookup } from "../Tx/TickerLookup"; 15 | 16 | type AssetFieldsProps = FieldsProps; 17 | 18 | const AssetFields: React.FC = ({ 19 | data, 20 | onChange, 21 | disabled, 22 | }) => { 23 | const setField = usePartialChange(data, onChange); 24 | 25 | const handeSelect = ({ shortname, longname, symbol: ticker }: Ticker) => 26 | onChange({ ticker, name: longname ?? shortname ?? ticker }); 27 | 28 | return ( 29 |
30 | 31 | Lookup 32 | 33 | 34 | 35 | Name 36 | 41 | 42 | 43 | ticker 44 | 49 | 50 |
51 | ); 52 | }; 53 | 54 | export const AssetForm = createForm(AssetFields, assetValidator); 55 | export const AssetModal = createModal( 56 | AssetFields, 57 | assetValidator, 58 | "Asset" 59 | ); 60 | export const assetModal = (value: PostAsset) => 61 | pipe( 62 | { value }, 63 | createDialog>(AssetModal) 64 | ); 65 | -------------------------------------------------------------------------------- /packages/assets-core/test/data/FUND3.json: -------------------------------------------------------------------------------- 1 | { 2 | "chart": { 3 | "result": [ 4 | { 5 | "meta": { 6 | "currency": "EUR", 7 | "symbol": "IE000I7E6HL0.SG", 8 | "exchangeName": "STU", 9 | "fullExchangeName": "Stuttgart", 10 | "instrumentType": "MUTUALFUND", 11 | "firstTradeDate": null, 12 | "regularMarketTime": 1761335732, 13 | "hasPrePostMarketData": false, 14 | "gmtoffset": 7200, 15 | "timezone": "CEST", 16 | "exchangeTimezoneName": "Europe/Berlin", 17 | "regularMarketPrice": 9.803, 18 | "fiftyTwoWeekHigh": 7.772, 19 | "fiftyTwoWeekLow": 7.445, 20 | "regularMarketDayHigh": 7.772, 21 | "regularMarketDayLow": 7.445, 22 | "regularMarketVolume": 8927, 23 | "shortName": "HANetf Future of European Defen", 24 | "chartPreviousClose": 9.845, 25 | "priceHint": 2, 26 | "currentTradingPeriod": { 27 | "pre": { 28 | "timezone": "CEST", 29 | "end": 1761285600, 30 | "start": 1761285600, 31 | "gmtoffset": 7200 32 | }, 33 | "regular": { 34 | "timezone": "CEST", 35 | "end": 1761336000, 36 | "start": 1761285600, 37 | "gmtoffset": 7200 38 | }, 39 | "post": { 40 | "timezone": "CEST", 41 | "end": 1761336000, 42 | "start": 1761336000, 43 | "gmtoffset": 7200 44 | } 45 | }, 46 | "dataGranularity": "1d", 47 | "range": "1d", 48 | "validRanges": [ 49 | "1mo", 50 | "3mo", 51 | "6mo", 52 | "ytd", 53 | "1y", 54 | "2y", 55 | "5y", 56 | "10y", 57 | "max" 58 | ] 59 | }, 60 | "indicators": { 61 | "quote": [ 62 | {} 63 | ], 64 | "adjclose": [ 65 | {} 66 | ] 67 | } 68 | } 69 | ], 70 | "error": null 71 | } 72 | } --------------------------------------------------------------------------------