From cdd49bea91cfd866cf284b0009a45c4654cec516 Mon Sep 17 00:00:00 2001 From: Error404IsFound Date: Wed, 16 Sep 2026 22:16:39 -0500 Subject: [PATCH] Created the design for zoom pan --- .config/prisma.ts | 2 +- .../audit/zoom-pan-time-range-data-spec.md | 170 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 project-docs/iteration-1/audit/zoom-pan-time-range-data-spec.md diff --git a/.config/prisma.ts b/.config/prisma.ts index 4556ba3052..456284e0ec 100644 --- a/.config/prisma.ts +++ b/.config/prisma.ts @@ -11,7 +11,7 @@ export default defineConfig({ }, migrations: { path: join(__dirname, '..', 'prisma', 'migrations'), - seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}` + seed: 'node prisma/seed.mts' }, schema: join(__dirname, '..', 'prisma', 'schema.prisma') }); diff --git a/project-docs/iteration-1/audit/zoom-pan-time-range-data-spec.md b/project-docs/iteration-1/audit/zoom-pan-time-range-data-spec.md new file mode 100644 index 0000000000..79956013ff --- /dev/null +++ b/project-docs/iteration-1/audit/zoom-pan-time-range-data-spec.md @@ -0,0 +1,170 @@ +# Design Doc: Zoom / Pan / Selectable Time Range - Data Spec + +**Status:** Draft (Iteration 1) +**Author:** _fill in_ +**Scope:** Docs only, no code changes yet. This defines the data model and API contract that a future iteration will implement. + +## 1. Summary + +Right now the portfolio and dividend charts only support a fixed set of preset ranges (1D, WTD, MTD, YTD, 1Y, 5Y, Max). This doc proposes a data spec for letting users zoom into a chart, pan across it, and select an arbitrary custom date range, instead of being locked to the presets. + +The goal here is to nail down the shape of the data before anyone touches the chart component or the backend endpoints. Once this is agreed on, Iteration 2 can wire up the actual zoom/pan interactions in the chart component and the granularity-aware data fetching on the backend. + +## 2. Problem Statement + +The current range selector is a fixed list of buttons. That's fine for quick checks but it breaks down in a few ways: + +- A user who wants to look at "the three weeks around when I made that big purchase" has no way to get there directly. They have to pick the closest preset and squint. +- Every preset re-fetches the full historical series at whatever the default granularity is, even if the user only wants to look at a narrow window. That's wasted payload for something like a 5 year series when you only care about the last two weeks of it. +- There's no shared concept of "current viewport" that the frontend and backend agree on, so any zoom/pan work would have to invent this from scratch inside the chart component, coupled to Chart.js internals instead of being a clean data contract. + +This doc defines that shared contract: what a "time range selection" looks like as data, how it's requested from the API, how granularity is chosen, and how it degrades gracefully when data is missing. + +## 3. Current State (for context) + +Today, a range selection is really just a string enum used to compute a start date on the frontend: + +- `1d`, `wtd`, `mtd`, `ytd`, `1y`, `5y`, `max` +- The frontend maps the selected preset to a `dateRange` value and passes it to the performance/chart endpoint. +- The backend resolves that into an actual start date server side and returns the full series for that window at a single, fixed granularity (daily, as far as the chart is concerned). + +There is no concept today of: + +- A user-defined arbitrary start/end pair +- A zoom level or viewport that's distinct from the requested range +- Granularity as something that can vary based on how wide the window is + +## 4. Goals + +- Define a `TimeRangeSelection` shape that covers both presets and custom ranges, so existing preset behavior keeps working unchanged. +- Define how zoom and pan translate into that same shape, so the backend never needs to know whether a range came from a button, a drag-to-zoom gesture, or a manual date picker. +- Define a granularity resolution rule so charts don't request more data points than they can usefully render. +- Keep the contract backward compatible: any client still sending the old preset-only request shape should keep working. + +## 5. Non-Goals + +- Actual chart rendering/interaction implementation (drag-to-zoom, pinch gestures, mouse wheel behavior). That's Iteration 2. +- Changing how performance metrics (ROI, ROAI, etc.) are calculated. This is purely about which data window is fetched and shown. +- Real-time/streaming updates while zoomed in. + +## 6. Proposed Data Spec + +### 6.1 `TimeRangeSelection` + +This is the core object both frontend and backend agree on. + +```ts +type TimeRangeSelection = + | { + mode: 'preset'; + preset: 'today' | 'wtd' | 'mtd' | 'ytd' | '1y' | '5y' | 'max'; + } + | { mode: 'custom'; startDate: string; endDate: string }; // ISO 8601 date, e.g. "2026-03-01" +``` + +Notes: + +- `mode: 'preset'` preserves exactly the current behavior. Nothing about existing preset requests changes. +- `mode: 'custom'` is new. `startDate` and `endDate` are inclusive, in the user's account currency's calendar (not UTC-shifted), matching how the rest of Ghostfolio already treats activity dates. +- Zoom and pan both resolve to a `custom` selection under the hood. Zooming in narrows `startDate`/`endDate`; panning shifts both by the same delta while keeping the window width constant. This means the chart component never needs a separate "zoom state" concept sent to the server, it's just a narrower or shifted custom range. + +### 6.2 Viewport vs. Selection + +Two related but distinct ideas: + +- **Selection**: the `TimeRangeSelection` that was actually requested from the API and used to fetch data. +- **Viewport**: the currently visible window inside the chart, which may be a subset of the fetched data if the frontend is doing client-side zoom without a re-fetch (e.g. zooming in a bit within data that's already loaded). + +The spec only concerns itself with **Selection**. Viewport is a frontend-only concept and never needs to be sent to or stored by the backend. This keeps the backend contract simple: it always just answers "give me the series for this range," regardless of why the range changed. + +### 6.3 Granularity Resolution + +Rather than sending an explicit granularity, the backend derives it from the width of the requested range, so the client doesn't need to duplicate that logic: + +| Range width | Granularity | +| ----------- | --------------------- | +| <= 7 days | daily | +| <= 2 years | daily | +| > 2 years | weekly (down-sampled) | + +This mirrors what most of the existing presets already imply. The only change is that a `custom` range spanning multiple years falls into the same down-sampling behavior as `5y` or `max` do today, instead of being undefined. + +Granularity is returned alongside the series data so the chart component knows how to space and label the x-axis, rather than assuming daily points: + +```ts +interface ChartSeriesResponse { + granularity: 'daily' | 'weekly'; + startDate: string; + endDate: string; + points: Array<{ date: string; value: number }>; +} +``` + +## 7. API Impact + +### 7.1 Request shape + +The existing performance/chart endpoints currently accept something like: + +``` +GET /api/v1/portfolio/performance?range=5y +``` + +Proposed addition, kept backward compatible by making the new params optional and mutually exclusive with `range`: + +``` +GET /api/v1/portfolio/performance?range=5y +GET /api/v1/portfolio/performance?startDate=2026-03-01&endDate=2026-03-21 +``` + +Validation rules: + +- If both `range` and `startDate`/`endDate` are present, the request is rejected with a 400. A selection is one or the other, never both, to avoid ambiguity about which one wins. +- If only one of `startDate`/`endDate` is present, reject with a 400. Custom ranges must be fully bounded. +- `endDate` must not be before `startDate`. Reject with a 400 if so. +- `endDate` must not be in the future beyond "today" in the account's timezone. Clamp to today rather than reject, since a chart zoomed slightly past "now" is a common and harmless UI state. +- If `startDate` is before the account's earliest activity, clamp it to the earliest activity date rather than rejecting. This avoids the client needing to know that date in advance just to construct a valid request. + +### 7.2 Response shape + +No breaking change to the existing response body. The only addition is the `granularity` field described in 6.3, which old clients can simply ignore. + +## 8. Frontend State Model + +The chart component holds a single `TimeRangeSelection` as its source of truth (matching 6.1). Proposed state transitions: + +- Clicking a preset button sets `{ mode: 'preset', preset }`. +- Drag-to-zoom on the chart computes a `{ mode: 'custom', startDate, endDate }` from the pixel range dragged, and triggers a re-fetch. +- Pan (click-drag on an already-zoomed chart, or arrow controls) shifts `startDate`/`endDate` by the same delta and re-fetches. +- A "reset zoom" action returns to whatever preset was last active before zooming, defaulting to `1y` if the chart was opened directly into a custom range (e.g. via a shared link). + +This keeps a single, serializable piece of state that could later be reflected in the URL (e.g. for shareable links to a specific zoomed view), without needing to invent a second state shape for that. + +## 9. Edge Cases + +- **Empty range**: `startDate` and `endDate` resolve to a window with no activity yet (e.g. before the account existed). Return an empty `points` array rather than an error, so the chart can show an empty state instead of failing. +- **Single-day range**: `startDate === endDate`. Valid, resolves to daily granularity with at most one point. +- **Range wider than available history**: clamp `startDate` as described in 7.1, and note in the response that the request was clamped is not required. The response's own `startDate`/`endDate` already tell the client what was actually served, so the client can compare against what it requested and show a subtle "showing full available history" note if it wants to. +- **Rapid zoom/pan while a previous request is in flight**: out of scope for the data spec itself, but worth flagging for Iteration 2 that the frontend should cancel/ignore stale in-flight requests when the selection changes again quickly. + +## 10. Backward Compatibility + +- Any existing client sending only `range=...` continues to work exactly as before. Nothing about the preset path changes. +- The new `granularity` field is additive. Clients that don't read it keep working. +- No database schema changes. This is purely a request/response contract change plus a frontend state shape, since ranges are computed on the fly rather than stored. + +## 11. Open Questions + +- Should custom ranges be shareable via URL query params so a link can reopen a chart at a specific zoom? Leaning yes, but deferring the URL structure to Iteration 2 once the interaction design is settled. +- Do we want a minimum zoom window (e.g. don't allow zooming below 1 day) to avoid a degenerate chart with a single point? Proposing yes, minimum of 1 day, but open to feedback. +- Should weekly down-sampling for wide ranges average, or sample the last point of each week? Existing `5y`/`max` presets presumably already answer this somewhere in the current performance calculation code, so Iteration 2 should just reuse whatever that does rather than introduce a second convention. + +## 12. Summary of Proposed Changes + +- Add `TimeRangeSelection` as a shared type (`preset` | `custom`). +- Add optional `startDate`/`endDate` query params to chart/performance endpoints, mutually exclusive with `range`. +- Add validation and clamping rules per section 7.1. +- Add `granularity` to the chart series response. +- No schema changes, no changes to performance calculation logic itself. + +npx prettier --write project-docs/iteration-1/audit/zoom-pan-time-range-data-spec.md