From 7f1bc7d448c09cffb97bd269698427326f582781 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:33:20 +0000 Subject: [PATCH 1/5] Slim holdings, budget, spending, health and sync output; add --fields Measured against the 3.10.0 server image with 2 accounts, 8 assets and ~100 activities: - holdings: one flat row per holding, base-currency values, nulls left out (9.8 KB -> 3.4 KB). --full keeps the server rows. - budget: computed rows only, without ids, colours and empty categories (13.7 KB -> 0.8 KB with no budget set up). - spending report: byDay and byDayByCategory only with --full. - health: also drops routes, translation keys, cache keys and the fix payload (8.4 KB -> 5.6 KB). - rebalance: category colours only with --full. - --wait: failed_syncs messages lose the provider URL (2.7 KB -> 1.3 KB). - Global --fields a,b.c projects each row, wf call included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178eJr8TT5NezDCgT1yLgZp --- README.md | 49 +++++----- skills/wealthfolio/SKILL.md | 42 ++++----- src/commands/curated.ts | 154 ++++++++++++++++++++++++++++---- src/commands/reads.ts | 106 ++++++++++++++++++---- src/context.ts | 6 +- src/events.ts | 18 +++- src/output.ts | 46 ++++++++++ src/program.ts | 9 ++ test/integration/server.test.ts | 2 +- test/unit/activities.test.ts | 72 +++++++++++++-- test/unit/curated.test.ts | 105 ++++++++++++++++++++++ test/unit/wait.test.ts | 6 +- 12 files changed, 520 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index ee7b530..8a0c76b 100644 --- a/README.md +++ b/README.md @@ -106,28 +106,31 @@ rule). Then commands exit with code 3 and `PROFILE_LOCKED`; run ## Commands -| Command | What it does | -| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wf login` / `wf logout` / `wf status` | Session; `status` also warns when the server version is not `UPSTREAM_TAG`. | -| `wf commands [--filter x]` | The name of every command. With `--filter`, also each one's method, path and the payload type `invoke` reads. | -| `wf schema ` | Payload and response types of one command, expanded, with field notes. | -| `wf call [--args json \| --args-file f \| --args -] [--wait]` | Runs a command. `-` reads stdin. | -| `wf sync [--asset id]... [--refetch-all \| --recent-days n]` | Syncs market data and updates the portfolio. | -| `wf accounts [--all]` | Accounts, with tracking mode, group and sync provider. | -| `wf holdings [--account id [--include-closed]] [--full]` | Holdings, cash included, with asset id, local values and FX rate. Without `--full`, the instrument's classifications and notes and the holding's metadata are left out. | -| `wf activity search [filters]` | Activities, newest first. Filters: `--account`, `--type`, `--id` (each repeatable), `--from`, `--to`, `--needs-review` or `--reviewed`, `--asset`, `--page`, `--page-size` (default 20). Each row has only `id`, `accountId`, `assetId`, `assetSymbol`, `activityType`, `subtype`, `status`, `date`, `quantity`, `unitPrice`, `amount`, `fee`, `currency`, `fxRate` and `needsReview`; `--full` returns every field. | -| `wf activity update [--field value]... [--dry-run]` | Corrects activity fields with the server's own rules; `--updates` for a batch. Needs `wf agent-token create`. | -| `wf agent-token create\|show\|delete` | The access token for the server's MCP endpoint, which `activity update` uses. | -| `wf activity delete ... [--dry-run]` | Deletes up to 100 activities. One leg of a linked transfer deletes both. | -| `wf activity void ... [--dry-run]` | Voids posted activities: kept, but out of holdings, cash and performance. | -| `wf activity restore ... [--dry-run]` | Posts voided activities again. | -| `wf health [--refresh]` | Health Center status; `--refresh` runs the checks first. Each issue's `diagnostics` is left out unless `--full`. | -| `wf rebalance [--target id\|name] [--holdings] [--cash n [--mode m] [--asset id]...]` | Drift against an allocation target (`--holdings` adds the holdings); with `--cash`, a rebalance plan. | -| `wf spending report [--from d] [--to d] [--account id]...` | Spending report (default: this month so far). | -| `wf budget [--period YYYY-MM]` | Budget snapshot. | -| `wf alt-asset value [--date d] [--notes t]` | Records a valuation for a property, vehicle, and so on. | +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wf login` / `wf logout` / `wf status` | Session; `status` also warns when the server version is not `UPSTREAM_TAG`. | +| `wf commands [--filter x]` | The name of every command. With `--filter`, also each one's method, path and the payload type `invoke` reads. | +| `wf schema ` | Payload and response types of one command, expanded, with field notes. | +| `wf call [--args json \| --args-file f \| --args -] [--wait]` | Runs a command. `-` reads stdin. | +| `wf sync [--asset id]... [--refetch-all \| --recent-days n]` | Syncs market data and updates the portfolio. | +| `wf accounts [--all]` | Accounts, with tracking mode, group and sync provider. | +| `wf holdings [--account id [--include-closed]] [--full]` | Holdings, cash included: one flat row each, with asset id, symbol, quantity, price, and values and gains in the base currency (plus `fxRate` and `marketValueLocal` for a foreign currency). `--full` returns the server's rows: local values, lots, classifications and so on. | +| `wf activity search [filters]` | Activities, newest first. Filters: `--account`, `--type`, `--id` (each repeatable), `--from`, `--to`, `--needs-review` or `--reviewed`, `--asset`, `--page`, `--page-size` (default 20). Each row has only `id`, `accountId`, `assetId`, `assetSymbol`, `activityType`, `subtype`, `status`, `date`, `quantity`, `unitPrice`, `amount`, `fee`, `currency`, `fxRate` and `needsReview`; `--full` returns every field. | +| `wf activity update [--field value]... [--dry-run]` | Corrects activity fields with the server's own rules; `--updates` for a batch. Needs `wf agent-token create`. | +| `wf agent-token create\|show\|delete` | The access token for the server's MCP endpoint, which `activity update` uses. | +| `wf activity delete ... [--dry-run]` | Deletes up to 100 activities. One leg of a linked transfer deletes both. | +| `wf activity void ... [--dry-run]` | Voids posted activities: kept, but out of holdings, cash and performance. | +| `wf activity restore ... [--dry-run]` | Posts voided activities again. | +| `wf health [--refresh]` | Health Center status; `--refresh` runs the checks first. Each issue's `diagnostics`, routes, translation keys and fix payload are left out unless `--full`. | +| `wf rebalance [--target id\| Drift against an allocation target (`--holdings`adds the holdings); with`--cash`, a rebalance plan. Category colours are left out unless `--full`. | +| `wf spending report [--from d] [--to d] [--account id]...` | Spending report (default: this month so far). The per-day totals (`byDay`, `byDayByCategory`) are left out unless `--full`. | +| `wf budget [--period YYYY-MM]` | Budget snapshot: totals, then each group and its categories with target, actual and remaining. Categories with no target and no spending are left out. `--full` returns the server's `{state, computed}`. | +| `wf alt-asset value [--date d] [--notes t]` | Records a valuation for a property, vehicle, and so on. | Add `--table` before or after the command for a human-readable table. +Add `--fields a,b.c` to print only some fields of each row (dotted paths; +for `activity search`, of each row in `data`). It works on every command, +`wf call` included. ### Waiting for the recalculation @@ -140,7 +143,8 @@ the new numbers. `--wait` works on `wf call` (not on read-only GET commands), The output gets a `wait` object: `{status: "complete" | "error" | "timeout", seconds, error?, marketSync?}`. `marketSync` is the server's quote sync result -without its list of skipped assets, so look at `failed_syncs` for failures. +without its list of skipped assets and the provider URLs in its messages, so +look at `failed_syncs` for failures. `wf call --wait` wraps the response as `{result, wait}`. An error or a timeout exits 1; the write has happened either way. Another client's update that starts during the wait can end it @@ -239,8 +243,7 @@ wf schema create_activity | jq -r .payloadType # Accounts, then holdings of the first one wf call get_accounts | jq -r '.[] | "\(.id)\t\(.name)\t\(.currency)"' id=$(wf call get_accounts | jq -r '.[0].id') -wf call get_holdings --args "{\"filter\":{\"type\":\"account\",\"accountId\":\"$id\"}}" \ - | jq '[.[] | {symbol: .instrument.symbol, value: .marketValue.base}]' +wf holdings --account "$id" --fields symbol,quantity,marketValue # Record a deposit, wait for the recalculation, then read holdings wf call create_activity --wait --args-file - <<'JSON' | jq '.wait.status' diff --git a/skills/wealthfolio/SKILL.md b/skills/wealthfolio/SKILL.md index e4f25ee..436b6b1 100644 --- a/skills/wealthfolio/SKILL.md +++ b/skills/wealthfolio/SKILL.md @@ -25,8 +25,8 @@ code you can act on. `wf activity search`. Never guess an id. 5. **Add `--wait` to writes when you read right after.** Without it, the server recalculates in the background and your next read can be stale. -6. **Read as JSON, without `--table`.** `--table` truncates nested fields such - as `instrument`, so you lose the symbol. Fetch JSON, then summarise or +6. **Read as JSON, without `--table`.** `--table` cuts long values and nested + fields, so you can lose data. Fetch JSON, then summarise or format it yourself, even when the user asks to see the data. Use `--table` only when the user asks for a table. @@ -77,18 +77,15 @@ wf rebalance --cash 5000 # plus a plan to invest 5000 round them. - Dates are `YYYY-MM-DD` or RFC 3339. - Add `--table` only when the user asks for a table (rule 6). -- `wf holdings` leaves out each instrument's classifications and notes and the - holding's metadata. Add `--full` only when you need sectors or regions. Even - slim, no `--account` returns the aggregate across all accounts (on one - portfolio, 26 rows were about 12k tokens), so pass `--account` when the question is about one - account. Pipe it through `jq` to the fields you need: - - ```sh - wf holdings --account | jq '[.[] | {symbol: .instrument.symbol, - quantity, price, value: .marketValue.base, cost: .costBasis.base, - gain: .totalGain.base, weight}]' - ``` - +- `wf holdings` returns one flat row per holding: `assetId`, `symbol`, + `quantity`, `price` (in `currency`), and `marketValue`, `costBasis`, + `unrealizedGain`, `totalGain`, `totalGainPct`, `dayChangePct` and `weight` + in `baseCurrency`. Null fields are left out. Add `--full` only when you need + local values, lots, sectors or regions. Without `--account`, it returns the + total across all accounts, with `sourceAccountIds`. Pass `--account` when the + question is about one account. +- Add `--fields a,b` to any command to keep only those fields of each row, + for example `wf holdings --fields symbol,marketValue,weight`. - `activity search` returns 20 rows by default, each with only `id`, `accountId`, `assetId`, `assetSymbol`, `activityType`, `subtype`, `status`, `date`, `quantity`, `unitPrice`, `amount`, `fee`, `currency`, `fxRate` and @@ -100,12 +97,15 @@ wf rebalance --cash 5000 # plus a plan to invest 5000 it was recorded at. - `wf spending report` defaults to this month. It returns `current` and `prior` totals (`income`, `outflow`, `saved`, `net`) and breakdowns by - category and day. `--account ` repeats. -- `wf budget` returns `state` (groups, targets) and `computed` (`totals`, - per-group rows). All zeros with empty `groupRows` means no budget is set up - for that month; say so instead of reporting zero spending. + category. Add `--full` for the per-day totals. `--account ` repeats. +- `wf budget` returns `totals`, `groups` (each with `planned`, `actual`, + `remaining`, `overspent` and its `categories`), `ungrouped` and `income`. + Categories with no target and no spending are left out. Zero totals with no + categories mean no budget is set up for that month; say so instead of + reporting zero spending. `--full` returns the server's `{state, computed}`. - `wf health` leaves out each issue's `diagnostics` (it repeats `details` and - `affectedItems`). Add `--full` only if those don't explain an issue. + `affectedItems`) and the web app's routes. Add `--full` only if those don't + explain an issue. - `wf holdings --account --include-closed` also lists sold-out positions. For anything without its own command: @@ -131,7 +131,7 @@ wf call create_activity --wait --args '{"activity":{ - A new asset needs `symbol` and `quoteCcy`. For an asset already stored, pass `"asset":{"id":""}`. `wf holdings` shows that id as - `instrument.id`. + `assetId`. Cash activities (`DEPOSIT`, `WITHDRAWAL`, `DIVIDEND`, `INTEREST`, `FEE`) take `amount` and no quantity. The output is `{result, wait}`. When @@ -161,7 +161,7 @@ wf activity update --unit-price 110 --wait - Name only the fields that change. `--clear fee` sets an optional field to null. - `--asset` takes an asset id, not a symbol. Find the id in `wf holdings` - (`instrument.id`) or in the activity's `assetId`. + or in the activity, as `assetId`. - `--approve` takes an activity out of the review queue. A draft becomes posted. - For a batch, use `--updates '[{"activityId":"a","fee":"1"},...]'` or diff --git a/src/commands/curated.ts b/src/commands/curated.ts index d332a93..64135cc 100644 --- a/src/commands/curated.ts +++ b/src/commands/curated.ts @@ -3,6 +3,7 @@ import { Command, Option } from "commander"; import { UsageError } from "../errors"; import { open, output } from "../context"; +import { omit } from "../output"; import { addWaitOptions, withWait, type WaitOptions } from "../wait"; import { collect, dateArg, decimalArg, intArg, monthArg, numberArg, today } from "./args"; @@ -74,6 +75,7 @@ export function registerCurated(program: Command): void { ) .option("--asset ", "limit the plan to this asset; repeat for more", collect) .option("--holdings", "include holdings in the drift report") + .option("--full", "keep the colours of each category, left out by default") .action( async ( opts: { @@ -82,6 +84,7 @@ export function registerCurated(program: Command): void { mode: string; asset?: string[]; holdings?: boolean; + full?: boolean; }, cmd: Command, ) => { @@ -104,14 +107,14 @@ export function registerCurated(program: Command): void { ); } const filter = targetFilter(target!); - const drift = await client.invoke("get_allocation_target_drift", { + const drift = await client.invoke("get_allocation_target_drift", { targetId: target!.id, filter, includeHoldings: opts.holdings ?? false, }); const result: Record = { target: { id: target!.id, name: target!.name }, - drift, + drift: opts.full ? drift : withoutColours(drift), }; if (opts.cash !== undefined) { result.plan = await client.invoke("calculate_rebalance_plan", { @@ -138,28 +141,44 @@ export function registerCurated(program: Command): void { ) .option("--to ", "end date, YYYY-MM-DD (default: today)", dateArg) .option("--account ", "only this account; repeat for more", collect) - .action(async (opts: { from?: string; to?: string; account?: string[] }, cmd: Command) => { - const endDate = opts.to ?? today(); - // Without --from, report the month of --to; a past --to must not start after it. - const startDate = opts.from ?? `${endDate.slice(0, 8)}01`; - if (startDate > endDate) throw new UsageError("--from must not be after --to."); - const request = { - startDate, - endDate, - ...(opts.account ? { accountIds: opts.account } : {}), - }; - output(cmd, await open(cmd).invoke("get_spending_report", { request })); - }); + .option("--full", "also return the per-day totals (byDay, byDayByCategory)") + .action( + async ( + opts: { from?: string; to?: string; account?: string[]; full?: boolean }, + cmd: Command, + ) => { + const endDate = opts.to ?? today(); + // Without --from, report the month of --to; a past --to must not start after it. + const startDate = opts.from ?? `${endDate.slice(0, 8)}01`; + if (startDate > endDate) throw new UsageError("--from must not be after --to."); + const request = { + startDate, + endDate, + ...(opts.account ? { accountIds: opts.account } : {}), + }; + const report = await open(cmd).invoke>("get_spending_report", { + request, + }); + if (!opts.full) { + // One row per day and per day and category: most of a long range's size. + delete report.byDay; + delete report.byDayByCategory; + } + output(cmd, report); + }, + ); program .command("budget") .description("Budget snapshot for a month (get_budget)") .option("--period ", "month as YYYY-MM (default: the server's current month)", monthArg) - .action(async (opts: { period?: string }, cmd: Command) => { - output( - cmd, - await open(cmd).invoke("get_budget", opts.period ? { periodKey: opts.period } : {}), + .option("--full", "return the server's {state, computed}, with ids, colours and every category") + .action(async (opts: { period?: string; full?: boolean }, cmd: Command) => { + const budget = await open(cmd).invoke( + "get_budget", + opts.period ? { periodKey: opts.period } : {}, ); + output(cmd, opts.full || !budget?.computed ? budget : briefBudget(budget)); }); const altAsset = program @@ -196,6 +215,105 @@ export function registerCurated(program: Command): void { ); } +interface Drift { + rows?: { color?: unknown }[]; + holdings?: { rows?: { categoryColor?: unknown }[] } | null; +} + +function withoutColours(drift: Drift): Drift { + return { + ...drift, + rows: drift.rows?.map((row) => omit(row, "color")), + ...(drift.holdings && { + holdings: { + ...drift.holdings, + rows: drift.holdings.rows?.map((row) => omit(row, "categoryColor")), + }, + }), + }; +} + +interface BudgetCategory { + categoryId: string; + name: string; + target: number; + actual: number; + rolloverIn: number; + rolloverOut: number; + remaining: number; + overspent: boolean; +} + +interface Budget { + state: unknown; + computed: { + currency: string; + periodKey: string; + fxAsOf?: string | null; + totals: Record; + groupRows: { + group: { name: string }; + plannedTotal: number; + buffer: number; + actual: number; + rolloverIn: number; + rolloverOut: number; + remaining: number; + overspent: boolean; + categories: BudgetCategory[]; + }[]; + ungroupedRows: BudgetCategory[]; + incomeRows: BudgetCategory[]; + }; +} + +/** Rollovers only when there are any. */ +function rollover(row: { rolloverIn: number; rolloverOut: number }) { + return row.rolloverIn || row.rolloverOut + ? { rolloverIn: row.rolloverIn, rolloverOut: row.rolloverOut } + : {}; +} + +/** Categories with no target, no spending and no rollover carry nothing. */ +function briefCategories(rows: BudgetCategory[]) { + return rows + .filter((c) => c.target || c.actual || c.rolloverIn || c.rolloverOut) + .map((c) => ({ + categoryId: c.categoryId, + name: c.name, + target: c.target, + actual: c.actual, + remaining: c.remaining, + overspent: c.overspent, + ...rollover(c), + })); +} + +/** + * The computed side only, without ids, colours and icons. `state` repeats the + * groups and targets that `computed` already shows, and every default category + * is listed even when nothing is budgeted or spent in it. + */ +export function briefBudget({ computed }: Budget) { + return { + periodKey: computed.periodKey, + currency: computed.currency, + totals: computed.totals, + groups: computed.groupRows.map((g) => ({ + name: g.group.name, + planned: g.plannedTotal, + ...(g.buffer ? { buffer: g.buffer } : {}), + actual: g.actual, + remaining: g.remaining, + overspent: g.overspent, + ...rollover(g), + categories: briefCategories(g.categories), + })), + ungrouped: briefCategories(computed.ungroupedRows), + income: briefCategories(computed.incomeRows), + }; +} + function listTargets(targets: AllocationTarget[]): string { return `Targets: ${targets.map((t) => `${t.name} (${t.id})`).join(", ")}`; } diff --git a/src/commands/reads.ts b/src/commands/reads.ts index 6afdbc3..1fef0f7 100644 --- a/src/commands/reads.ts +++ b/src/commands/reads.ts @@ -1,32 +1,101 @@ // Read commands for the data the MCP read tools return. The REST responses // already carry every field those tools add (tracking mode, provider, asset -// id, local values, FX rate, ...), so these pass them through unchanged, -// except that holdings drop their classification tree and health its -// per-entity diagnostics unless asked (--full). +// id, local values, FX rate, ...). Holdings and health print a brief shape +// unless asked (--full): agents pay for every byte. import type { Command } from "commander"; import { open, output } from "../context"; +import { omit } from "../output"; + +type Money = { local: number; base: number } | null | undefined; interface Holding { - metadata?: unknown; - instrument?: { classifications?: unknown; notes?: unknown }; + id: string; + accountId: string; + holdingType: string; + isClosed?: boolean; + instrument?: { id: string; symbol: string; name?: string | null } | null; + quantity: number; + price?: number | null; + localCurrency: string; + baseCurrency: string; + fxRate?: number | null; + marketValue?: Money; + costBasis?: Money; + unrealizedGain?: Money; + totalGain?: Money; + totalGainPct?: number | null; + income?: Money; + dayChangePct?: number | null; + weight?: number; + sourceAccountIds?: string[]; +} + +/** Drops null and undefined values. */ +function compact(row: Record): Record { + return Object.fromEntries(Object.entries(row).filter(([, v]) => v !== null && v !== undefined)); } -// The classification tree (sectors, regions, colours) is most of a holding's size. -function slim(holding: Holding): Holding { - const copy = { ...holding, ...(holding.instrument && { instrument: { ...holding.instrument } }) }; - delete copy.metadata; - delete copy.instrument?.classifications; - delete copy.instrument?.notes; - return copy; +/** + * One flat row per holding, money in the base currency. A full holding is + * about 1 KB: ten local/base money pairs, lots, classifications and provider + * fields. Price stays in the holding's own currency, as the server sends it. + */ +export function briefHolding(h: Holding): Record { + const foreign = h.localCurrency !== h.baseCurrency; + return compact({ + id: h.id, + accountId: h.accountId, + holdingType: h.holdingType, + isClosed: h.isClosed || undefined, + assetId: h.instrument?.id, + symbol: h.instrument?.symbol, + name: h.instrument?.name, + quantity: h.quantity, + price: h.price, + currency: h.localCurrency, + baseCurrency: h.baseCurrency, + fxRate: foreign ? h.fxRate : undefined, + marketValueLocal: foreign ? h.marketValue?.local : undefined, + marketValue: h.marketValue?.base, + costBasis: h.costBasis?.base, + unrealizedGain: h.unrealizedGain?.base, + totalGain: h.totalGain?.base, + totalGainPct: h.totalGainPct, + income: h.income?.base || undefined, + dayChangePct: h.dayChangePct, + weight: h.weight, + sourceAccountIds: h.sourceAccountIds?.length ? h.sourceAccountIds : undefined, + }); } interface Health { - issues?: { diagnostics?: unknown }[]; + issues?: { + diagnostics?: unknown; + fixAction?: { id: string; label: string; payload?: unknown }; + navigateAction?: unknown; + params?: unknown; + affectedItems?: { id: string; name: string; symbol?: string; route?: string }[]; + }[]; } // diagnostics repeats each issue's details and affected items, entity by entity. +// The other fields left out are for the web app: routes, translation keys and +// params, cache keys, and the fix button's payload of asset ids, which +// affectedItems also lists. function slimHealth(health: Health): Health { - return { ...health, issues: health.issues?.map((i) => ({ ...i, diagnostics: undefined })) }; + return { + ...health, + issues: health.issues?.map((issue) => { + const { fixAction, affectedItems } = issue; + return { + ...omit(issue, "diagnostics", "navigateAction", "params", "code", "dataHash", "timestamp"), + ...(fixAction && { fixAction: { id: fixAction.id, label: fixAction.label } }), + ...(affectedItems && { + affectedItems: affectedItems.map((item) => omit(item, "route")), + }), + }; + }), + }; } export function registerReads(program: Command): void { @@ -48,7 +117,7 @@ export function registerReads(program: Command): void { .option("--include-closed", "include closed positions (with --account)") .option( "--full", - "also return each instrument's classifications and notes and the holding's metadata, which are left out by default", + "return every field the server sends (local values, lots, classifications, ...), not one flat row per holding", ) .action( async (opts: { account?: string; includeClosed?: boolean; full?: boolean }, cmd: Command) => { @@ -59,7 +128,7 @@ export function registerReads(program: Command): void { filter, ...(opts.includeClosed ? { includeClosed: true } : {}), }); - output(cmd, opts.full ? holdings : holdings.map(slim)); + output(cmd, opts.full ? holdings : holdings.map(briefHolding)); }, ); @@ -67,7 +136,10 @@ export function registerReads(program: Command): void { .command("health") .description("Health Center status (get_health_status); --refresh runs the checks first") .option("--refresh", "run the full checks first (run_health_checks)") - .option("--full", "also return each issue's diagnostics, which are left out by default") + .option( + "--full", + "return every field of each issue (diagnostics, actions, routes), not only the main ones", + ) .action(async (opts: { refresh?: boolean; full?: boolean }, cmd: Command) => { const client = open(cmd); const show = (health: Health) => output(cmd, opts.full ? health : slimHealth(health)); diff --git a/src/context.ts b/src/context.ts index 696f8d7..659b908 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { Client } from "./client"; -import { print } from "./output"; +import { pick, print } from "./output"; declare const __WF_VERSION__: string; export const VERSION = typeof __WF_VERSION__ === "string" ? __WF_VERSION__ : "0.0.0"; @@ -8,6 +8,7 @@ export const VERSION = typeof __WF_VERSION__ === "string" ? __WF_VERSION__ : "0. interface GlobalOptions { url?: string; table?: boolean; + fields?: string[]; } /** Global flags merged with the subcommand's own flags. */ @@ -20,5 +21,6 @@ export function open(cmd: Command): Client { } export function output(cmd: Command, value: unknown): void { - print(value, { table: globals(cmd).table }); + const { table, fields } = globals(cmd); + print(fields ? pick(value, fields) : value, { table }); } diff --git a/src/events.ts b/src/events.ts index 54fa6a1..b90c603 100644 --- a/src/events.ts +++ b/src/events.ts @@ -47,12 +47,24 @@ export async function* parseSse(body: ReadableStream): AsyncGenerato } } -/** The skip list names an asset id and a reason per skipped asset, around 2 KB, and never changes the outcome. */ -function withoutSkipped(data: unknown): unknown { +/** + * The skip list names an asset id and a reason per skipped asset, around 2 KB, and never changes the outcome. + * Each failure's message ends with the provider's request URL, about 200 bytes that only repeat the symbol. + */ +function slimSync(data: unknown): unknown { if (typeof data !== "object" || data === null) return data; const rest: Record = { ...data }; delete rest.skipped_reasons; delete rest.show_skipped_reasons; + if (Array.isArray(rest.failed_syncs)) { + rest.failed_syncs = rest.failed_syncs.map((failure: unknown) => + Array.isArray(failure) + ? failure.map((part) => + typeof part === "string" ? part.replace(/ for url \(https?:[^)]*\)/g, "") : part, + ) + : failure, + ); + } return rest; } @@ -119,7 +131,7 @@ export async function watchPortfolio(): Promise { for await (const event of events) { switch (event.name) { case "market:sync-complete": - marketSync = withoutSkipped(event.data); + marketSync = slimSync(event.data); break; case "market:sync-error": marketError = event.data; diff --git a/src/output.ts b/src/output.ts index 519db31..113144d 100644 --- a/src/output.ts +++ b/src/output.ts @@ -57,3 +57,49 @@ function grid(columns: string[], rows: string[][]): string { .trimEnd(); return [line(columns), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)].join("\n"); } + +/** A copy of `row` without `keys`. */ +export function omit(row: T, ...keys: string[]): T { + const copy = { ...row } as Record; + for (const key of keys) delete copy[key]; + return copy as T; +} + +function pickPaths(row: unknown, paths: string[][]): unknown { + if (row === null || typeof row !== "object" || Array.isArray(row)) return row; + const out: Record = {}; + for (const path of paths) { + let source: unknown = row; + for (const key of path) { + source = + source !== null && typeof source === "object" + ? (source as Record)[key] + : undefined; + } + if (source === undefined) continue; + let target = out; + for (const key of path.slice(0, -1)) { + target = (target[key] ??= {}) as Record; + } + target[path.at(-1)!] = source; + } + return out; +} + +/** + * Keeps only the dotted `fields` of each row: of each item of an array, of + * each item of `data` in a `{data, meta}` page (meta stays), or of an object. + */ +export function pick(value: unknown, fields: string[]): unknown { + const paths = fields.map((f) => f.split(".")); + if (Array.isArray(value)) return value.map((row) => pickPaths(row, paths)); + if ( + value !== null && + typeof value === "object" && + Array.isArray((value as { data?: unknown }).data) + ) { + const page = value as { data: unknown[] }; + return { ...page, data: page.data.map((row) => pickPaths(row, paths)) }; + } + return pickPaths(value, paths); +} diff --git a/src/program.ts b/src/program.ts index abc41e5..f82f49f 100644 --- a/src/program.ts +++ b/src/program.ts @@ -57,6 +57,15 @@ export function buildProgram(): Command { .version(VERSION) .option("--url ", "server URL (overrides WF_URL and the config file)") .option("--table", "print a table instead of JSON") + .option( + "--fields ", + "print only these comma-separated fields of each row (dotted paths, such as instrument.symbol)", + (list: string) => + list + .split(",") + .map((f) => f.trim()) + .filter(Boolean), + ) // Must come before subcommands are added: they copy it at creation. // main() maps commander errors to exit code 2. .exitOverride() diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index 8fb0613..65e1cc9 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -242,7 +242,7 @@ describe.skipIf(!URL || !PASSWORD)("against a live server", () => { queued: true, wait: { status: "complete" }, }); - expect(await wfJson("budget")).toHaveProperty("state"); + expect(await wfJson("budget")).toHaveProperty("totals"); expect( await wfJson("spending", "report", "--from", "2026-01-01", "--to", "2026-01-31"), ).toHaveProperty("current"); diff --git a/test/unit/activities.test.ts b/test/unit/activities.test.ts index 7063d87..6f8615b 100644 --- a/test/unit/activities.test.ts +++ b/test/unit/activities.test.ts @@ -368,32 +368,90 @@ describe("reads and health", () => { expect(seen[1]!.path).toBe("/api/v1/holdings?accountId=a1&includeClosed=true"); }); - it("holdings drops classifications, notes and metadata unless --full", async () => { + it("holdings prints one flat row in the base currency unless --full", async () => { const holding = { id: "h1", - price: 10, + accountId: "a1", + holdingType: "security", + isClosed: false, + instrument: { id: "s1", symbol: "SAP", name: null, isin: "DE0007164600", notes: "n" }, + quantity: 5, + price: 200, + localCurrency: "EUR", + baseCurrency: "USD", + fxRate: 1.1, + marketValue: { local: 1000, base: 1100 }, + costBasis: { local: 900, base: 990 }, + unrealizedGain: { local: 100, base: 110 }, + totalGain: { local: 100, base: 110 }, + totalGainPct: 0.11, + income: { local: 0, base: 0 }, + dayChange: null, + dayChangePct: null, + lots: [{ id: "l1", quantity: 5 }], + weight: 0.5, + sourceAccountIds: [], metadata: { profile: {} }, - instrument: { symbol: "BLOK", notes: "n", classifications: { sectors: [] } }, }; mockFetch(() => json([holding])); expect((await run("holdings")).json()).toEqual([ - { id: "h1", price: 10, instrument: { symbol: "BLOK" } }, + { + id: "h1", + accountId: "a1", + holdingType: "security", + assetId: "s1", + symbol: "SAP", + quantity: 5, + price: 200, + currency: "EUR", + baseCurrency: "USD", + fxRate: 1.1, + marketValueLocal: 1000, + marketValue: 1100, + costBasis: 990, + unrealizedGain: 110, + totalGain: 110, + totalGainPct: 0.11, + weight: 0.5, + }, ]); expect((await run("holdings", "--full")).json()).toEqual([holding]); }); + it("--fields picks dotted fields from each row", async () => { + mockFetch(() => json([{ id: "h1", instrument: { symbol: "SAP", name: "SAP SE" }, weight: 1 }])); + expect( + (await run("holdings", "--full", "--fields", "instrument.symbol,weight")).json(), + ).toEqual([{ instrument: { symbol: "SAP" }, weight: 1 }]); + }); + it("health reads the cached status", async () => { const seen = mockFetch(() => json({ overallSeverity: "INFO", issues: [] })); await run("health"); expect(seen.map((r) => `${r.method} ${r.path}`)).toEqual(["GET /api/v1/health/status"]); }); - it("health drops each issue's diagnostics unless --full", async () => { - const issue = { id: "i1", title: "Cash went below zero", diagnostics: [{ code: "X" }] }; + it("health drops diagnostics and the web app's routes and payloads unless --full", async () => { + const issue = { + id: "i1", + title: "No market data for 1 holding", + params: { count: 1 }, + fixAction: { id: "sync_prices", label: "Sync Prices", payload: ["s1"] }, + navigateAction: { route: "/settings/market-data", label: "Review" }, + affectedItems: [{ id: "s1", name: "SAP", symbol: "SAP", route: "/holdings/s1" }], + diagnostics: [{ code: "X" }], + }; mockFetch(() => json({ overallSeverity: "WARNING", issues: [issue] })); expect((await run("health")).json()).toEqual({ overallSeverity: "WARNING", - issues: [{ id: "i1", title: "Cash went below zero" }], + issues: [ + { + id: "i1", + title: "No market data for 1 holding", + fixAction: { id: "sync_prices", label: "Sync Prices" }, + affectedItems: [{ id: "s1", name: "SAP", symbol: "SAP" }], + }, + ], }); expect((await run("health", "--full")).json()).toEqual({ overallSeverity: "WARNING", diff --git a/test/unit/curated.test.ts b/test/unit/curated.test.ts index 2e63803..ce3cea5 100644 --- a/test/unit/curated.test.ts +++ b/test/unit/curated.test.ts @@ -94,6 +94,21 @@ describe("wf rebalance", () => { }); }); + it("drops category colours unless --full", async () => { + const drift = { + rows: [{ categoryId: "c1", color: "#fff", driftBps: 10 }], + holdings: { rows: [{ symbol: "SAP", categoryColor: "#fff" }] }, + }; + server({ "POST /api/v1/allocation-targets/t1/drift": drift }); + expect((await run("rebalance", "--holdings")).json()).toMatchObject({ + drift: { + rows: [{ categoryId: "c1", driftBps: 10 }], + holdings: { rows: [{ symbol: "SAP" }] }, + }, + }); + expect(((await run("rebalance", "--full")).json() as { drift: unknown }).drift).toEqual(drift); + }); + it("exits 2 when several targets are active and none is picked", async () => { server({ "GET /api/v1/allocation-targets": [ @@ -162,6 +177,21 @@ describe("wf spending report", () => { } }); + it("drops the per-day totals unless --full", async () => { + const report = { + current: { outflow: 10 }, + spendingBreakdown: [{ categoryId: "cat_food", amount: 10 }], + byDay: [{ date: "2026-01-01" }], + byDayByCategory: [{ date: "2026-01-01", categoryId: "cat_food" }], + }; + server({ "POST /api/v1/spending/report": report }); + expect((await run("spending", "report")).json()).toEqual({ + current: { outflow: 10 }, + spendingBreakdown: [{ categoryId: "cat_food", amount: 10 }], + }); + expect((await run("spending", "report", "--full")).json()).toEqual(report); + }); + it.each([ [["--from", "2026-13-40"]], [["--from", "2026-02-30"]], @@ -185,6 +215,81 @@ describe("wf budget", () => { expect(seen[0]!.path).toBe("/api/v1/spending/budget"); }); + it("prints computed rows without ids, colours or empty categories unless --full", async () => { + const category = (name: string, target: number, actual: number) => ({ + taxonomyId: "spending_categories", + categoryId: `cat_${name}`, + groupId: "g1", + parentId: null, + name, + color: "#fff", + icon: "Home", + target, + actual, + rolloverIn: 0, + rolloverOut: 0, + remaining: target - actual, + overspent: actual > target, + hasDefaultTarget: false, + hasMonthOverride: false, + rolloverEnabled: false, + }); + const budget = { + state: { groups: [{ id: "g1", name: "Needs" }], groupAssignments: [], targets: [] }, + computed: { + currency: "USD", + periodKey: "2026-09", + fxAsOf: "2026-09-30", + groupRows: [ + { + group: { id: "g1", name: "Needs", color: "#fff", createdAt: "x" }, + categoryTargetTotal: 500, + buffer: 0, + plannedTotal: 500, + actual: 600, + rolloverIn: 0, + rolloverOut: 0, + remaining: -100, + overspent: true, + rolloverEnabled: false, + categories: [category("housing", 500, 600), category("bills", 0, 0)], + }, + ], + ungroupedRows: [], + incomeRows: [category("salary", 0, 0)], + totals: { spendingPlanned: 500, spendingActual: 600 }, + }, + }; + server({ "GET /api/v1/spending/budget": budget }); + expect((await run("budget")).json()).toEqual({ + periodKey: "2026-09", + currency: "USD", + totals: { spendingPlanned: 500, spendingActual: 600 }, + groups: [ + { + name: "Needs", + planned: 500, + actual: 600, + remaining: -100, + overspent: true, + categories: [ + { + categoryId: "cat_housing", + name: "housing", + target: 500, + actual: 600, + remaining: -100, + overspent: true, + }, + ], + }, + ], + ungrouped: [], + income: [], + }); + expect((await run("budget", "--full")).json()).toEqual(budget); + }); + it("rejects a bad month", async () => { server(); expect((await run("budget", "--period", "2026-9")).code).toBe(2); diff --git a/test/unit/wait.test.ts b/test/unit/wait.test.ts index c776def..d0646a6 100644 --- a/test/unit/wait.test.ts +++ b/test/unit/wait.test.ts @@ -85,18 +85,18 @@ describe("--wait", () => { }); }); - it("drops the list of skipped assets from marketSync", async () => { + it("drops the skipped assets and the provider URLs from marketSync", async () => { server([ event("portfolio:update-start"), event( "market:sync-complete", - '{"failed_syncs":[["a","boom"]],"show_skipped_reasons":false,"skipped_reasons":[["b","Inactive asset"]]}', + '{"failed_syncs":[["a","boom: error sending request for url (https://x.test/a?b=c)"]],"show_skipped_reasons":false,"skipped_reasons":[["b","Inactive asset"]]}', ), event("portfolio:update-complete"), ]); const res = await run(...createArgs, "--wait"); expect((res.json() as { wait: { marketSync: unknown } }).wait.marketSync).toEqual({ - failed_syncs: [["a", "boom"]], + failed_syncs: [["a", "boom: error sending request"]], }); }); From 54deed844e9b887d7d31f2898034aa514c3db50f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:43:19 +0000 Subject: [PATCH 2/5] spending report: send whole days as RFC 3339 in the app's timezone The server parses startDate and endDate as RFC 3339 instants. With no account opted into spending it returns zeros before parsing, so a bare YYYY-MM-DD passed the integration test; once an account is tracked the report failed with "premature end of input" (500). Send the first and last millisecond of each day in the app's timezone, as the web app does (zonedCalendarDateBoundaryToDate). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178eJr8TT5NezDCgT1yLgZp --- src/commands/args.ts | 60 +++++++++++++++++++++++++++++++++++++++ src/commands/curated.ts | 21 +++++++++++--- test/unit/curated.test.ts | 42 ++++++++++++++++++++------- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/commands/args.ts b/src/commands/args.ts index 6837ce7..36e741d 100644 --- a/src/commands/args.ts +++ b/src/commands/args.ts @@ -44,3 +44,63 @@ export function intArg(value: string): number { } export const collect = (value: string, previous: string[] = []) => [...previous, value]; + +/** The timezone if Intl knows it, else this machine's, as the web app resolves it. */ +function resolveTimezone(timezone?: string | null): string { + const candidate = timezone?.trim(); + if (candidate) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date()); + return candidate; + } catch { + // fall through + } + } + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +/** How far `timezone`'s wall clock is ahead of UTC at `instant`, in ms. */ +function offsetMs(instant: Date, timezone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(instant); + const part = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((p) => p.type === type)?.value); + const hour = part("hour") === 24 ? 0 : part("hour"); + const wall = Date.UTC( + part("year"), + part("month") - 1, + part("day"), + hour, + part("minute"), + part("second"), + instant.getUTCMilliseconds(), + ); + return wall - instant.getTime(); +} + +/** + * The first or last millisecond of a YYYY-MM-DD day in `timezone`, as an + * RFC 3339 timestamp: what the web app sends for a date range + * (zonedCalendarDateBoundaryToDate in features/spending/lib/timezone.ts). + */ +export function dayBoundary(date: string, boundary: "start" | "end", timezone?: string | null) { + const tz = resolveTimezone(timezone); + const [y, m, d] = date.split("-").map(Number) as [number, number, number]; + const wall = + boundary === "start" ? Date.UTC(y, m - 1, d) : Date.UTC(y, m - 1, d, 23, 59, 59, 999); + let instant = new Date(wall); + for (let i = 0; i < 3; i += 1) { + const next = new Date(wall - offsetMs(instant, tz)); + if (next.getTime() === instant.getTime()) break; + instant = next; + } + return instant.toISOString(); +} diff --git a/src/commands/curated.ts b/src/commands/curated.ts index 64135cc..3f7b550 100644 --- a/src/commands/curated.ts +++ b/src/commands/curated.ts @@ -5,7 +5,16 @@ import { UsageError } from "../errors"; import { open, output } from "../context"; import { omit } from "../output"; import { addWaitOptions, withWait, type WaitOptions } from "../wait"; -import { collect, dateArg, decimalArg, intArg, monthArg, numberArg, today } from "./args"; +import { + collect, + dateArg, + dayBoundary, + decimalArg, + intArg, + monthArg, + numberArg, + today, +} from "./args"; type AccountScope = | { type: "all" } @@ -151,12 +160,16 @@ export function registerCurated(program: Command): void { // Without --from, report the month of --to; a past --to must not start after it. const startDate = opts.from ?? `${endDate.slice(0, 8)}01`; if (startDate > endDate) throw new UsageError("--from must not be after --to."); + const client = open(cmd); + // The server reads RFC 3339 instants, and refuses a bare date once an + // account is tracked. As the web app: whole days in the app's timezone. + const settings = await client.invoke<{ timezone?: string }>("get_settings"); const request = { - startDate, - endDate, + startDate: dayBoundary(startDate, "start", settings?.timezone), + endDate: dayBoundary(endDate, "end", settings?.timezone), ...(opts.account ? { accountIds: opts.account } : {}), }; - const report = await open(cmd).invoke>("get_spending_report", { + const report = await client.invoke>("get_spending_report", { request, }); if (!opts.full) { diff --git a/test/unit/curated.test.ts b/test/unit/curated.test.ts index ce3cea5..456448a 100644 --- a/test/unit/curated.test.ts +++ b/test/unit/curated.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { targetFilter } from "../../src/commands/curated"; -import { today } from "../../src/commands/args"; +import { dayBoundary, today } from "../../src/commands/args"; import { json, mockFetch, run, useTempConfig, type Recorded } from "../helpers"; useTempConfig(); @@ -136,8 +136,12 @@ describe("wf rebalance", () => { }); describe("wf spending report", () => { - it("sends the request object with dates and accounts", async () => { - const seen = server(); + // The app's timezone, not the machine's, sets where each day starts. + const SETTINGS = { "GET /api/v1/settings": { timezone: "Asia/Hong_Kong" } }; + const report = (seen: Recorded[]) => seen.find((r) => r.path === "/api/v1/spending/report")!; + + it("sends whole days in the app's timezone as RFC 3339, and the accounts", async () => { + const seen = server(SETTINGS); await run( "spending", "report", @@ -148,19 +152,25 @@ describe("wf spending report", () => { "--account", "a1", ); - expect(seen[0]).toMatchObject({ + expect(report(seen)).toMatchObject({ method: "POST", - path: "/api/v1/spending/report", - body: { startDate: "2026-01-01", endDate: "2026-03-31", accountIds: ["a1"] }, + body: { + startDate: "2025-12-31T16:00:00.000Z", + endDate: "2026-03-31T15:59:59.999Z", + accountIds: ["a1"], + }, }); }); it("defaults to this month so far", async () => { vi.useFakeTimers({ now: new Date(2026, 9, 18, 12) }); try { - const seen = server(); + const seen = server(SETTINGS); await run("spending", "report"); - expect(seen[0]!.body).toEqual({ startDate: "2026-10-01", endDate: "2026-10-18" }); + expect(report(seen).body).toEqual({ + startDate: "2026-09-30T16:00:00.000Z", + endDate: "2026-10-18T15:59:59.999Z", + }); } finally { vi.useRealTimers(); } @@ -169,9 +179,12 @@ describe("wf spending report", () => { it("starts at the first day of the --to month without --from", async () => { vi.useFakeTimers({ now: new Date(2026, 9, 18, 12) }); try { - const seen = server(); + const seen = server(SETTINGS); expect((await run("spending", "report", "--to", "2026-01-31")).code).toBe(0); - expect(seen[0]!.body).toEqual({ startDate: "2026-01-01", endDate: "2026-01-31" }); + expect(report(seen).body).toEqual({ + startDate: "2025-12-31T16:00:00.000Z", + endDate: "2026-01-31T15:59:59.999Z", + }); } finally { vi.useRealTimers(); } @@ -202,6 +215,15 @@ describe("wf spending report", () => { }); }); +describe("dayBoundary", () => { + it("handles UTC, offsets and a DST change", () => { + expect(dayBoundary("2025-11-01", "start", "UTC")).toBe("2025-11-01T00:00:00.000Z"); + expect(dayBoundary("2025-11-01", "start", "America/New_York")).toBe("2025-11-01T04:00:00.000Z"); + // London moves to BST at 01:00 UTC on 2025-03-30; the day ends at 22:59:59.999 UTC. + expect(dayBoundary("2025-03-30", "end", "Europe/London")).toBe("2025-03-30T22:59:59.999Z"); + }); +}); + describe("wf budget", () => { it("sends periodKey as a query", async () => { const seen = server(); From 0b187b9d6640f1ec3f25650bee2d5788fbdcd5a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:46:19 +0000 Subject: [PATCH 3/5] Add an output size benchmark: seed script and bench table scripts/bench-seed.sh fills a fresh test server with a fixed data set (3 accounts, 8 stocks, ~120 activities, a month of categorised spending with budget targets). scripts/bench.sh prints a Markdown table of each read command's output size, and with a git ref builds that ref in a temporary worktree and compares. test-server.sh: WF_TEST_PROXY=1 passes HTTPS_PROXY (and WF_TEST_CA_BUNDLE) to the server, so quote syncs work in sandboxes whose outbound traffic goes through a proxy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178eJr8TT5NezDCgT1yLgZp --- .gitignore | 1 + CLAUDE.md | 8 +++ README.md | 22 ++++++++ scripts/bench-seed.sh | 125 +++++++++++++++++++++++++++++++++++++++++ scripts/bench.sh | 91 ++++++++++++++++++++++++++++++ scripts/test-server.sh | 17 +++++- 6 files changed, 262 insertions(+), 2 deletions(-) create mode 100755 scripts/bench-seed.sh create mode 100755 scripts/bench.sh diff --git a/.gitignore b/.gitignore index f2b8937..cbffc6a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist coverage *.log CLAUDE.local.md +.bench/ diff --git a/CLAUDE.md b/CLAUDE.md index f590bc1..e04a70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,14 @@ WF_TEST_REFRESH=1 pnpm test:integration scripts/test-server.sh down ``` +Output size benchmark (see README "Output size benchmark"). Run it before and after a change to what a command prints: + +```sh +pnpm build && eval "$(scripts/test-server.sh up)" +scripts/bench-seed.sh # once per server; WF_TEST_PROXY=1 on test-server.sh if quotes need a proxy +scripts/bench.sh main # Markdown table: main vs dist/wf.js +``` + CI also fails if `vendor/`, `src/contract.ts` or `UPSTREAM_TAG` differ from what is committed (`git diff --exit-code`). ## Architecture diff --git a/README.md b/README.md index 8a0c76b..4148b4d 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,28 @@ WF_TEST_REFRESH=1 pnpm test:integration scripts/test-server.sh down ``` +### Output size benchmark + +Agents pay for every byte that `wf` prints. `scripts/bench.sh` prints a +Markdown table of how much each read command outputs (tokens are estimated as +bytes / 4) against a server that `scripts/bench-seed.sh` fills with a fixed +data set: 3 accounts, 8 stocks, about 120 activities, and a month of +categorised spending with budget targets. + +```sh +pnpm build +eval "$(scripts/test-server.sh up)" +scripts/bench-seed.sh # once per server +scripts/bench.sh # this build +scripts/bench.sh v0.3.1 # this build against a tag or commit +scripts/test-server.sh down +``` + +Without prices, holdings carry nulls and look smaller than a real portfolio's. +The server fetches them from Yahoo Finance. If outbound traffic must go through +a proxy, start the server with `WF_TEST_PROXY=1` (it passes `HTTPS_PROXY`), and +with `WF_TEST_CA_BUNDLE=` when that proxy has its own CA. + ### Upgrading upstream ```sh diff --git a/scripts/bench-seed.sh b/scripts/bench-seed.sh new file mode 100755 index 0000000..bd5b8e2 --- /dev/null +++ b/scripts/bench-seed.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Seeds a fresh test server (scripts/test-server.sh up) with a fixed data set +# for scripts/bench.sh: +# +# Brokerage USD securities: 6 stocks bought monthly in 2025, dividends +# Euro ISA EUR securities: 2 stocks bought quarterly +# Checking USD cash, tracked for spending: November 2025 salary, +# categorised spending, and budget targets for 2025-11 +# +# eval "$(WF_TEST_PROXY=1 scripts/test-server.sh up)" +# pnpm build && scripts/bench-seed.sh +# +# Run it once per server: it adds the same data again on a second run. Prices +# need the server to reach Yahoo Finance; without them, holdings carry nulls +# and the numbers are smaller than a real portfolio's. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +: "${WF_TEST_URL:?Run: eval \"\$(scripts/test-server.sh up)\"}" +: "${WF_TEST_PASSWORD:?Run: eval \"\$(scripts/test-server.sh up)\"}" +export WF_CONFIG_DIR="${WF_BENCH_CONFIG_DIR:-$ROOT/.bench}" +mkdir -p "$WF_CONFIG_DIR" +wf() { node "$ROOT/dist/wf.js" "$@"; } +id() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).id))'; } + +WF_PASSWORD="$WF_TEST_PASSWORD" wf login --url "$WF_TEST_URL" >/dev/null +wf call update_settings --args \ + '{"settingsUpdate":{"baseCurrency":"USD","timezone":"UTC","onboardingCompleted":true}}' >/dev/null + +account() { + wf call create_account --args "{\"account\":{\"name\":\"$1\",\"accountType\":\"$2\",\"currency\":\"$3\", + \"isDefault\":false,\"isActive\":true,\"isArchived\":false,\"trackingMode\":\"TRANSACTIONS\"}}" | id +} +activity() { wf call create_activity --args "{\"activity\":$1}" | id; } + +BROKER=$(account Brokerage SECURITIES USD) +ISA=$(account "Euro ISA" SECURITIES EUR) +CHECKING=$(account Checking CASH USD) +echo "accounts: $BROKER $ISA $CHECKING" >&2 + +activity "{\"accountId\":\"$BROKER\",\"activityType\":\"DEPOSIT\",\"activityDate\":\"2025-01-02\",\"amount\":\"200000\",\"currency\":\"USD\"}" >/dev/null +activity "{\"accountId\":\"$ISA\",\"activityType\":\"DEPOSIT\",\"activityDate\":\"2025-01-02\",\"amount\":\"100000\",\"currency\":\"EUR\"}" >/dev/null +for symbol in AAPL MSFT NVDA GOOGL AMZN VTI; do + for month in 01 02 03 04 05 06 07 08 09 10 11 12; do + activity "{\"accountId\":\"$BROKER\",\"activityType\":\"BUY\",\"activityDate\":\"2025-$month-15\", + \"quantity\":\"2\",\"unitPrice\":\"$((100 + 10#$month * 3))\",\"fee\":\"1\",\"currency\":\"USD\", + \"asset\":{\"symbol\":\"$symbol\",\"quoteCcy\":\"USD\"}}" >/dev/null + done +done +for symbol in SAP.DE ASML.AS; do + for month in 02 05 08 11; do + activity "{\"accountId\":\"$ISA\",\"activityType\":\"BUY\",\"activityDate\":\"2025-$month-10\", + \"quantity\":\"5\",\"unitPrice\":\"200\",\"fee\":\"2\",\"currency\":\"EUR\", + \"asset\":{\"symbol\":\"$symbol\",\"quoteCcy\":\"EUR\"}}" >/dev/null + done +done +for month in 03 06 09 12; do + activity "{\"accountId\":\"$BROKER\",\"activityType\":\"DIVIDEND\",\"activityDate\":\"2025-$month-20\", + \"amount\":\"12.5\",\"currency\":\"USD\",\"asset\":{\"symbol\":\"AAPL\"}}" >/dev/null +done +echo "investments: done" >&2 + +# Spending: the account must be tracked before its activities count. +wf call update_spending_settings --args \ + "{\"update\":{\"enabled\":true,\"accountIds\":[\"$CHECKING\"]}}" >/dev/null +categorise() { + wf call assign_activity_category --args \ + "{\"activityId\":\"$1\",\"taxonomyId\":\"$2\",\"categoryId\":\"$3\"}" >/dev/null +} +salary=$(activity "{\"accountId\":\"$CHECKING\",\"activityType\":\"DEPOSIT\",\"activityDate\":\"2025-11-01\",\"amount\":\"6500\",\"currency\":\"USD\"}") +categorise "$salary" income_sources cat_income_employment +# day category amount +while read -r day category amount; do + spend=$(activity "{\"accountId\":\"$CHECKING\",\"activityType\":\"WITHDRAWAL\", + \"activityDate\":\"2025-11-$day\",\"amount\":\"$amount\",\"currency\":\"USD\"}") + categorise "$spend" spending_categories "$category" +done <<'ROWS' +01 cat_housing 2200 +02 cat_groceries 84.20 +03 cat_transport 45 +04 cat_food 32.50 +05 cat_bills 120 +06 cat_groceries 61.75 +07 cat_entertainment 18.99 +08 cat_shopping 149 +09 cat_food 27.80 +10 cat_health 60 +11 cat_groceries 92.10 +12 cat_transport 45 +13 cat_food 41.20 +14 cat_personal 35 +15 cat_bills 64.99 +16 cat_groceries 73.40 +17 cat_travel 410 +18 cat_food 22.30 +19 cat_gifts 75 +20 cat_groceries 88.60 +21 cat_entertainment 12.99 +22 cat_fees 4.50 +23 cat_food 55.10 +24 cat_shopping 89 +25 cat_groceries 66.30 +26 cat_transport 45 +27 cat_education 120 +28 cat_food 38.90 +29 cat_other_expense 25 +30 cat_groceries 79.95 +ROWS +while read -r category amount; do + wf call upsert_budget_target --args "{\"periodKey\":\"2025-11\",\"target\":{\"periodKey\":\"2025-11\", + \"targetType\":\"category\",\"taxonomyId\":\"spending_categories\",\"categoryId\":\"$category\", + \"amount\":\"$amount\"}}" >/dev/null +done <<'ROWS' +cat_housing 2200 +cat_groceries 500 +cat_food 200 +cat_transport 150 +cat_bills 200 +cat_shopping 150 +cat_entertainment 50 +ROWS +echo "spending: done" >&2 + +# Quotes and the recalculation; the sync result says which symbols failed. +wf sync --wait --wait-timeout 300 >&2 diff --git a/scripts/bench.sh b/scripts/bench.sh new file mode 100755 index 0000000..eeeef65 --- /dev/null +++ b/scripts/bench.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Measures how much each read command prints, as a Markdown table: what an +# agent pays to read it. Tokens are estimated as bytes / 4. +# +# scripts/bench.sh # dist/wf.js against the seeded server +# scripts/bench.sh # also builds and compares +# +# Needs a server seeded by scripts/bench-seed.sh (it reuses its login), and a +# build (pnpm build). Runs only reads, so it can run any number of times. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export WF_CONFIG_DIR="${WF_BENCH_CONFIG_DIR:-$ROOT/.bench}" +NEW="$ROOT/dist/wf.js" +[ -f "$NEW" ] || { echo "Run pnpm build first." >&2; exit 2; } +[ -d "$WF_CONFIG_DIR" ] || { echo "Run scripts/bench-seed.sh first." >&2; exit 2; } + +BASE="" +if [ -n "${1:-}" ]; then + TREE="$(mktemp -d)" + trap 'git -C "$ROOT" worktree remove --force "$TREE" >/dev/null 2>&1 || true' EXIT + git -C "$ROOT" worktree add -q --detach "$TREE" "$1" + ln -s "$ROOT/node_modules" "$TREE/node_modules" + (cd "$TREE" && pnpm -s build >/dev/null) + BASE="$TREE/dist/wf.js" +fi + +account() { + node "$NEW" accounts | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{ + const a=JSON.parse(s).find((x)=>x.name===process.argv[1]); console.log(a ? a.id : "")})' "$1" +} +BROKER="$(account Brokerage)" +[ -n "$BROKER" ] || { echo "No Brokerage account. Run scripts/bench-seed.sh first." >&2; exit 2; } + +# label|arguments. is replaced by the Brokerage account id. +COMMANDS=( + "status|status" + "accounts|accounts" + "holdings|holdings" + "holdings --account|holdings --account " + "holdings --full|holdings --full" + "activity search|activity search" + "activity search --page-size 100|activity search --page-size 100" + "activity search --full|activity search --full" + "health|health" + "budget|budget --period 2025-11" + "budget --full|budget --period 2025-11 --full" + "spending report|spending report --from 2025-11-01 --to 2025-11-30" + "spending report --full|spending report --from 2025-11-01 --to 2025-11-30 --full" + "commands|commands" + "schema create_activity|schema create_activity" + "--help|--help" +) + +# Bytes on stdout, or "exit N" when the command fails. +measure() { + local bin=$1 out code + shift + set +e + out="$(node "$bin" "$@" 2>/dev/null | wc -c)" + code=${PIPESTATUS[0]} + set -e + if [ "$code" != 0 ]; then echo "exit $code"; else echo "$out"; fi +} + +cell() { case $1 in exit*) echo "$1" ;; *) echo "$1 (~$(($1 / 4)))" ;; esac; } + +if [ -n "$BASE" ]; then + echo "| Command | $1 bytes (~tokens) | dist bytes (~tokens) | Change |" + echo "| --- | ---: | ---: | ---: |" +else + echo "| Command | Bytes (~tokens) |" + echo "| --- | ---: |" +fi +for entry in "${COMMANDS[@]}"; do + label="${entry%%|*}" + read -r -a args <<<"${entry#*|}" + args=("${args[@]//$BROKER}") + now="$(measure "$NEW" "${args[@]}")" + if [ -n "$BASE" ]; then + before="$(measure "$BASE" "${args[@]}")" + change="" + case "$before$now" in + *exit*) ;; + *) [ "$before" -gt 0 ] && change="$(((now - before) * 100 / before))%" ;; + esac + echo "| \`$label\` | $(cell "$before") | $(cell "$now") | $change |" + else + echo "| \`$label\` | $(cell "$now") |" + fi +done diff --git a/scripts/test-server.sh b/scripts/test-server.sh index 0ec5a87..c31eda5 100755 --- a/scripts/test-server.sh +++ b/scripts/test-server.sh @@ -6,6 +6,9 @@ # # The image tag comes from UPSTREAM_TAG without the leading "v". # WF_TEST_PORT (default 8088) and WF_TEST_TTL_MINUTES (default 60) are optional. +# WF_TEST_PROXY=1 passes HTTPS_PROXY and WF_TEST_CA_BUNDLE (a CA file) to the +# server, for sandboxes where outbound traffic must go through a proxy, so +# quote syncs can reach the market data provider. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -20,8 +23,18 @@ HASH='$argon2id$v=19$m=65536,t=3,p=4$MmjLEewIk4DVBqvLmF5+Dg$0yHuiPF4d7YOOS9+tnOV case "${1:-}" in up) docker rm -f "$NAME" >/dev/null 2>&1 || true - docker run -d --name "$NAME" -p "127.0.0.1:$PORT:8088" \ - -e WF_LISTEN_ADDR=0.0.0.0:8088 \ + NET=(-p "127.0.0.1:$PORT:8088" -e WF_LISTEN_ADDR=0.0.0.0:8088) + if [ "${WF_TEST_PROXY:-}" = 1 ]; then + # A proxy on the host's loopback is reachable only from the host network. + NET=(--network host -e "WF_LISTEN_ADDR=127.0.0.1:$PORT" + -e "HTTPS_PROXY=${HTTPS_PROXY:?WF_TEST_PROXY=1 needs HTTPS_PROXY}" + -e "HTTP_PROXY=${HTTP_PROXY:-$HTTPS_PROXY}" -e NO_PROXY=127.0.0.1,localhost) + if [ -n "${WF_TEST_CA_BUNDLE:-}" ]; then + NET+=(-v "$WF_TEST_CA_BUNDLE:/etc/ssl/certs/ca-certificates.crt:ro" + -e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt) + fi + fi + docker run -d --name "$NAME" "${NET[@]}" \ -e WF_DB_PATH=/data/wealthfolio.db \ -e "WF_SECRET_KEY=$(openssl rand -base64 32)" \ -e "WF_AUTH_PASSWORD_HASH=$HASH" \ From 841736433a55f94bf5e7ea9ece0e474ac5364f33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:52:16 +0000 Subject: [PATCH 4/5] Review fixes: app-timezone today, shared timezone lookup, --fields guards - spending report: the default --to is today in the app's timezone, so it names the same day as the boundaries sent to the server. - appTimezone(client) in context.ts serves spending report and health --refresh. - --fields with an empty list exits 2 instead of printing {} rows. - --fields reads only a row's own properties, not inherited ones such as constructor or __proto__. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178eJr8TT5NezDCgT1yLgZp --- src/commands/args.ts | 10 ++++++++++ src/commands/curated.ts | 21 +++++++++++++-------- src/commands/reads.ts | 5 ++--- src/context.ts | 6 ++++++ src/output.ts | 2 +- src/program.ts | 11 +++++++---- test/unit/activities.test.ts | 8 ++++++++ test/unit/curated.test.ts | 4 +++- 8 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/commands/args.ts b/src/commands/args.ts index 36e741d..720e30b 100644 --- a/src/commands/args.ts +++ b/src/commands/args.ts @@ -59,6 +59,16 @@ function resolveTimezone(timezone?: string | null): string { return Intl.DateTimeFormat().resolvedOptions().timeZone; } +/** Today's calendar date as YYYY-MM-DD in `timezone` (else this machine's). */ +export function todayIn(timezone?: string | null, now = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: resolveTimezone(timezone), + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} + /** How far `timezone`'s wall clock is ahead of UTC at `instant`, in ms. */ function offsetMs(instant: Date, timezone: string): number { const parts = new Intl.DateTimeFormat("en-US", { diff --git a/src/commands/curated.ts b/src/commands/curated.ts index 3f7b550..b3e8ecb 100644 --- a/src/commands/curated.ts +++ b/src/commands/curated.ts @@ -2,7 +2,7 @@ // vendored invoke switch reads for its command (see `wf commands`). import { Command, Option } from "commander"; import { UsageError } from "../errors"; -import { open, output } from "../context"; +import { appTimezone, open, output } from "../context"; import { omit } from "../output"; import { addWaitOptions, withWait, type WaitOptions } from "../wait"; import { @@ -14,6 +14,7 @@ import { monthArg, numberArg, today, + todayIn, } from "./args"; type AccountScope = @@ -156,17 +157,21 @@ export function registerCurated(program: Command): void { opts: { from?: string; to?: string; account?: string[]; full?: boolean }, cmd: Command, ) => { - const endDate = opts.to ?? today(); + if (opts.from && opts.to && opts.from > opts.to) { + throw new UsageError("--from must not be after --to."); + } + const client = open(cmd); + // The server reads RFC 3339 instants, and refuses a bare date once an + // account is tracked. As the web app: whole days in the app's timezone, + // and "today" there too. + const timezone = await appTimezone(client); + const endDate = opts.to ?? todayIn(timezone); // Without --from, report the month of --to; a past --to must not start after it. const startDate = opts.from ?? `${endDate.slice(0, 8)}01`; if (startDate > endDate) throw new UsageError("--from must not be after --to."); - const client = open(cmd); - // The server reads RFC 3339 instants, and refuses a bare date once an - // account is tracked. As the web app: whole days in the app's timezone. - const settings = await client.invoke<{ timezone?: string }>("get_settings"); const request = { - startDate: dayBoundary(startDate, "start", settings?.timezone), - endDate: dayBoundary(endDate, "end", settings?.timezone), + startDate: dayBoundary(startDate, "start", timezone), + endDate: dayBoundary(endDate, "end", timezone), ...(opts.account ? { accountIds: opts.account } : {}), }; const report = await client.invoke>("get_spending_report", { diff --git a/src/commands/reads.ts b/src/commands/reads.ts index 1fef0f7..fb2cd12 100644 --- a/src/commands/reads.ts +++ b/src/commands/reads.ts @@ -3,7 +3,7 @@ // id, local values, FX rate, ...). Holdings and health print a brief shape // unless asked (--full): agents pay for every byte. import type { Command } from "commander"; -import { open, output } from "../context"; +import { appTimezone, open, output } from "../context"; import { omit } from "../output"; type Money = { local: number; base: number } | null | undefined; @@ -149,8 +149,7 @@ export function registerReads(program: Command): void { } // As the MCP tool: check in the timezone configured in the app, not the // machine's. Without one, the vendored invoke sends the local timezone. - const settings = await client.invoke<{ timezone?: string }>("get_settings"); - const timezone = settings?.timezone?.trim(); + const timezone = await appTimezone(client); show( await client.invoke( "run_health_checks", diff --git a/src/context.ts b/src/context.ts index 659b908..6ca15e2 100644 --- a/src/context.ts +++ b/src/context.ts @@ -20,6 +20,12 @@ export function open(cmd: Command): Client { return Client.open({ url: globals(cmd).url }); } +/** The timezone set in the app, if any (get_settings). */ +export async function appTimezone(client: Client): Promise { + const settings = await client.invoke<{ timezone?: string }>("get_settings"); + return settings?.timezone?.trim() || undefined; +} + export function output(cmd: Command, value: unknown): void { const { table, fields } = globals(cmd); print(fields ? pick(value, fields) : value, { table }); diff --git a/src/output.ts b/src/output.ts index 113144d..ff7662c 100644 --- a/src/output.ts +++ b/src/output.ts @@ -72,7 +72,7 @@ function pickPaths(row: unknown, paths: string[][]): unknown { let source: unknown = row; for (const key of path) { source = - source !== null && typeof source === "object" + source !== null && typeof source === "object" && Object.hasOwn(source, key) ? (source as Record)[key] : undefined; } diff --git a/src/program.ts b/src/program.ts index f82f49f..3cf51fa 100644 --- a/src/program.ts +++ b/src/program.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { Argument, Command } from "commander"; +import { Argument, Command, InvalidArgumentError } from "commander"; import { Client } from "./client"; import { globals, open, output, VERSION } from "./context"; import { registerActivities } from "./commands/activities"; @@ -60,11 +60,14 @@ export function buildProgram(): Command { .option( "--fields ", "print only these comma-separated fields of each row (dotted paths, such as instrument.symbol)", - (list: string) => - list + (list: string) => { + const fields = list .split(",") .map((f) => f.trim()) - .filter(Boolean), + .filter(Boolean); + if (fields.length === 0) throw new InvalidArgumentError("Name at least one field."); + return fields; + }, ) // Must come before subcommands are added: they copy it at creation. // main() maps commander errors to exit code 2. diff --git a/test/unit/activities.test.ts b/test/unit/activities.test.ts index 6f8615b..e54361e 100644 --- a/test/unit/activities.test.ts +++ b/test/unit/activities.test.ts @@ -425,6 +425,14 @@ describe("reads and health", () => { ).toEqual([{ instrument: { symbol: "SAP" }, weight: 1 }]); }); + it("--fields refuses an empty list, and ignores inherited properties", async () => { + mockFetch(() => json([{ id: "h1" }])); + expect((await run("holdings", "--fields", ",")).code).toBe(2); + expect((await run("holdings", "--full", "--fields", "constructor.name,id")).json()).toEqual([ + { id: "h1" }, + ]); + }); + it("health reads the cached status", async () => { const seen = mockFetch(() => json({ overallSeverity: "INFO", issues: [] })); await run("health"); diff --git a/test/unit/curated.test.ts b/test/unit/curated.test.ts index 456448a..2dbd05d 100644 --- a/test/unit/curated.test.ts +++ b/test/unit/curated.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { targetFilter } from "../../src/commands/curated"; -import { dayBoundary, today } from "../../src/commands/args"; +import { dayBoundary, today, todayIn } from "../../src/commands/args"; import { json, mockFetch, run, useTempConfig, type Recorded } from "../helpers"; useTempConfig(); @@ -218,6 +218,8 @@ describe("wf spending report", () => { describe("dayBoundary", () => { it("handles UTC, offsets and a DST change", () => { expect(dayBoundary("2025-11-01", "start", "UTC")).toBe("2025-11-01T00:00:00.000Z"); + expect(todayIn("Asia/Tokyo", new Date("2026-10-31T16:00:00Z"))).toBe("2026-11-01"); + expect(todayIn("America/Los_Angeles", new Date("2026-11-01T03:00:00Z"))).toBe("2026-10-31"); expect(dayBoundary("2025-11-01", "start", "America/New_York")).toBe("2025-11-01T04:00:00.000Z"); // London moves to BST at 01:00 UTC on 2025-03-30; the day ends at 22:59:59.999 UTC. expect(dayBoundary("2025-03-30", "end", "Europe/London")).toBe("2025-03-30T22:59:59.999Z"); From 475a460a721ab0e05d9974c2f583ac20647de308 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:54:07 +0000 Subject: [PATCH 5/5] health: keep fixAction whole in the brief output execute_health_fix needs the action's payload; an {id, label} stub looked usable but would run the fix with nothing to act on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0178eJr8TT5NezDCgT1yLgZp --- README.md | 2 +- src/commands/reads.ts | 7 ++----- test/unit/activities.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4148b4d..c771d55 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ rule). Then commands exit with code 3 and `PROFILE_LOCKED`; run | `wf activity delete ... [--dry-run]` | Deletes up to 100 activities. One leg of a linked transfer deletes both. | | `wf activity void ... [--dry-run]` | Voids posted activities: kept, but out of holdings, cash and performance. | | `wf activity restore ... [--dry-run]` | Posts voided activities again. | -| `wf health [--refresh]` | Health Center status; `--refresh` runs the checks first. Each issue's `diagnostics`, routes, translation keys and fix payload are left out unless `--full`. | +| `wf health [--refresh]` | Health Center status; `--refresh` runs the checks first. Each issue's `diagnostics`, routes and translation keys are left out unless `--full`. | | `wf rebalance [--target id\| Drift against an allocation target (`--holdings`adds the holdings); with`--cash`, a rebalance plan. Category colours are left out unless `--full`. | | `wf spending report [--from d] [--to d] [--account id]...` | Spending report (default: this month so far). The per-day totals (`byDay`, `byDayByCategory`) are left out unless `--full`. | | `wf budget [--period YYYY-MM]` | Budget snapshot: totals, then each group and its categories with target, actual and remaining. Categories with no target and no spending are left out. `--full` returns the server's `{state, computed}`. | diff --git a/src/commands/reads.ts b/src/commands/reads.ts index fb2cd12..6de9d03 100644 --- a/src/commands/reads.ts +++ b/src/commands/reads.ts @@ -71,7 +71,6 @@ export function briefHolding(h: Holding): Record { interface Health { issues?: { diagnostics?: unknown; - fixAction?: { id: string; label: string; payload?: unknown }; navigateAction?: unknown; params?: unknown; affectedItems?: { id: string; name: string; symbol?: string; route?: string }[]; @@ -80,16 +79,14 @@ interface Health { // diagnostics repeats each issue's details and affected items, entity by entity. // The other fields left out are for the web app: routes, translation keys and -// params, cache keys, and the fix button's payload of asset ids, which -// affectedItems also lists. +// params, and cache keys. fixAction stays whole: execute_health_fix needs its payload. function slimHealth(health: Health): Health { return { ...health, issues: health.issues?.map((issue) => { - const { fixAction, affectedItems } = issue; + const { affectedItems } = issue; return { ...omit(issue, "diagnostics", "navigateAction", "params", "code", "dataHash", "timestamp"), - ...(fixAction && { fixAction: { id: fixAction.id, label: fixAction.label } }), ...(affectedItems && { affectedItems: affectedItems.map((item) => omit(item, "route")), }), diff --git a/test/unit/activities.test.ts b/test/unit/activities.test.ts index e54361e..1f74570 100644 --- a/test/unit/activities.test.ts +++ b/test/unit/activities.test.ts @@ -439,7 +439,7 @@ describe("reads and health", () => { expect(seen.map((r) => `${r.method} ${r.path}`)).toEqual(["GET /api/v1/health/status"]); }); - it("health drops diagnostics and the web app's routes and payloads unless --full", async () => { + it("health drops diagnostics and the web app's routes unless --full, and keeps fixAction whole", async () => { const issue = { id: "i1", title: "No market data for 1 holding", @@ -456,7 +456,7 @@ describe("reads and health", () => { { id: "i1", title: "No market data for 1 holding", - fixAction: { id: "sync_prices", label: "Sync Prices" }, + fixAction: { id: "sync_prices", label: "Sync Prices", payload: ["s1"] }, affectedItems: [{ id: "s1", name: "SAP", symbol: "SAP" }], }, ],