From 4f92620710fa1b835d556306bdfc6a9f32b246bf Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 10:56:21 +0100 Subject: [PATCH 01/10] chore(backend): dockerise backend for dev and prod Adds a `node:24-alpine` Dockerfile that bakes in deps and sources and defaults to `pnpm start` (prod). The compose file overrides `command` to `pnpm dev` and volume-mounts the host source tree for hot reload, and points the container at the in-network Jaeger for OTLP traces. Also pins Node to 24 via `.tool-versions` + `engines` so host and container match. --- .dockerignore | 54 +++++++++++++++++++++++++++++ .tool-versions | 1 + README.md | 26 +++++++------- package.json | 2 +- packages/backend/Dockerfile | 32 +++++++++++++++++ packages/backend/docker-compose.yml | 31 +++++++++++++++++ packages/backend/package.json | 1 + 7 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 .tool-versions create mode 100644 packages/backend/Dockerfile 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/.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/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..606511c 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", From 6f254133669768479c0261092ad69234d6ba198f Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:33:43 +0100 Subject: [PATCH 02/10] perf(investments): memoise loadInvestmentStats per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps `loadInvestmentStats` in `contextAwareDataLoader` keyed on `(investmentId, assetId)`, and threads `Context` through the four callers that hit it. Cuts the per-investment query fanout in half (46 → 23 per `InvestmentsPage`), since the `investments()` sort and `Investment.position` resolver no longer re-run the four underlying selects for the same investment. --- packages/backend/src/__generated__/schema.ts | 19 ++++++++++++++----- .../backend/src/graphql/investments/index.ts | 12 +++++++----- .../src/graphql/investments/position.ts | 5 +++-- .../backend/src/graphql/investments/stats.ts | 11 ++++++++++- 4 files changed, 34 insertions(+), 13 deletions(-) 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/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..f100133 100644 --- a/packages/backend/src/graphql/investments/stats.ts +++ b/packages/backend/src/graphql/investments/stats.ts @@ -9,6 +9,8 @@ import { } from "@/db/schema/investments"; import { readOrRefresh } from "@/tasks/yahoo"; +import { type Context, contextAwareDataLoader } from "../context"; + /** * Aggregated numbers for an investment (optionally scoped to a single wrapper), computed from the raw transactions and the cached price history. * @@ -42,8 +44,15 @@ export type InvestmentStats = { /** * Load the raw stats for an investment (and optionally a wrapper). Caller combines them into `Money` / `Float` fields. + * + * Exported as a `Context`-aware loader: repeated calls for the same `(investmentId, assetId)` within a single request share the underlying DB work. Callers on hot paths (list resolvers, portfolio roll-ups) therefore don't fire the four underlying queries once per invocation — `Investment.position` and `investments()`'s sort key both key into the same cached promise. */ -export async function loadInvestmentStats( +export const loadInvestmentStats = contextAwareDataLoader( + (_ctx: Context, investmentId: string, assetId?: string) => + loadInvestmentStatsFromDb(investmentId, assetId), +); + +async function loadInvestmentStatsFromDb( investmentId: string, assetId?: string, ): Promise { From 9d17ac0bb5cc12211fb731d52ae9ead2efc9544d Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:36:55 +0100 Subject: [PATCH 03/10] perf(investments): batch loadInvestmentStats via per-request dataloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-id memo with a microtask-coalesced batcher: every `loadInvestmentStats(ctx, id)` call issued within the same tick is folded into four `IN (...)` selects — one each for `Investments`, `InvestmentTransactions`, `InvestmentStockSplits`, and `InvestmentPrices`. Stats are then computed from the grouped rows. On `InvestmentsPage`, the four per-id queries that used to fire 23× each collapse into a single batched round-trip each, dropping the total drizzle-span count from ~230 to ~50 and halving cumulative DB time. `assetId` filtering runs in JS over the batched transactions so per-wrapper resolvers share the same fetch. --- .../backend/src/graphql/investments/stats.ts | 210 ++++++++++++++---- 1 file changed, 163 insertions(+), 47 deletions(-) diff --git a/packages/backend/src/graphql/investments/stats.ts b/packages/backend/src/graphql/investments/stats.ts index f100133..84c29ad 100644 --- a/packages/backend/src/graphql/investments/stats.ts +++ b/packages/backend/src/graphql/investments/stats.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq } from "drizzle-orm"; +import { asc, desc, inArray } from "drizzle-orm"; import { db } from "@/db"; import { @@ -9,7 +9,7 @@ import { } from "@/db/schema/investments"; import { readOrRefresh } from "@/tasks/yahoo"; -import { type Context, contextAwareDataLoader } from "../context"; +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. @@ -45,47 +45,170 @@ export type InvestmentStats = { /** * Load the raw stats for an investment (and optionally a wrapper). Caller combines them into `Money` / `Float` fields. * - * Exported as a `Context`-aware loader: repeated calls for the same `(investmentId, assetId)` within a single request share the underlying DB work. Callers on hot paths (list resolvers, portfolio roll-ups) therefore don't fire the four underlying queries once per invocation — `Investment.position` and `investments()`'s sort key both key into the same cached promise. + * Batched per `Context`: every `loadInvestmentStats` call issued in the same microtask 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 const loadInvestmentStats = contextAwareDataLoader( - (_ctx: Context, investmentId: string, assetId?: string) => - loadInvestmentStatsFromDb(investmentId, assetId), -); - -async function loadInvestmentStatsFromDb( +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`); + const batcher = getBatcher(ctx); + const key = `${investmentId}|${assetId ?? ""}`; + + const cached = batcher.cache.get(key); + if (cached) return cached; + + let entry = batcher.pending.get(key); + if (!entry) { + entry = { investmentId, assetId, deferred: defer() }; + batcher.pending.set(key, entry); + if (!batcher.scheduled) { + batcher.scheduled = true; + queueMicrotask(() => { + void flush(batcher); + }); + } } + batcher.cache.set(key, entry.deferred.promise); + return entry.deferred.promise; +} - const where = - assetId === undefined - ? eq(InvestmentTransactions.investmentId, investmentId) - : and( - eq(InvestmentTransactions.investmentId, investmentId), - eq(InvestmentTransactions.assetId, assetId), +type Deferred = { + promise: Promise; + resolve: (v: T) => void; + reject: (e: unknown) => void; +}; + +function defer(): Deferred { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +type PendingEntry = { + investmentId: string; + assetId: string | undefined; + deferred: Deferred; +}; + +type Batcher = { + pending: Map; + cache: Map>; + scheduled: boolean; +}; + +const batchersByCtx = new WeakMap(); + +function getBatcher(ctx: Context): Batcher { + let b = batchersByCtx.get(ctx); + if (!b) { + b = { pending: new Map(), cache: new Map(), scheduled: false }; + batchersByCtx.set(ctx, b); + } + return b; +} + +async function flush(batcher: Batcher): Promise { + const entries = [...batcher.pending.values()]; + batcher.pending.clear(); + batcher.scheduled = false; + + const ids = [...new Set(entries.map((e) => e.investmentId))]; + + try { + 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(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); + + for (const entry of entries) { + try { + const inv = invById.get(entry.investmentId); + if (!inv) { + throw new Error(`Investment ${entry.investmentId} not found`); + } + const txs = txByInv.get(entry.investmentId) ?? []; + const splits = splitsByInv.get(entry.investmentId) ?? []; + const prices = pricesByInv.get(entry.investmentId) ?? []; + entry.deferred.resolve( + computeStats( + entry.assetId, + inv.currency, + inv.stockCode, + txs, + splits, + prices, + ), ); + } catch (err) { + entry.deferred.reject(err); + } + } + } catch (err) { + for (const entry of entries) entry.deferred.reject(err); + } +} - const [txRows, splitRows] = await Promise.all([ - db.select().from(InvestmentTransactions).where(where), - db - .select({ - date: InvestmentStockSplits.date, - ratio: InvestmentStockSplits.ratio, - }) - .from(InvestmentStockSplits) - .where(eq(InvestmentStockSplits.investmentId, investmentId)) - .orderBy(asc(InvestmentStockSplits.date)), - ]); +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 @@ -106,7 +229,7 @@ async function loadInvestmentStatsFromDb( 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; @@ -123,29 +246,22 @@ async function loadInvestmentStatsFromDb( } } - 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, From c22c10ecefbd86aaf4b0e4056bdfdbd27781329a Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:41:01 +0100 Subject: [PATCH 04/10] refactor(investments): use dataloader for loadInvestmentStats batching --- packages/backend/package.json | 1 + .../backend/src/graphql/investments/stats.ts | 194 +++++++----------- pnpm-lock.yaml | 8 + 3 files changed, 78 insertions(+), 125 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 606511c..3c37e58 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -27,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/graphql/investments/stats.ts b/packages/backend/src/graphql/investments/stats.ts index 84c29ad..801b088 100644 --- a/packages/backend/src/graphql/investments/stats.ts +++ b/packages/backend/src/graphql/investments/stats.ts @@ -1,3 +1,4 @@ +import DataLoader from "dataloader"; import { asc, desc, inArray } from "drizzle-orm"; import { db } from "@/db"; @@ -45,145 +46,88 @@ export type InvestmentStats = { /** * Load the raw stats for an investment (and optionally a wrapper). Caller combines them into `Money` / `Float` fields. * - * Batched per `Context`: every `loadInvestmentStats` call issued in the same microtask 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. + * 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 function loadInvestmentStats( ctx: Context, investmentId: string, assetId?: string, ): Promise { - const batcher = getBatcher(ctx); - const key = `${investmentId}|${assetId ?? ""}`; - - const cached = batcher.cache.get(key); - if (cached) return cached; - - let entry = batcher.pending.get(key); - if (!entry) { - entry = { investmentId, assetId, deferred: defer() }; - batcher.pending.set(key, entry); - if (!batcher.scheduled) { - batcher.scheduled = true; - queueMicrotask(() => { - void flush(batcher); - }); - } - } - batcher.cache.set(key, entry.deferred.promise); - return entry.deferred.promise; -} - -type Deferred = { - promise: Promise; - resolve: (v: T) => void; - reject: (e: unknown) => void; -}; - -function defer(): Deferred { - let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; + return getLoader(ctx).load({ investmentId, assetId }); } -type PendingEntry = { - investmentId: string; - assetId: string | undefined; - deferred: Deferred; -}; +type StatsKey = { investmentId: string; assetId: string | undefined }; -type Batcher = { - pending: Map; - cache: Map>; - scheduled: boolean; -}; +type StatsLoader = DataLoader; -const batchersByCtx = new WeakMap(); +const loadersByCtx = new WeakMap(); -function getBatcher(ctx: Context): Batcher { - let b = batchersByCtx.get(ctx); - if (!b) { - b = { pending: new Map(), cache: new Map(), scheduled: false }; - batchersByCtx.set(ctx, b); +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 b; + return loader; } -async function flush(batcher: Batcher): Promise { - const entries = [...batcher.pending.values()]; - batcher.pending.clear(); - batcher.scheduled = false; - - const ids = [...new Set(entries.map((e) => e.investmentId))]; - - try { - 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(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); - - for (const entry of entries) { - try { - const inv = invById.get(entry.investmentId); - if (!inv) { - throw new Error(`Investment ${entry.investmentId} not found`); - } - const txs = txByInv.get(entry.investmentId) ?? []; - const splits = splitsByInv.get(entry.investmentId) ?? []; - const prices = pricesByInv.get(entry.investmentId) ?? []; - entry.deferred.resolve( - computeStats( - entry.assetId, - inv.currency, - inv.stockCode, - txs, - splits, - prices, - ), - ); - } catch (err) { - entry.deferred.reject(err); - } - } - } catch (err) { - for (const entry of entries) entry.deferred.reject(err); - } +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(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 { 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: {} From d1aec67f8353a9b4d7f6caa67fce6626190803e8 Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:46:02 +0100 Subject: [PATCH 05/10] perf(investments): memoise loadHeldInvestments per Portfolio instance Every Portfolio field resolver (totalValue, totalCost, totalGain, percentGain, xirr, dailyGainValue, dailyGainPercent, timeseries, candlestick) used to call `loadHeldInvestments` independently, firing the same four `IN (...)` selects once per field. `Portfolio` now has a private `loadHeld(opts)` method that caches by normalised `skipLive` flag, and `computePortfolioXirr` / `loadDailySeriesMinor` accept the already-loaded held list as a parameter so they share the cache. On the `InvestmentsPage` query this drops drizzle spans from ~29 to ~17 and cumulative DB time from ~450 ms to ~100 ms. --- .../src/graphql/investments/portfolio.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) 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); From f557f6cd0bc1d6fd14e3d0461036ea4323951cc4 Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:49:27 +0100 Subject: [PATCH 06/10] ci: add GH Actions workflow for typecheck, lint, and backend tests Two parallel jobs: - `lint`: runs `pnpm typecheck` and `pnpm exec eslint .` at the repo root. - `backend-test`: spins up Postgres 18 on port 5433 (matching `test/global-setup.ts`) and runs `pnpm --filter backend test`. --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5887a31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Typecheck + lint + runs-on: ubuntu-latest + 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: pnpm typecheck + - run: pnpm exec eslint . + + backend-test: + name: Backend tests + 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: pnpm --filter backend test From 59021b57b2eeb1c529aceb6ab90167d40de5efcf Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:51:11 +0100 Subject: [PATCH 07/10] fix: lint-staged tsx files --- .lintstagedrc.json | 1 + 1 file changed, 1 insertion(+) 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" } From 33293ea4cda1a0fa174ee2086b89fb665af36d7e Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:52:25 +0100 Subject: [PATCH 08/10] style: apply eslint --fix and clean up remaining lint errors - `pnpm exec eslint . --fix` to apply prettier formatting across the tree. - Allow `process` / `console` as globals in `**/*.mjs` (covers `packages/backend/otel.mjs`, which runs as a plain Node script). - Drop the unused `FormSection` helper and its orphaned imports from `packages/web/src/components/net-worth/entry-form.tsx`. - Rename unused `year` destructures to `_year` in `packages/web/src/routes/planning/$year.tsx`. `pnpm exec eslint .` is now clean. --- eslint.config.mjs | 12 + packages/web/src/components/delete-button.tsx | 4 +- .../investments/investment-form.tsx | 2 - .../investments/portfolio-chart.tsx | 343 +++++++++--------- .../src/components/net-worth/entry-form.tsx | 37 +- packages/web/src/components/ui/button.tsx | 6 +- packages/web/src/components/ui/input.tsx | 3 +- packages/web/src/components/ui/table.tsx | 10 +- packages/web/src/main.tsx | 3 +- packages/web/src/routes/investments/$id.tsx | 155 ++++---- .../web/src/routes/net-worth/categories.tsx | 7 +- packages/web/src/routes/net-worth/entries.tsx | 9 +- .../src/routes/net-worth/entries/$id.edit.tsx | 4 +- .../web/src/routes/net-worth/entries/new.tsx | 6 +- packages/web/src/routes/planning/$year.tsx | 16 +- .../src/routes/planning/$year/accounts.tsx | 9 +- .../web/src/routes/planning/$year/bills.tsx | 8 +- .../src/routes/planning/$year/earnings.tsx | 26 +- .../src/routes/planning/$year/payslips.tsx | 12 +- .../src/routes/planning/$year/tax-rates.tsx | 13 +- 20 files changed, 328 insertions(+), 357 deletions(-) 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/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 })} /> From d04034aba1173abc31f0e9cc503f6b53f9e2b85a Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:56:50 +0100 Subject: [PATCH 09/10] ci: merge lint and test jobs so tests skip on typecheck/lint failure --- .github/workflows/ci.yml | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5887a31..9663704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,22 +10,8 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: Typecheck + lint - runs-on: ubuntu-latest - 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: pnpm typecheck - - run: pnpm exec eslint . - - backend-test: - name: Backend tests + ci: + name: Typecheck, lint, and backend tests runs-on: ubuntu-latest services: postgres: @@ -51,4 +37,8 @@ jobs: 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 From 8d7d413b0281885875401e3f83319b9d6eb904bf Mon Sep 17 00:00:00 2001 From: Fela Maslen Date: Sun, 19 Apr 2026 11:57:04 +0100 Subject: [PATCH 10/10] ci: rename job to test --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9663704..2317c7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,7 @@ concurrency: cancel-in-progress: true jobs: - ci: - name: Typecheck, lint, and backend tests + test: runs-on: ubuntu-latest services: postgres: