fix: Charts 15m/1m/5m stop in the past — GET /candles returns oldest N; selected interval chip is low-contrast #705

Closed
opened 2026-08-29 14:16:55 +00:00 by PlasticDigits · 6 comments
PlasticDigits commented 2026-08-29 14:16:55 +00:00 (Migrated from gitlab.com)

Summary

On /charts and /trade, switching the Price (USD) chart from 1h to 15m (and likewise 5m / 1m) plots candles that stop days in the past, with empty time-scale space to the right of the last bar. A retail report on 2026-08-29 showed the last 15m bar at 21 Aug 2026 11:15 while 1h still reached “now.” The volume pane read 0 on that last bar. This is not browser-specific (reproduced on desktop Firefox, Brave, and private windows, with and without VPN).

Same control: the selected interval chip (15m when pressed) is low-contrast on dark chrome — a 14% blue wash that does not read as selected.

This is one product bug with two coupled causes plus a related chip contrast fix. Bundle them: the empty-right screenshot is the oldest-N payload sitting inside a 1h time-scale that was not refit on interval switch.

Related: #568 (idle mark-to-market densifies 15m/1m bars), #148 (interval switch must not unmount the canvas), #336 (fitContent forbidden on live 30s refetch), #226 (stale getCandles races).

Environment (production, 2026-08-29)

  • Network: columbus-5 (https://dex.cl8y.com)
  • Surface: Price (USD) chart interval group on /charts and /trade
  • Repro: load a liquid pair on 1h → click 15m
  • Observed: last 15m candle 21 Aug '26 11:15; empty plot to the right; volume 0; selected 15m chip hard to see
  • Not a client cache: same after private browsing / VPN toggle

Current codebase

GET /candles returns the oldest limit rows in the window

indexer/src/api/pairs.rs get_pair_candles:

SELECT * FROM candles
 WHERE pair_id = $1 AND interval = $2 AND open_time >= $3 AND open_time <= $4
 ORDER BY open_time ASC
 LIMIT $5

Oldest-first + LIMIT is the opposite of a retail chart. After #568, idle marks write the current bucket for every interval, so 15m / 5m / 1m series are dense (trade_count = 0, zero volume on mark-only bars — matches the report’s Volume 0).

Coverage of default limit=200:

Interval 200 bars span What the user sees
1m ~3.3 hours from the start of history Stops almost immediately
5m ~16.7 hours from start Stops ~1 day in
15m 50 hours from start Stops ~2 days after the first 15m bar (report: 21 Aug 11:15 if marks/swaps began ~19 Aug)
1h ~8.3 days from start Looks “current” on a pair that is ~1–2 weeks old
4h / 1d / 1w Weeks–months Fine

90 days of dense 15m is 8,640 bars — far above the 200 default and the 1000 cap. Raising the client limit alone cannot cover 90d of 1m/15m. The default read must be newest N, chronological on the wire.

The dApp never passes from / to / a higher limit:

// frontend-dapp/src/services/indexer/client.ts
export async function getCandles(..., limit = 200)

PriceChart.tsx queryFn: () => getCandles(pairAddress, interval) and tradePairPrefetch.ts use that default.

Client mapping sortedValidUsdCandles sorts ASC after fetch — it cannot invent bars the API omitted.

Interval switch keeps the 1h time-scale (#336)

PriceChartLightweightCanvas.tsx: createChart runs once per pair mount (#148). Interval changes call syncCandleSeriesData (setData when first bar time changes). timeScale().fitContent() runs only on initial mount and indicator toggle — not on candle setData (#336 / docs/frontend.md § Time-scale zoom).

So: 1h viewport extends to “now”; 15m payload ends 21 Aug; empty black to the right. Even after newest-N, a leftover 1h range would leave empty left if 15m only covers ~50h. Interval switch must refit (or reset visible range) without breaking live-refetch zoom.

Selected interval chip is 14% blue

PriceChart.tsx interval buttons use tab-glass + tab-glass-active / tab-glass-inactive. Active fill in index.css:

.tab-glass-active {
  background: linear-gradient(180deg, rgba(68, 138, 255, 0.14), rgba(255, 255, 255, 0.02));
  ...
}

rgba(68, 138, 255, 0.14) is #448aff at 14%. On --bg-0 #0d111c that is a faint oval — aria-pressed is correct (#214) but sighted users cannot tell which interval is on. Same primitive is used for slippage chips, overlay INDICATORS, Pool Manage actions — any stronger active state must stay blue (#488), not gold fill, and must keep :focus-visible with --focus-ring.

What is already correct (do not regress)

  • Interval allowlist 1m|5m|15m|1h|4h|1d|1w → else 400 (VALID_INTERVALS, security.rs).
  • limit clamp 1–1000 (#431 / api_limit_lower_bound.rs); negative/zero → 1, not 500.
  • 90-day default window (short windows emptied LocalTerra/QA charts).
  • Idle marks C568 (current bucket only; no historical rewrite; mark bars trade_count = 0).
  • Canvas stays mounted on interval switch; pair switch remounts via key={pairAddress} (#148 / #180).
  • Live refetch must not fitContent (#336).
  • Candle trust boundary drops NaN/non-positive USD (#226).
  • Response array is chronological for lightweight-charts (time must be non-decreasing).

Why the new implementation is needed

  1. Retail charts mean “now.” Users switch to 15m to see recent structure. Oldest-200 after idle-mark densification shows the first two days of 15m history and hides the last week. 1h “works” only because 200 hours ≈ 8 days.
  2. #568 made fine intervals dense. Mark-to-market is correct; the GET window was never updated for dense 1m/5m/15m.
  3. Empty-right is a second bug on the same click. Newest-N without interval fitContent still leaves a misleading viewport.
  4. Selected interval must be visible. Low-contrast active chips cause mis-clicks and “is it 15m?” doubt — same toolbar as the cutoff.

Constraints / guardrails

  1. Newest N, chronological on the wire. ORDER BY open_time DESC LIMIT n then re-sort ASC (subquery or application). Do not return DESC to the dApp — lightweight-charts requires non-decreasing time.
  2. Keep limit clamp 1–1000. Do not raise the HTTP max to “cover 90d of 1m.” Do not uncap. Optional later: pan-left pagination with to/from — not required for this ticket if newest-200 (or newest-1000) always includes now.
  3. Keep 90-day from/to default. Do not shrink lookback to “fix” oldest-N (that re-breaks LocalTerra/QA empty charts).
  4. Do not disable idle marks to sparsify 15m. Marks stay C568.
  5. Do not fitContent on 30s refetch (#336). Refit only on interval switch (and existing mount / indicator toggle). User zoom on a given interval must survive live updates.
  6. Do not remount the canvas on interval change (#148). Pair change still remounts.
  7. Interval allowlist + parameterized SQL stay. No format! of user interval into SQL beyond the existing match arm in rebuild. Unknown / javascript: / ../ → 400.
  8. Explicit from/to still honor the window. Newest-N applies inside that window (integrators paging history must still work).
  9. Active chip stays blue (--blue / #448aff family). No gold fill, no Buy/Sell green/red, no btn-primary on interval chips. Light theme must still contrast. :focus-visible ring unchanged. Do not weaken inactive chips into invisibility.
  10. Global .tab-glass-active vs chart-only class: if the stronger fill is global, snapshot slippage / Pool Manage / Indicators in dark and light. Prefer a chart-interval modifier if a global bump fights other chips.
  11. No client-side oracle stitch. Do not invent candles the indexer omitted.
  12. Integrators: document that default GET is latest limit bars, oldest→newest in the JSON array. CG/CMC paths unchanged.

Relevant files

File Role
indexer/src/db/queries/candles.rs get_candles ORDER BY … ASC LIMIT (change here)
indexer/src/api/pairs.rs get_pair_candles, DEFAULT_CANDLE_LOOKBACK_DAYS, CandleQuery.limit
indexer/tests/api_pairs.rs Interval 400 / default interval
indexer/tests/security.rs limit=99999 cap; interval injection
indexer/tests/api_limit_lower_bound.rs Negative/zero limit
indexer/tests/candle_usd_mark.rs Mark bars on GET; body[0] order assumptions
docs/indexer-invariants.md Candle window + numeric caps — add newest-N
frontend-dapp/src/services/indexer/client.ts getCandles default limit = 200
frontend-dapp/src/components/charts/PriceChart.tsx Interval chips; getCandles(pair, interval)
frontend-dapp/src/utils/tradePairPrefetch.ts Prefetch same default
frontend-dapp/src/components/charts/PriceChartLightweightCanvas.tsx fitContent policy
frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts Interval setData vs live update
frontend-dapp/src/components/charts/__tests__/PriceChart.test.tsx 1h → 15m aria-pressed / aria-live (#214)
frontend-dapp/src/components/charts/__tests__/PriceChartLightweightCanvas.test.tsx No fitContent on background refresh
frontend-dapp/src/index.css .tab-glass-active
frontend-dapp/e2e/price-chart-smoke.spec.ts Canvas smoke (#228)
docs/frontend.md § Trade page — price chart invariants Timeframe + zoom
skills/AGENTS_FRONTEND_PRICE_CHART.md Playbook
skills/AGENTS_FRONTEND_DESIGN_SYSTEM.md tab-glass* / blue active
skills/AGENTS_INDEXER_CANDLE_USD_MARK.md Do not weaken C568
  1. Indexer get_candles: wrap as newest-N then ASC, e.g.

    SELECT * FROM (
      SELECT * FROM candles
       WHERE pair_id = $1 AND interval = $2 AND open_time >= $3 AND open_time <= $4
       ORDER BY open_time DESC
       LIMIT $5
    ) t
    ORDER BY open_time ASC
    

    Integration test: seed > limit 15m bars spanning several days; default GET last open_time is the newest seeded bar, not the oldest; array is still strictly increasing open_time; limit=200 still returns ≤ 200.

  2. Optional dApp limit: keep 200 or use min(1000, …) for 1m/5m/15m only. Newest-200 of 15m ≈ 50h of recent data — enough for the cutoff report. Do not require 1000 if indexer newest-N is correct.

  3. Interval switch refit: when interval changes (detect via first-bar time / query key), call fitContent() once after setData. Regression: existing test background data refresh does not call fitContent stays green; new test: interval setData does call fitContent.

  4. Selected chip: raise active blue (e.g. --accent-surface / higher #448aff mix + stronger border) on interval buttons until the pressed chip is obvious on dark and light. WCAG-ish contrast vs unselected. Keep aria-pressed. Snapshot or computed-style test so 14% wash cannot return.

  5. Docs: one row in docs/indexer-invariants.md (default GET = latest limit bars, ASC JSON). Note in docs/frontend.md that interval switch refits; live refetch does not.

Acceptance criteria

  • C1. Default GET /api/v1/pairs/{addr}/candles?interval=15m (no from/to) last element open_time is the newest stored 15m bar in the 90d window (same for 1m / 5m).
  • C2. Response is chronological (open_time non-decreasing). Empty pair → [].
  • C3. limit still clamp 1–1000; limit=99999 → ≤ 1000; limit=-1/0 → 200 OK, ≤ 1 row.
  • C4. Invalid interval still 400; pair missing 404.
  • C5. Explicit from/to still filter; newest-N is within that range (oldest-N must not return).
  • C6. /charts and /trade: 1h → 15m (and 5m, 1m) last candle is current (within one interval of indexer now), not days stale. Empty-right gap from a leftover 1h range is gone (plot fits the new series).
  • C7. 1h / 4h / 1d / 1w still show recent data (no regression to empty/oldest-only).
  • C8. 30s candle refetch does not reset user zoom (#336).
  • C9. Interval switch does not unmount the canvas (#148); pair switch still remounts.
  • C10. Idle mark bars still appear (trade_count = 0, #568).
  • C11. Pressed interval chip is clearly stronger blue on dark and light; aria-pressed="true" only on the selected interval; keyboard :focus-visible ring remains.
  • C12. Slippage / Pool Manage / Indicators tab-glass-active still acceptable if the fill is shared; no gold wash, no *-neo.

Test plan (all paths)

Indexer

  1. Seed 400+ 15m candles over ≥ 4 days; GET .../candles?interval=15m (default limit 200): len==200, first open_time > oldest seed, last open_time == newest seed, times sorted ASC.
  2. Same for 1m, 5m, 1h (1h with 250 hourly bars: last bar is newest).
  3. limit=10: newest 10, ASC.
  4. limit=1000 and limit=99999: ≤ 1000, newest-capped.
  5. from/to covering only an old slice: returns newest-N inside that slice (not leaking bars after to).
  6. from/to inverted or unparseable: existing behavior (ignore / default window) — do not 500.
  7. Invalid interval 3h, javascript:alert(1), 1m;drop: 400.
  8. Unknown pair: 404.
  9. Negative/zero limit: existing lower-bound test still passes.
  10. Mark-only pair (#568): GET includes current mark bar as last (or within the newest-N set), trade_count=0.
  11. interval omitted: default 1h, still newest-N.

Frontend unit / charts Vitest

  1. getCandles call includes interval; do not send DESC-ordered data into the canvas without client sort (mapper already sorts — keep).
  2. PriceChart.test.tsx: 1h → 15m still updates aria-live + aria-pressed (#214); mock 15m payload newer than 1h first bar.
  3. Canvas: interval setData calls fitContent; background refetch (same first time, appended/last-bar update) does not.
  4. Pair switch remount: createChart count increases; interval spam does not.
  5. Active chip: tab-glass-active (or new modifier) present on selected interval; contrast/class regression if 14% gradient is the old selector.
  6. Light theme: pressed chip still distinguishable (data-theme='light').
  7. Invert #543 / #524: newest-N USD/human rows still invert per bar; no 1/x on USD.

E2E / QA (columbus-5 or LocalTerra with dense 15m)

  1. /charts: 1h → 15m → 5m → 1m → 1h. Each time, last candle time is within one interval of “now” (or last indexed swap/mark). No multi-day empty-right.
  2. /trade same toolbar.
  3. Firefox and Chromium (report was Firefox; root cause is API).
  4. Zoom on 15m, wait > 30s: zoom preserved; new last bar may update.
  5. Indicators toggle still refits (existing).
  6. Fullscreen aria (#228) unchanged.
  7. Visual: selected interval obvious at a glance (dark + light).

Test plan — attack, hack, and abuse

  1. SQL injection / interval: interval=1h%20OR%201=1, quotes, ;, ../, javascript: → 400, no 500, no extra rows (security.rs pattern).
  2. Limit DoS: limit=999999999, limit=-1, overflow strings → clamp, never unbounded LIMIT, never 500 from negative LIMIT (#431).
  3. Window DoS: from/to spanning years with limit=1000 → at most 1000 rows (newest inside window). No full-table scan without LIMIT.
  4. DESC-without-reverse: if a regress returns newest-first JSON, lightweight-charts may throw or plot backwards — unit test rejects decreasing open_time on the mapped series (or indexer contract test on ASC).
  5. Cross-pair leakage: addr of pair A must not return pair B candles after the query rewrite (JOIN/WHERE still pair_id).
  6. Cache / CDN: if any cache key omitted interval or limit, 1h could be served as 15m — query string must remain part of the cache key; dApp React Query key already ['candles', pairAddress, interval].
  7. Stale race (#226): slow 1h response must not overwrite fast 15m (existing test).
  8. Integer overflow on open_time: malformed RFC3339 already dropped client-side; API parse failure must not 500.
  9. AuthZ: candles stay public read; do not add cookies/tokens to GET; no user-specific filtering that could be confused with pair addr.
  10. XSS: open/close strings stay numeric; interval never interpolated into HTML. Chip labels are fixed INTERVALS const, not API text.
  11. Clickjack / UI redress: stronger chip must not look like btn-primary Confirm/Swap. Interval is tab-glass, not a money CTA.
  12. Theme spoof: do not introduce inline style from query params for chip color.

Verification criteria

  • make verify-issue-NNN (new script) runs: indexer candle newest-N test + PriceChart interval tests + canvas fitContent split + make verify-issue-568 (marks still on GET) + frontend lint for *-neo.
  • Manual columbus-5: liquid UST1 pair, 1h then 15m — last bar is today, selected 15m is obvious blue, Volume may still be 0 on a mark-only last bar (that is C568, not a fail).
  • No production screenshot with last 15m bar many days behind wall clock after deploy.

Mode

  • v2 (pool-only swap)
  • limit order
  • hybrid
  • charts / indexer candles (display)

Severity

  • blocks production
  • major UX / incorrect accounting display (chart history truncated to oldest window)
  • minor (chip contrast alone would be minor; bundled because it is the same toolbar)
## Summary On `/charts` and `/trade`, switching the Price (USD) chart from **1h** to **15m** (and likewise **5m** / **1m**) plots candles that **stop days in the past**, with empty time-scale space to the right of the last bar. A retail report on **2026-08-29** showed the last **15m** bar at **21 Aug 2026 11:15** while **1h** still reached “now.” The volume pane read **0** on that last bar. This is **not** browser-specific (reproduced on desktop Firefox, Brave, and private windows, with and without VPN). Same control: the **selected interval chip** (`15m` when pressed) is **low-contrast** on dark chrome — a 14% blue wash that does not read as selected. This is one product bug with two coupled causes plus a related chip contrast fix. Bundle them: the empty-right screenshot is the oldest-N payload sitting inside a **1h** time-scale that was **not** refit on interval switch. Related: [#568](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/568) (idle mark-to-market densifies 15m/1m bars), [#148](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/148) (interval switch must not unmount the canvas), [#336](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/336) (`fitContent` forbidden on live 30s refetch), [#226](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/226) (stale `getCandles` races). ## Environment (production, 2026-08-29) - Network: columbus-5 (`https://dex.cl8y.com`) - Surface: Price (USD) chart interval group on `/charts` and `/trade` - Repro: load a liquid pair on **1h** → click **15m** - Observed: last 15m candle **21 Aug '26 11:15**; empty plot to the right; volume **0**; selected **15m** chip hard to see - Not a client cache: same after private browsing / VPN toggle ## Current codebase ### GET `/candles` returns the **oldest** `limit` rows in the window [`indexer/src/api/pairs.rs`](indexer/src/api/pairs.rs) `get_pair_candles`: - Default `from` = now − **`DEFAULT_CANDLE_LOOKBACK_DAYS` (90)**, `to` = now ([`docs/indexer-invariants.md`](docs/indexer-invariants.md) row “Candle default time window”). - `limit` defaults to **200**, clamped **1–1000**. - Query is passed to [`candles::get_candles`](indexer/src/db/queries/candles.rs): ```sql SELECT * FROM candles WHERE pair_id = $1 AND interval = $2 AND open_time >= $3 AND open_time <= $4 ORDER BY open_time ASC LIMIT $5 ``` **Oldest-first + LIMIT** is the opposite of a retail chart. After [#568](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/568), idle marks write the **current** bucket for every interval, so **15m / 5m / 1m** series are dense (`trade_count = 0`, zero volume on mark-only bars — matches the report’s Volume **0**). Coverage of default `limit=200`: | Interval | 200 bars span | What the user sees | |----------|---------------|-------------------| | 1m | ~3.3 hours from the **start** of history | Stops almost immediately | | 5m | ~16.7 hours from start | Stops ~1 day in | | 15m | **50 hours** from start | Stops ~2 days after the first 15m bar (report: **21 Aug 11:15** if marks/swaps began ~19 Aug) | | 1h | ~8.3 days from start | Looks “current” on a pair that is ~1–2 weeks old | | 4h / 1d / 1w | Weeks–months | Fine | 90 days of dense 15m is **8,640** bars — far above the 200 default and the 1000 cap. Raising the client limit alone cannot cover 90d of 1m/15m. The default read must be **newest N**, chronological on the wire. The dApp never passes `from` / `to` / a higher `limit`: ```ts // frontend-dapp/src/services/indexer/client.ts export async function getCandles(..., limit = 200) ``` [`PriceChart.tsx`](frontend-dapp/src/components/charts/PriceChart.tsx) `queryFn: () => getCandles(pairAddress, interval)` and [`tradePairPrefetch.ts`](frontend-dapp/src/utils/tradePairPrefetch.ts) use that default. Client mapping [`sortedValidUsdCandles`](frontend-dapp/src/components/charts/priceChartCandles.ts) sorts ASC after fetch — it cannot invent bars the API omitted. ### Interval switch keeps the **1h** time-scale (#336) [`PriceChartLightweightCanvas.tsx`](frontend-dapp/src/components/charts/PriceChartLightweightCanvas.tsx): `createChart` runs **once** per pair mount ([#148](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/148)). Interval changes call [`syncCandleSeriesData`](frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts) (`setData` when first bar time changes). **`timeScale().fitContent()`** runs only on **initial mount** and **indicator toggle** — **not** on candle `setData` ([#336](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/336) / [`docs/frontend.md`](docs/frontend.md) § Time-scale zoom). So: 1h viewport extends to “now”; 15m payload ends 21 Aug; empty black to the **right**. Even after newest-N, a leftover 1h range would leave empty **left** if 15m only covers ~50h. Interval switch must **refit** (or reset visible range) without breaking live-refetch zoom. ### Selected interval chip is 14% blue [`PriceChart.tsx`](frontend-dapp/src/components/charts/PriceChart.tsx) interval buttons use `tab-glass` + `tab-glass-active` / `tab-glass-inactive`. Active fill in [`index.css`](frontend-dapp/src/index.css): ```css .tab-glass-active { background: linear-gradient(180deg, rgba(68, 138, 255, 0.14), rgba(255, 255, 255, 0.02)); ... } ``` `rgba(68, 138, 255, 0.14)` is `#448aff` at **14%**. On `--bg-0` `#0d111c` that is a faint oval — `aria-pressed` is correct ([#214](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/214)) but sighted users cannot tell which interval is on. Same primitive is used for slippage chips, overlay **INDICATORS**, Pool Manage actions — any stronger active state must stay **blue** (#488), not gold fill, and must keep `:focus-visible` with `--focus-ring`. ### What is already correct (do not regress) - Interval allowlist `1m|5m|15m|1h|4h|1d|1w` → else **400** (`VALID_INTERVALS`, `security.rs`). - `limit` clamp **1–1000** ([#431](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/431) / `api_limit_lower_bound.rs`); negative/zero → 1, not 500. - 90-day default window (short windows emptied LocalTerra/QA charts). - Idle marks **C568** (current bucket only; no historical rewrite; mark bars `trade_count = 0`). - Canvas stays mounted on interval switch; pair switch remounts via `key={pairAddress}` ([#148](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/148) / [#180](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/180)). - Live refetch must **not** `fitContent` ([#336](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/336)). - Candle trust boundary drops NaN/non-positive USD ([#226](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/226)). - Response array is chronological for lightweight-charts (`time` must be non-decreasing). ## Why the new implementation is needed 1. **Retail charts mean “now.”** Users switch to 15m to see recent structure. Oldest-200 after idle-mark densification shows the **first two days** of 15m history and hides the last week. 1h “works” only because 200 hours ≈ 8 days. 2. **#568 made fine intervals dense.** Mark-to-market is correct; the GET window was never updated for dense 1m/5m/15m. 3. **Empty-right is a second bug on the same click.** Newest-N without interval `fitContent` still leaves a misleading viewport. 4. **Selected interval must be visible.** Low-contrast active chips cause mis-clicks and “is it 15m?” doubt — same toolbar as the cutoff. ## Constraints / guardrails 1. **Newest N, chronological on the wire.** `ORDER BY open_time DESC LIMIT n` then **re-sort ASC** (subquery or application). Do **not** return DESC to the dApp — lightweight-charts requires non-decreasing `time`. 2. **Keep `limit` clamp 1–1000.** Do not raise the HTTP max to “cover 90d of 1m.” Do not uncap. Optional later: pan-left pagination with `to`/`from` — not required for this ticket if newest-200 (or newest-1000) always includes **now**. 3. **Keep 90-day `from`/`to` default.** Do not shrink lookback to “fix” oldest-N (that re-breaks LocalTerra/QA empty charts). 4. **Do not disable idle marks** to sparsify 15m. Marks stay **C568**. 5. **Do not `fitContent` on 30s refetch** ([#336](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/336)). Refit **only** on interval switch (and existing mount / indicator toggle). User zoom on a given interval must survive live updates. 6. **Do not remount the canvas** on interval change ([#148](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/148)). Pair change still remounts. 7. **Interval allowlist + parameterized SQL stay.** No `format!` of user `interval` into SQL beyond the existing match arm in rebuild. Unknown / `javascript:` / `../` → **400**. 8. **Explicit `from`/`to` still honor the window.** Newest-N applies **inside** that window (integrators paging history must still work). 9. **Active chip stays blue** (`--blue` / `#448aff` family). No gold fill, no Buy/Sell green/red, no `btn-primary` on interval chips. Light theme must still contrast. `:focus-visible` ring unchanged. Do not weaken inactive chips into invisibility. 10. **Global `.tab-glass-active` vs chart-only class:** if the stronger fill is global, snapshot slippage / Pool Manage / Indicators in dark **and** light. Prefer a **chart-interval** modifier if a global bump fights other chips. 11. **No client-side oracle stitch.** Do not invent candles the indexer omitted. 12. **Integrators:** document that default GET is **latest** `limit` bars, oldest→newest in the JSON array. CG/CMC paths unchanged. ## Relevant files | File | Role | |------|------| | [`indexer/src/db/queries/candles.rs`](indexer/src/db/queries/candles.rs) | `get_candles` `ORDER BY … ASC LIMIT` (change here) | | [`indexer/src/api/pairs.rs`](indexer/src/api/pairs.rs) | `get_pair_candles`, `DEFAULT_CANDLE_LOOKBACK_DAYS`, `CandleQuery.limit` | | [`indexer/tests/api_pairs.rs`](indexer/tests/api_pairs.rs) | Interval 400 / default interval | | [`indexer/tests/security.rs`](indexer/tests/security.rs) | `limit=99999` cap; interval injection | | [`indexer/tests/api_limit_lower_bound.rs`](indexer/tests/api_limit_lower_bound.rs) | Negative/zero `limit` | | [`indexer/tests/candle_usd_mark.rs`](indexer/tests/candle_usd_mark.rs) | Mark bars on GET; `body[0]` order assumptions | | [`docs/indexer-invariants.md`](docs/indexer-invariants.md) | Candle window + numeric caps — add **newest-N** | | [`frontend-dapp/src/services/indexer/client.ts`](frontend-dapp/src/services/indexer/client.ts) | `getCandles` default `limit = 200` | | [`frontend-dapp/src/components/charts/PriceChart.tsx`](frontend-dapp/src/components/charts/PriceChart.tsx) | Interval chips; `getCandles(pair, interval)` | | [`frontend-dapp/src/utils/tradePairPrefetch.ts`](frontend-dapp/src/utils/tradePairPrefetch.ts) | Prefetch same default | | [`frontend-dapp/src/components/charts/PriceChartLightweightCanvas.tsx`](frontend-dapp/src/components/charts/PriceChartLightweightCanvas.tsx) | `fitContent` policy | | [`frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts`](frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts) | Interval `setData` vs live `update` | | [`frontend-dapp/src/components/charts/__tests__/PriceChart.test.tsx`](frontend-dapp/src/components/charts/__tests__/PriceChart.test.tsx) | 1h → 15m `aria-pressed` / aria-live ([#214](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/214)) | | [`frontend-dapp/src/components/charts/__tests__/PriceChartLightweightCanvas.test.tsx`](frontend-dapp/src/components/charts/__tests__/PriceChartLightweightCanvas.test.tsx) | No `fitContent` on background refresh | | [`frontend-dapp/src/index.css`](frontend-dapp/src/index.css) | `.tab-glass-active` | | [`frontend-dapp/e2e/price-chart-smoke.spec.ts`](frontend-dapp/e2e/price-chart-smoke.spec.ts) | Canvas smoke ([#228](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/228)) | | [`docs/frontend.md`](docs/frontend.md) § Trade page — price chart invariants | Timeframe + zoom | | [`skills/AGENTS_FRONTEND_PRICE_CHART.md`](skills/AGENTS_FRONTEND_PRICE_CHART.md) | Playbook | | [`skills/AGENTS_FRONTEND_DESIGN_SYSTEM.md`](skills/AGENTS_FRONTEND_DESIGN_SYSTEM.md) | `tab-glass*` / blue active | | [`skills/AGENTS_INDEXER_CANDLE_USD_MARK.md`](skills/AGENTS_INDEXER_CANDLE_USD_MARK.md) | Do not weaken C568 | ## Recommended direction 1. **Indexer `get_candles`:** wrap as newest-N then ASC, e.g. ```sql SELECT * FROM ( SELECT * FROM candles WHERE pair_id = $1 AND interval = $2 AND open_time >= $3 AND open_time <= $4 ORDER BY open_time DESC LIMIT $5 ) t ORDER BY open_time ASC ``` Integration test: seed **> limit** 15m bars spanning several days; default GET last `open_time` is the **newest** seeded bar, not the oldest; array is still strictly increasing `open_time`; `limit=200` still returns ≤ 200. 2. **Optional dApp `limit`:** keep 200 or use `min(1000, …)` for 1m/5m/15m only. Newest-200 of 15m ≈ 50h of *recent* data — enough for the cutoff report. Do not require 1000 if indexer newest-N is correct. 3. **Interval switch refit:** when `interval` changes (detect via first-bar time / query key), call `fitContent()` **once** after `setData`. Regression: existing test *background data refresh does not call fitContent* stays green; **new** test: interval `setData` **does** call `fitContent`. 4. **Selected chip:** raise active blue (e.g. `--accent-surface` / higher `#448aff` mix + stronger border) on interval buttons until the pressed chip is obvious on dark **and** light. WCAG-ish contrast vs unselected. Keep `aria-pressed`. Snapshot or computed-style test so 14% wash cannot return. 5. **Docs:** one row in `docs/indexer-invariants.md` (default GET = latest `limit` bars, ASC JSON). Note in `docs/frontend.md` that interval switch refits; live refetch does not. ## Acceptance criteria - [ ] **C1.** Default `GET /api/v1/pairs/{addr}/candles?interval=15m` (no `from`/`to`) last element `open_time` is the newest stored 15m bar in the 90d window (same for `1m` / `5m`). - [ ] **C2.** Response is chronological (`open_time` non-decreasing). Empty pair → `[]`. - [ ] **C3.** `limit` still clamp 1–1000; `limit=99999` → ≤ 1000; `limit=-1`/`0` → 200 OK, ≤ 1 row. - [ ] **C4.** Invalid interval still 400; pair missing 404. - [ ] **C5.** Explicit `from`/`to` still filter; newest-N is **within** that range (oldest-N must not return). - [ ] **C6.** `/charts` and `/trade`: 1h → 15m (and 5m, 1m) last candle is **current** (within one interval of indexer now), not days stale. Empty-right gap from a leftover 1h range is gone (plot fits the new series). - [ ] **C7.** 1h / 4h / 1d / 1w still show recent data (no regression to empty/oldest-only). - [ ] **C8.** 30s candle refetch does **not** reset user zoom ([#336](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/336)). - [ ] **C9.** Interval switch does **not** unmount the canvas ([#148](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/148)); pair switch still remounts. - [ ] **C10.** Idle mark bars still appear (`trade_count = 0`, [#568](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/568)). - [ ] **C11.** Pressed interval chip is clearly stronger blue on dark and light; `aria-pressed="true"` only on the selected interval; keyboard `:focus-visible` ring remains. - [ ] **C12.** Slippage / Pool Manage / Indicators `tab-glass-active` still acceptable if the fill is shared; no gold wash, no `*-neo`. ## Test plan (all paths) ### Indexer 1. Seed 400+ 15m candles over ≥ 4 days; `GET .../candles?interval=15m` (default limit 200): `len==200`, first `open_time` > oldest seed, last `open_time` == newest seed, times sorted ASC. 2. Same for `1m`, `5m`, `1h` (1h with 250 hourly bars: last bar is newest). 3. `limit=10`: newest 10, ASC. 4. `limit=1000` and `limit=99999`: ≤ 1000, newest-capped. 5. `from`/`to` covering only an **old** slice: returns newest-N **inside that slice** (not leaking bars after `to`). 6. `from`/`to` inverted or unparseable: existing behavior (ignore / default window) — do not 500. 7. Invalid interval `3h`, `javascript:alert(1)`, `1m;drop`: 400. 8. Unknown pair: 404. 9. Negative/zero limit: existing lower-bound test still passes. 10. Mark-only pair ([#568](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/568)): GET includes current mark bar as **last** (or within the newest-N set), `trade_count=0`. 11. `interval` omitted: default `1h`, still newest-N. ### Frontend unit / charts Vitest 12. `getCandles` call includes `interval`; do not send DESC-ordered data into the canvas without client sort (mapper already sorts — keep). 13. `PriceChart.test.tsx`: 1h → 15m still updates aria-live + `aria-pressed` ([#214](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/214)); mock 15m payload **newer** than 1h first bar. 14. Canvas: interval `setData` calls `fitContent`; **background** refetch (same first time, appended/last-bar `update`) does **not**. 15. Pair switch remount: `createChart` count increases; interval spam does not. 16. Active chip: `tab-glass-active` (or new modifier) present on selected interval; contrast/class regression if 14% gradient is the old selector. 17. Light theme: pressed chip still distinguishable (`data-theme='light'`). 18. Invert [#543](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/543) / [#524](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/524): newest-N USD/human rows still invert per bar; no `1/x` on USD. ### E2E / QA (columbus-5 or LocalTerra with dense 15m) 19. `/charts`: 1h → 15m → 5m → 1m → 1h. Each time, last candle time is within one interval of “now” (or last indexed swap/mark). No multi-day empty-right. 20. `/trade` same toolbar. 21. Firefox **and** Chromium (report was Firefox; root cause is API). 22. Zoom on 15m, wait > 30s: zoom preserved; new last bar may `update`. 23. Indicators toggle still refits (existing). 24. Fullscreen aria ([#228](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/228)) unchanged. 25. Visual: selected interval obvious at a glance (dark + light). ## Test plan — attack, hack, and abuse 1. **SQL injection / interval:** `interval=1h%20OR%201=1`, quotes, `;`, `../`, `javascript:` → 400, no 500, no extra rows (`security.rs` pattern). 2. **Limit DoS:** `limit=999999999`, `limit=-1`, overflow strings → clamp, never unbounded `LIMIT`, never 500 from negative LIMIT ([#431](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/431)). 3. **Window DoS:** `from`/`to` spanning years with `limit=1000` → at most 1000 rows (newest inside window). No full-table scan without LIMIT. 4. **DESC-without-reverse:** if a regress returns newest-first JSON, lightweight-charts may throw or plot backwards — unit test **rejects** decreasing `open_time` on the mapped series (or indexer contract test on ASC). 5. **Cross-pair leakage:** `addr` of pair A must not return pair B candles after the query rewrite (JOIN/WHERE still `pair_id`). 6. **Cache / CDN:** if any cache key omitted `interval` or `limit`, 1h could be served as 15m — query string must remain part of the cache key; dApp React Query key already `['candles', pairAddress, interval]`. 7. **Stale race ([#226](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/226)):** slow 1h response must not overwrite fast 15m (existing test). 8. **Integer overflow on `open_time`:** malformed RFC3339 already dropped client-side; API parse failure must not 500. 9. **AuthZ:** candles stay public read; do not add cookies/tokens to GET; no user-specific filtering that could be confused with pair addr. 10. **XSS:** `open`/`close` strings stay numeric; interval never interpolated into HTML. Chip labels are fixed `INTERVALS` const, not API text. 11. **Clickjack / UI redress:** stronger chip must not look like `btn-primary` Confirm/Swap. Interval is `tab-glass`, not a money CTA. 12. **Theme spoof:** do not introduce inline `style` from query params for chip color. ## Verification criteria - `make verify-issue-NNN` (new script) runs: indexer candle newest-N test + `PriceChart` interval tests + canvas fitContent split + `make verify-issue-568` (marks still on GET) + frontend lint for `*-neo`. - Manual columbus-5: liquid UST1 pair, 1h then 15m — last bar is **today**, selected **15m** is obvious blue, Volume may still be 0 on a mark-only last bar (that is C568, not a fail). - No production screenshot with last 15m bar many days behind wall clock after deploy. ## Mode - [ ] v2 (pool-only swap) - [ ] limit order - [ ] hybrid - [x] charts / indexer candles (display) ## Severity - [ ] blocks production - [x] major UX / incorrect accounting display (chart history truncated to oldest window) - [ ] minor (chip contrast alone would be minor; bundled because it is the same toolbar)
PlasticDigits commented 2026-08-29 15:21:04 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1197

mentioned in merge request !1197
PlasticDigits commented 2026-08-30 05:23:00 +00:00 (Migrated from gitlab.com)

mentioned in commit 54c7c82604

mentioned in commit 54c7c82604152e02342d7e62d16172ebf90fe7d7
PlasticDigits commented 2026-08-30 15:46:17 +00:00 (Migrated from gitlab.com)

mentioned in commit 8491aad6c9

mentioned in commit 8491aad6c904232a2a2f3e0374da632a743f6c1e
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-30 15:46:18 +00:00
PlasticDigits commented 2026-08-31 05:07:34 +00:00 (Migrated from gitlab.com)

mentioned in commit 56e4c4f513

mentioned in commit 56e4c4f51339b9bc20422a7783bf94af748d7044
PlasticDigits commented 2026-08-31 05:09:18 +00:00 (Migrated from gitlab.com)

mentioned in issue #712

mentioned in issue #712
PlasticDigits commented 2026-09-01 08:14:37 +00:00 (Migrated from gitlab.com)

mentioned in issue #717

mentioned in issue #717
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#705
No description provided.