diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb498d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Build context is the repo root (see `packages/backend/docker-compose.yml`). +# Keep the layer that copies workspace sources small and deterministic. + +# Dependency installs — always reinstalled inside the image. +**/node_modules + +# Build outputs — regenerated inside the image. +**/dist +**/.vite + +# VCS / tooling. +.git +.gitignore +**/.DS_Store + +# Editor / scratch areas. +.idea +.vscode +**/.idea +**/.vscode + +# Local env and logs. +**/.env +**/.env.* +!**/.env.example +**/*.log +**/npm-debug.log* +**/pnpm-debug.log* + +# Test + CI caches. +**/coverage +**/.nyc_output +**/.eslintcache +**/.turbo +**/.cache + +# Docker itself. +**/Dockerfile +**/docker-compose.yml +**/.dockerignore + +# Only the backend needs its sources in the image; skip the web package to +# keep the backend image small and cache-friendly. +packages/web/src +packages/web/public +packages/web/vite.config.ts +packages/web/tsconfig.json +packages/web/scripts + +# Local uploads directory — bind-mounted at runtime instead. +packages/backend/uploads + +# Yahoo quote disk cache — re-populated at runtime. +packages/backend/.yahoo-cache.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2317c7b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_DB: fire + POSTGRES_USER: fire + POSTGRES_PASSWORD: fire + # Tests hard-code `localhost:5433` (see `test/global-setup.ts`), so + # expose the container port on the runner host at 5433. + ports: + - 5433:5432 + options: >- + --health-cmd="pg_isready -U fire -d fire" + --health-interval=5s + --health-timeout=3s + --health-retries=10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: pnpm + - run: pnpm install --frozen-lockfile + # Run the cheap checks first so a failure short-circuits the job before + # the (slower) backend test suite runs against the Postgres service. + - run: pnpm typecheck + - run: pnpm exec eslint . + - run: pnpm --filter backend test diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 9ee169f..91ff622 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,5 @@ { "*.ts": "eslint --fix", + "*.tsx": "eslint --fix", "*.sql": "prettier --write" } diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..1b05724 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.13.0 diff --git a/README.md b/README.md index 3b44fb2..43431f7 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ A personal net-worth tracker. Records assets, investments, and month-by-month ca ## Dev setup ### Prerequisites -- Node ≥ 22 +- Node ≥ 24 (see `.tool-versions`) - `pnpm` (repo pins a version via `packageManager`) -- Docker (for Postgres + Jaeger) +- Docker (runs Postgres, Jaeger, and the backend) ### First-time bootstrap @@ -45,40 +45,38 @@ A personal net-worth tracker. Records assets, investments, and month-by-month ca pnpm install ``` -Start the dependent services (Postgres and Jaeger): +Start the backing services (Postgres, Jaeger) and the backend itself: ```bash cd packages/backend docker compose up -d ``` -Create a `.env` in `packages/backend`: +This builds and runs the backend in a container against the containerised Postgres and Jaeger. The backend's source tree is mounted from the host, so `vite-node` hot-reloads on file changes without rebuilding the image. -``` -DATABASE_URL=postgres://fire:fire@localhost:5433/fire -UPLOADS_DIR=./uploads -``` - -Apply migrations: +Apply migrations (against the containerised Postgres, exposed on `localhost:5433`): ```bash -pnpm --filter backend db:migrate +DATABASE_URL=postgres://fire:fire@localhost:5433/fire \ + pnpm --filter backend db:migrate ``` ### Running -Backend (`:4000`, GraphQL at `/graphql`): +The backend (`:4000`, GraphQL at `/graphql`) runs inside docker compose. Tail its logs with: ```bash -pnpm --filter backend dev +docker compose -f packages/backend/docker-compose.yml logs -f backend ``` -Web app (`:4001`): +Web app (`:4001`) runs on the host: ```bash pnpm --filter web dev ``` +> Running the backend on the host instead of in docker is still possible (`pnpm --filter backend dev`) — just stop the container first and point `DATABASE_URL` at `localhost:5433`. The `OTEL_*` defaults in `src/env.ts` already target the containerised Jaeger. + With both running, regenerate the frontend's typed GraphQL from the backend's live schema whenever resolvers change: ```bash diff --git a/eslint.config.mjs b/eslint.config.mjs index c41b3b5..d405bb0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,18 @@ export default tseslint.config( }, js.configs.recommended, ...tseslint.configs.recommended, + { + // `.mjs` entry points (notably `packages/backend/otel.mjs`) run as Node + // scripts, not through the TS compiler, so their globals aren't in the + // default set. + files: ["**/*.mjs"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, + }, { plugins: { "simple-import-sort": simpleImportSort }, rules: { diff --git a/package.json b/package.json index 14262cd..afca725 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "packageManager": "pnpm@10.31.0", "engines": { - "node": ">=22" + "node": ">=24" }, "scripts": { "typecheck": "pnpm -r typecheck" diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile new file mode 100644 index 0000000..320c132 --- /dev/null +++ b/packages/backend/Dockerfile @@ -0,0 +1,32 @@ +# Production-oriented image for the backend. In dev, `docker-compose.yml` +# mounts the host source tree over `/app/packages/backend` and overrides +# the default command to `pnpm dev`, so the same image serves both modes. +# +# Build context is the repo root (see `docker-compose.yml`) so we can copy +# workspace manifests in the right layout for `pnpm install`. +FROM node:24-alpine + +RUN apk add --no-cache libc6-compat \ + && corepack enable \ + && corepack prepare pnpm@10.31.0 --activate + +WORKDIR /app + +# Copy manifests first so the dependency install layer stays cached across +# source-only changes. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/backend/package.json packages/backend/ +COPY packages/web/package.json packages/web/ + +RUN pnpm install --frozen-lockfile + +# Copy the rest of the backend sources so the image is self-contained in +# prod. In dev, docker-compose mounts the host tree over this — that mount +# takes priority, so these layers are effectively ignored at runtime. +COPY packages/backend ./packages/backend + +WORKDIR /app/packages/backend + +EXPOSE 4000 + +CMD ["pnpm", "start"] diff --git a/packages/backend/docker-compose.yml b/packages/backend/docker-compose.yml index b65d459..b7eaecd 100644 --- a/packages/backend/docker-compose.yml +++ b/packages/backend/docker-compose.yml @@ -27,5 +27,36 @@ services: - "4318:4318" # OTLP/HTTP receiver (matches backend's OTEL_EXPORTER_OTLP_ENDPOINT default) - "4317:4317" # OTLP/gRPC receiver + backend: + build: + # Build context is the repo root so the Dockerfile can copy workspace + # manifests (pnpm-workspace.yaml, root package.json, per-package package.json). + context: ../.. + dockerfile: packages/backend/Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + jaeger: + condition: service_started + environment: + NODE_ENV: development + DATABASE_URL: postgres://fire:fire@postgres:5432/fire + UPLOADS_DIR: /app/packages/backend/uploads + # Traces go to the Jaeger OTLP/HTTP receiver on the docker network. + OTEL_ENABLED: "true" + OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318 + command: pnpm dev + ports: + - "4000:4000" + volumes: + # Mount the backend package over the image so `vite-node` picks up host + # edits without rebuilding. Anonymous volumes on the two `node_modules` + # paths preserve the image-baked installs (built for linux/musl) from + # being shadowed by the host's (which are built for the host's arch). + - ./:/app/packages/backend + - /app/node_modules + - /app/packages/backend/node_modules + volumes: postgres-data: diff --git a/packages/backend/package.json b/packages/backend/package.json index b7b8c4e..3c37e58 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -7,6 +7,7 @@ "grats": "grats --tsconfig tsconfig.grats.json", "codegen": "run-p grats db:generate-schema && gql.tada generate-output", "dev": "NODE_ENV=development NODE_OPTIONS=\"--import=./otel.mjs\" vite", + "start": "NODE_ENV=production NODE_OPTIONS=\"--import=./otel.mjs\" vite-node src/index.ts", "typecheck": "pnpm grats && tsc --noEmit", "test": "vitest", "db:generate": "drizzle-kit generate", @@ -26,6 +27,7 @@ "@opentelemetry/resources": "^2.7.0", "@opentelemetry/sdk-node": "^0.215.0", "@opentelemetry/semantic-conventions": "^1.40.0", + "dataloader": "^2.2.3", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.2", "fastify": "^5.8.5", diff --git a/packages/backend/src/__generated__/schema.ts b/packages/backend/src/__generated__/schema.ts index 395ac54..89613b8 100644 --- a/packages/backend/src/__generated__/schema.ts +++ b/packages/backend/src/__generated__/schema.ts @@ -718,7 +718,10 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { position: { description: "Holdings, cost basis, and gain/loss filtered to this wrapper.", name: "position", - type: new GraphQLNonNull(InvestmentPositionType) + type: new GraphQLNonNull(InvestmentPositionType), + resolve(source, _args, context) { + return source.position(context); + } } }; } @@ -749,7 +752,10 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { position: { description: "Holdings, cost basis, and gain/loss aggregated across every wrapper.", name: "position", - type: new GraphQLNonNull(InvestmentPositionType) + type: new GraphQLNonNull(InvestmentPositionType), + resolve(source, _args, context) { + return source.position(context); + } }, stockSplits: { description: "Stock-split events on this investment, oldest-first.", @@ -786,7 +792,10 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { unitPriceCached: { description: "Most recent split-adjusted unit price known for this investment. `null` if no prices have been recorded yet.", name: "unitPriceCached", - type: MoneyType + type: MoneyType, + resolve(source, _args, context) { + return source.unitPriceCached(context); + } }, unitPriceLatest: { description: "Live unit price and the timestamp it was captured at, sourced from the real-time quote provider. `null` for non-stock investments, or when no quote is available. Querying this may trigger a background refresh if the cached quote is stale (> 5 minutes).", @@ -1796,8 +1805,8 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { type: InvestmentSortType } }, - resolve(_source, args) { - return assertNonNull(queryInvestmentsResolver(args.first, args.after, args.sort)); + resolve(_source, args, context) { + return assertNonNull(queryInvestmentsResolver(context, args.first, args.after, args.sort)); } }, netWorth: { diff --git a/packages/backend/src/graphql/investments/index.ts b/packages/backend/src/graphql/investments/index.ts index 2917780..ce58812 100644 --- a/packages/backend/src/graphql/investments/index.ts +++ b/packages/backend/src/graphql/investments/index.ts @@ -8,6 +8,7 @@ import { db } from "@/db"; import { Investments } from "@/db/schema/investments"; import { readOrRefresh } from "@/tasks/yahoo"; +import type { Context } from "../context"; import type { DateTime } from "../date-time"; import { assertCurrencyCode, Money } from "../money"; import { @@ -120,8 +121,8 @@ export class Investment { } /** Most recent split-adjusted unit price known for this investment. `null` if no prices have been recorded yet. @gqlField */ - async unitPriceCached(): Promise { - const s = await loadInvestmentStats(this.id); + async unitPriceCached(ctx: Context): Promise { + const s = await loadInvestmentStats(ctx, this.id); if (s.priceLatest === null) return null; return Money.fromMinorDenomination(s.priceLatest, s.currency); } @@ -138,8 +139,8 @@ export class Investment { } /** Holdings, cost basis, and gain/loss aggregated across every wrapper. @gqlField */ - async position(): Promise { - const s = await loadInvestmentStats(this.id); + async position(ctx: Context): Promise { + const s = await loadInvestmentStats(ctx, this.id); return new InvestmentPosition(s); } @@ -259,6 +260,7 @@ function parseSortInput(sort: InvestmentSort | null | undefined): { * @gqlAnnotate semanticNonNull */ export async function investments( + ctx: Context, first?: Int | null, after?: ID | null, sort?: InvestmentSort | null, @@ -283,7 +285,7 @@ export async function investments( })) : await Promise.all( rows.map(async (row) => { - const s = await loadInvestmentStats(row.id); + const s = await loadInvestmentStats(ctx, row.id); const totalValue = s.priceLatest === null ? null : s.unitsHeld * s.priceLatest; const totalGain = diff --git a/packages/backend/src/graphql/investments/portfolio.ts b/packages/backend/src/graphql/investments/portfolio.ts index 4d52e12..4475a65 100644 --- a/packages/backend/src/graphql/investments/portfolio.ts +++ b/packages/backend/src/graphql/investments/portfolio.ts @@ -90,11 +90,9 @@ type HeldInvestment = { }; async function computePortfolioXirr( + held: HeldInvestment[], filters: Filters, - opts: { skipLive: boolean }, ): Promise { - const held = await loadHeldInvestments(filters, opts); - const investmentIds = held.map((h) => h.id); if (investmentIds.length === 0) return null; @@ -321,9 +319,9 @@ async function loadHeldInvestments( } async function loadDailySeriesMinor( + held: HeldInvestment[], filters: Filters, ): Promise> { - const held = await loadHeldInvestments(filters); if (held.length === 0) return new Map(); const investmentIds = held.map((h) => h.id); @@ -516,6 +514,28 @@ export class Portfolio { private readonly filterInvestmentIdIn: string[] | null, ) {} + /** + * Per-instance memo of `loadHeldInvestments` keyed on whether live quotes + * are folded in. A fresh `Portfolio` is created per request (via + * `Query.portfolio` / `Query.portfolios`), so this cache is inherently + * request-scoped. Keeps the fanout to at most two underlying fetches + * regardless of how many Portfolio fields the caller selected. + */ + private heldCache: Map> = new Map(); + + private loadHeld( + opts: { skipLive?: boolean } = {}, + ): Promise { + const skipLive = !!opts.skipLive; + const key = skipLive ? "S" : ""; + let p = this.heldCache.get(key); + if (!p) { + p = loadHeldInvestments(this.filters, { skipLive }); + this.heldCache.set(key, p); + } + return p; + } + /** Synthetic, stable identifier derived from the filters + currency. Used for client-side cache normalisation; not meaningful as an external key. @gqlField */ get id(): ID { const assets = this.filterAssetIdIn @@ -594,7 +614,9 @@ export class Portfolio { /** When `true`, ignore any live quote and terminate against the most recent cached close instead. */ skipLive?: boolean | null, ): Promise { - return computePortfolioXirr(this.filters, { skipLive: skipLive ?? false }); + const opts = { skipLive: skipLive ?? false }; + const held = await this.loadHeld(opts); + return computePortfolioXirr(held, this.filters); } /** Change in portfolio value over the most recent pricing interval. When live quotes are available they're folded into each holding's latest price so this reflects today's move against yesterday's close. Pass `skipLive: true` to compare the two most recent cached closes only. `null` until enough price history exists. @gqlField */ @@ -699,7 +721,8 @@ export class Portfolio { period: PortfolioTimePeriod, length: number, ): Promise<{ days: Date[]; totals: Map }> { - const fullTotals = await loadDailySeriesMinor(this.filters); + const held = await this.loadHeld(); + const fullTotals = await loadDailySeriesMinor(held, this.filters); if (fullTotals.size === 0) { return { days: [], totals: new Map() }; } @@ -717,7 +740,7 @@ export class Portfolio { compute: (h: HeldInvestment) => number | null, opts: { skipLive?: boolean } = {}, ): Promise { - const held = await loadHeldInvestments(this.filters, opts); + const held = await this.loadHeld(opts); let total = 0; for (const h of held) { const rawMinor = compute(h); diff --git a/packages/backend/src/graphql/investments/position.ts b/packages/backend/src/graphql/investments/position.ts index 3d34ca7..9607923 100644 --- a/packages/backend/src/graphql/investments/position.ts +++ b/packages/backend/src/graphql/investments/position.ts @@ -7,6 +7,7 @@ import { db } from "@/db"; import { InvestmentTransactions } from "@/db/schema/investments"; import { NetWorthCategoryAssets } from "@/db/schema/net-worth"; +import type { Context } from "../context"; import { Money } from "../money"; import { NetWorthCategoryAsset } from "../net-worth/categories"; import { @@ -133,8 +134,8 @@ export class InvestmentWrapper { } /** Holdings, cost basis, and gain/loss filtered to this wrapper. @gqlField */ - async position(): Promise { - const s = await loadInvestmentStats(this.investmentId, this.assetId); + async position(ctx: Context): Promise { + const s = await loadInvestmentStats(ctx, this.investmentId, this.assetId); return new InvestmentPosition(s); } } diff --git a/packages/backend/src/graphql/investments/stats.ts b/packages/backend/src/graphql/investments/stats.ts index 8aef7b5..801b088 100644 --- a/packages/backend/src/graphql/investments/stats.ts +++ b/packages/backend/src/graphql/investments/stats.ts @@ -1,4 +1,5 @@ -import { and, asc, desc, eq } from "drizzle-orm"; +import DataLoader from "dataloader"; +import { asc, desc, inArray } from "drizzle-orm"; import { db } from "@/db"; import { @@ -9,6 +10,8 @@ import { } from "@/db/schema/investments"; import { readOrRefresh } from "@/tasks/yahoo"; +import type { Context } from "../context"; + /** * Aggregated numbers for an investment (optionally scoped to a single wrapper), computed from the raw transactions and the cached price history. * @@ -42,42 +45,115 @@ export type InvestmentStats = { /** * Load the raw stats for an investment (and optionally a wrapper). Caller combines them into `Money` / `Float` fields. + * + * Batched per `Context` via `DataLoader`: every `loadInvestmentStats` call issued in the same tick is coalesced into four `IN (...)` selects (one each for `Investments`, `InvestmentTransactions`, `InvestmentStockSplits`, `InvestmentPrices`). Repeated calls for the same `(investmentId, assetId)` within the same request share the resolved promise, so `Investment.position`, `investments()`'s sort key, and any per-wrapper slice all key into the same fetch. */ -export async function loadInvestmentStats( +export function loadInvestmentStats( + ctx: Context, investmentId: string, assetId?: string, ): Promise { - const [investmentRow] = await db - .select({ - currency: Investments.currency, - stockCode: Investments.stockCode, - }) - .from(Investments) - .where(eq(Investments.id, investmentId)); - if (!investmentRow) { - throw new Error(`Investment ${investmentId} not found`); + return getLoader(ctx).load({ investmentId, assetId }); +} + +type StatsKey = { investmentId: string; assetId: string | undefined }; + +type StatsLoader = DataLoader; + +const loadersByCtx = new WeakMap(); + +function getLoader(ctx: Context): StatsLoader { + let loader = loadersByCtx.get(ctx); + if (!loader) { + loader = new DataLoader(batchLoadStats, { + cacheKeyFn: (k) => `${k.investmentId}|${k.assetId ?? ""}`, + }); + loadersByCtx.set(ctx, loader); } + return loader; +} - const where = - assetId === undefined - ? eq(InvestmentTransactions.investmentId, investmentId) - : and( - eq(InvestmentTransactions.investmentId, investmentId), - eq(InvestmentTransactions.assetId, assetId), - ); - - const [txRows, splitRows] = await Promise.all([ - db.select().from(InvestmentTransactions).where(where), +async function batchLoadStats( + keys: ReadonlyArray, +): Promise<(InvestmentStats | Error)[]> { + const ids = [...new Set(keys.map((k) => k.investmentId))]; + + const [invRows, txRows, splitRows, priceRows] = await Promise.all([ + db + .select({ + id: Investments.id, + currency: Investments.currency, + stockCode: Investments.stockCode, + }) + .from(Investments) + .where(inArray(Investments.id, ids)), + db + .select() + .from(InvestmentTransactions) + .where(inArray(InvestmentTransactions.investmentId, ids)), db .select({ + investmentId: InvestmentStockSplits.investmentId, date: InvestmentStockSplits.date, ratio: InvestmentStockSplits.ratio, }) .from(InvestmentStockSplits) - .where(eq(InvestmentStockSplits.investmentId, investmentId)) + .where(inArray(InvestmentStockSplits.investmentId, ids)) .orderBy(asc(InvestmentStockSplits.date)), + db + .select({ + investmentId: InvestmentPrices.investmentId, + priceAdjusted: InvestmentPrices.priceAdjusted, + }) + .from(InvestmentPrices) + .where(inArray(InvestmentPrices.investmentId, ids)) + .orderBy(desc(InvestmentPrices.date)), ]); + const invById = new Map(invRows.map((r) => [r.id, r])); + const txByInv = groupBy(txRows, (r) => r.investmentId); + const splitsByInv = groupBy(splitRows, (r) => r.investmentId); + // Prices are already date-desc; `pricesByInv.get(id)[0..1]` is latest + previous. + const pricesByInv = groupBy(priceRows, (r) => r.investmentId); + + return keys.map((key) => { + const inv = invById.get(key.investmentId); + if (!inv) return new Error(`Investment ${key.investmentId} not found`); + return computeStats( + key.assetId, + inv.currency, + inv.stockCode, + txByInv.get(key.investmentId) ?? [], + splitsByInv.get(key.investmentId) ?? [], + pricesByInv.get(key.investmentId) ?? [], + ); + }); +} + +function groupBy(xs: T[], key: (x: T) => K): Map { + const m = new Map(); + for (const x of xs) { + const k = key(x); + const arr = m.get(k); + if (arr) arr.push(x); + else m.set(k, [x]); + } + return m; +} + +function computeStats( + assetId: string | undefined, + currency: string, + stockCode: string | null, + txRows: (typeof InvestmentTransactions.$inferSelect)[], + splitRows: { date: Date; ratio: string }[], + priceRows: { priceAdjusted: number }[], +): InvestmentStats { + const filteredTxs = + assetId === undefined + ? txRows + : txRows.filter((r) => r.assetId === assetId); + // Multiplier for a transaction dated `d`: product of every split's ratio // whose `date > d`. A pre-split buy of 100 units at a 10:1 ratio therefore // counts as 1000 of today's shares. @@ -97,7 +173,7 @@ export async function loadInvestmentStats( let sellValueSum = 0; let taxesSum = 0; let feesSum = 0; - for (const r of txRows) { + for (const r of filteredTxs) { const mult = splitMultiplier(r.date); const adjustedUnits = r.units * mult; unitsHeld += adjustedUnits; @@ -114,29 +190,22 @@ export async function loadInvestmentStats( } } - const priceRows = await db - .select({ priceAdjusted: InvestmentPrices.priceAdjusted }) - .from(InvestmentPrices) - .where(eq(InvestmentPrices.investmentId, investmentId)) - .orderBy(desc(InvestmentPrices.date)) - .limit(2); - let priceLatest = priceRows[0]?.priceAdjusted ?? null; let pricePrevious = priceRows[1]?.priceAdjusted ?? null; // When a live quote is cached for a stock investment, treat it as the latest // price and shift the most recent cached close into the "previous" slot so // `dailyGain*` tracks today's move against yesterday's close. - if (investmentRow.stockCode) { - const live = readOrRefresh(investmentRow.stockCode); - if (live && live.currency === investmentRow.currency) { + if (stockCode) { + const live = readOrRefresh(stockCode); + if (live && live.currency === currency) { pricePrevious = priceLatest; priceLatest = live.priceMinorUnits; } } return { - currency: investmentRow.currency, + currency, unitsHeld, reinvestedUnits, unitsPriceSum, diff --git a/packages/web/src/components/delete-button.tsx b/packages/web/src/components/delete-button.tsx index 334adb3..39c5c7b 100644 --- a/packages/web/src/components/delete-button.tsx +++ b/packages/web/src/components/delete-button.tsx @@ -26,7 +26,9 @@ export function DeleteButton({ onConfirm }: DeleteButtonProps) { aria-hidden={!pending} className={cn( "overflow-hidden transition-[width,margin-left,opacity] duration-200", - pending ? "ml-1 w-9 opacity-100" : "pointer-events-none w-0 opacity-0", + pending + ? "ml-1 w-9 opacity-100" + : "pointer-events-none w-0 opacity-0", )} > - deleteSplit({ variables: { id: s.id } }) - } + onConfirm={() => deleteSplit({ variables: { id: s.id } })} /> @@ -956,8 +956,7 @@ function StockSplitForm({ const form = useForm({ defaultValues: { - date: - existing?.date ?? new Date().toISOString().slice(0, 10), + date: existing?.date ?? new Date().toISOString().slice(0, 10), ratio: existing?.ratio ?? 2, }, onSubmit: async ({ value }) => { diff --git a/packages/web/src/routes/net-worth/categories.tsx b/packages/web/src/routes/net-worth/categories.tsx index 08cfcc7..66a4017 100644 --- a/packages/web/src/routes/net-worth/categories.tsx +++ b/packages/web/src/routes/net-worth/categories.tsx @@ -474,7 +474,8 @@ function LiabilitiesSection({ s.values.type}> {(type) => - type === "CREDIT_CARD" && planningAccounts.length > 0 && ( + type === "CREDIT_CARD" && + planningAccounts.length > 0 && ( {(field) => ( { onChange([...entries, { taxCode: "", end: "" }]); diff --git a/packages/web/src/routes/planning/$year/payslips.tsx b/packages/web/src/routes/planning/$year/payslips.tsx index 3b75211..299c784 100644 --- a/packages/web/src/routes/planning/$year/payslips.tsx +++ b/packages/web/src/routes/planning/$year/payslips.tsx @@ -150,7 +150,9 @@ type AccountOption = NonNullable< PlanningPayslipsData["planningYear"] >["accounts"][number]; type LiabilityOption = Extract< - NonNullable["edges"][number]["node"], + NonNullable< + PlanningPayslipsData["netWorthCategories"] + >["edges"][number]["node"], { __typename: "NetWorthCategoryLiability" } >; @@ -265,9 +267,7 @@ function PlanningPayslipsDialog() { const payslips: Payslip[] = data.payslips?.edges.map((e) => e.node) ?? []; const accounts: AccountOption[] = data.planningYear?.accounts ?? []; - const liabilities: LiabilityOption[] = ( - data.netWorthCategories?.edges ?? [] - ) + const liabilities: LiabilityOption[] = (data.netWorthCategories?.edges ?? []) .map((e) => e.node) .filter( (n): n is LiabilityOption => n.__typename === "NetWorthCategoryLiability", @@ -465,7 +465,9 @@ function EditPayslipForm({ refetch: RefetchEntry[]; onDone: () => void; }) { - const [values, setValues] = useState(() => payslipToForm(payslip)); + const [values, setValues] = useState(() => + payslipToForm(payslip), + ); const [update, { loading }] = useMutation(PlanningPayslipUpdateDocument, { refetchQueries: refetch, }); diff --git a/packages/web/src/routes/planning/$year/tax-rates.tsx b/packages/web/src/routes/planning/$year/tax-rates.tsx index d75fdc6..dbb78a8 100644 --- a/packages/web/src/routes/planning/$year/tax-rates.tsx +++ b/packages/web/src/routes/planning/$year/tax-rates.tsx @@ -42,10 +42,7 @@ const PlanningTaxRatesDialogDocument = graphql(` `); const PlanningYearSetDocument = graphql(` - mutation PlanningYearSet( - $year: ID! - $rates: PlanningYearTaxRatesUKInput! - ) { + mutation PlanningYearSet($year: ID!, $rates: PlanningYearTaxRatesUKInput!) { planningYearSet(year: $year, taxRates: { uk: $rates }) { id } @@ -58,7 +55,9 @@ export const Route = createFileRoute("/planning/$year/tax-rates")({ type CurrentRates = Extract< NonNullable< - NonNullable["planningYear"]>["taxRates"] + NonNullable< + ResultOf["planningYear"] + >["taxRates"] >, { __typename?: "PlanningYearTaxRatesUK" } >; @@ -202,9 +201,7 @@ function PlanningTaxRatesDialog() { - patch({ thresholdPersonalAllowanceTaper: v }) - } + onChange={(v) => patch({ thresholdPersonalAllowanceTaper: v })} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd6b4c6..7ff68a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.40.0 version: 1.40.0 + dataloader: + specifier: ^2.2.3 + version: 2.2.3 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -2578,6 +2581,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -6541,6 +6547,8 @@ snapshots: csstype@3.2.3: {} + dataloader@2.2.3: {} + date-fns@4.1.0: {} debounce@2.2.0: {}