Skip to content

[High] /analysis ships the full unbounded snapshot history (incl. per-account breakdown) to the client #644

Description

@mike840609

Summary

/analysis serialises a user's entire snapshot history — including the per-account breakdown of every snapshot ever taken — into the RSC payload and hands it to a client component, which then recomputes every chart in the browser on each range switch. Nothing is windowed by the selected range, so both the payload and the client-side work grow without bound as the account ages.

Evidence

src/lib/services/analysis-payload-service.ts:25-32 bundles four unbounded reads:

const [snapshots, cashFlowData, rawHistory, accountCashFlow, investmentCostBasis] =
  await Promise.all([
    getFullNormalizedHistory(userId, baseCurrency),   // every snapshot, no date bound
    getMonthlyCashFlow(userId, baseCurrency),
    getRawHistoryWithBreakdown(userId, baseCurrency), // every snapshot + breakdown JSON
    getAccountMonthlyCashFlow(userId, baseCurrency),  // every (account, month) pair
    getInvestmentCostBasisSummary(userId, baseCurrency),
  ]);

src/lib/services/history-service.ts:299-353getRawHistoryWithBreakdown selects the heavy breakdown column for all of the user's snapshots and expands it into one accountValues object per day:

prisma.netWorthSnapshot.findMany({
  where: { userId },                                   // no date filter
  select: { date: true, breakdown: true, baseCurrency: true, createdAt: true },
  orderBy: { date: "asc" },
}),

src/app/(main)/analysis/page.tsx:40-49 passes all of it straight into a client component, and src/components/analysis/analysis-view.tsx:1 is "use client":

<AnalysisView
  snapshots={snapshots}
  cashFlowData={cashFlowData}
  rawHistory={rawHistory}
  accountCashFlow={accountCashFlow}
  ...
/>

So everything crosses the server/client boundary. analysis-view.tsx:22-31 then imports ten pure aggregation functions from analysis-service.ts (aggregateMonthlyChange, computeKpis, fillMonthRange, buildCashFlowBuckets, buildCumulativeGrowth, aggregateCategoryHistory, computePerformanceAttribution, computeInvestmentReturn, computeInvestmentReturnSeries, computeDrawdownSeries) and runs them client-side — which also pulls that module into the client bundle.

The contrast with the dashboard is instructive: dashboard-content.tsx:312-320 explicitly notes that getFullNormalizedHistory "never selects the heavy breakdown column". The analysis path does.

Failure scenario

A three-year-old account with 12 accounts:

  • ~1 100 daily snapshots.
  • rawHistory.snapshots = 1 100 entries × 12 accountId → number pairs. With cuid keys (~25 chars) that is on the order of 400–500 KB of JSON before compression, for a view whose default range shows 12 months.
  • snapshots (NormalizedSnapshot[]) adds another 1 100 objects carrying id, date, createdAt, three numbers, baseCurrency, label, note.
  • accountCashFlow adds one entry per (account, month) with any activity — up to 12 × 36.

On a phone that is a multi-hundred-KB RSC payload to parse plus ten aggregation passes over ~1 100 records, repeated on every range toggle (YTD / 6M / 1Y / All) because the memo inputs are the full arrays. getCachedAnalysisPayload (revalidate: 300, tags net-worth / snapshots / exchange-rates / prices / history:${userId} / accounts:${userId}) means a cache miss also re-runs the full breakdown scan server-side — and those tags are invalidated by ordinary writes.

Suggested fix

The range is already a first-class concept in the UI (analysis-view.tsx ranges), so push it down instead of shipping everything:

  1. Bound the query. Give getRawHistoryWithBreakdown and getFullNormalizedHistory a from date derived from the selected range (plus one prior month for the baseline that aggregateMonthlyChange needs), and make the range part of the getCachedAnalysisPayload cache key. getFullNormalizedHistory already supports options.from/togetRawHistoryWithBreakdown does not.
  2. Aggregate on the server. The ten functions in analysis-service.ts are pure and already take exactly the arrays they need; call them in the server component and send the derived series (a few dozen points each) rather than the raw history. That drops both the payload and the client bundle.
  3. If "All" must stay unbounded, at minimum drop id / createdAt / label / note from the client-bound NormalizedSnapshot (only date and the three numbers are charted) and pre-collapse rawHistory to month-end snapshots — aggregateCategoryHistory and computeInvestmentReturnSeries only ever use month boundaries anyway.

Verification method

Static reading of analysis-payload-service.ts, history-service.ts, analysis/page.tsx and analysis-view.tsx. Payload sizes above are estimated from the data shapes, not measured against a real account — worth confirming with a production-sized history before sizing the fix.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions