From c1d508cb4f4de5cc424d70ea2f20167731257706 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Fri, 28 Aug 2026 15:01:47 -0500 Subject: [PATCH] Duplicate a line even when 10 source bars would pass the horizon. Prod never saw POST /api/lines: the browser aborted the shift. Clamp to the last owned source slot so Duplicate still creates a copy. --- static/app.js | 38 +++++++++++++++++++++++++++++++----- static/chart.js | 33 +++++++++++++++++++++++++++++-- tests/e2e/trendline.test.mjs | 6 +++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/static/app.js b/static/app.js index b0a6816..2eac973 100644 --- a/static/app.js +++ b/static/app.js @@ -1095,6 +1095,14 @@ createApp({ return chartApi?.shiftLineTime(line, time, count) ?? null; } + function duplicateShiftCount(line, count = 10) { + const times = [line.anchor_t, line.last_t]; + if (line.cutoff_t != null) times.push(line.cutoff_t); + const allowed = times.map(time => chartApi?.maxLineShift(line, time, count)); + if (allowed.some(value => value == null)) return null; + return Math.min(...allowed); + } + function duplicateName(line) { const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/); const base = match[1]; @@ -1105,15 +1113,35 @@ createApp({ } async function duplicateLine(line) { + const fail = reason => { + addEvent(`DUPLICATE #${line.number} FAILED · ${reason}`, 'error'); + fetch('/api/debug/snap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'duplicate', + tf: line.tf, + note: `#${line.number} ${reason} last_t ${line.last_t} cutoff ${line.cutoff_t}`, + }), + }).catch(() => {}); + }; if (line.geometry_resolved === false) { - addEvent(`DUPLICATE #${line.number} FAILED · no source geometry`, 'error'); + fail('no source geometry'); return; } - const anchorT = shiftTimeByBars(line, line.anchor_t); - const endT = shiftTimeByBars(line, line.last_t); - const cutoffT = line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t); + const count = duplicateShiftCount(line); + if (count == null) { + fail('cannot shift'); + return; + } + const shift = (time, bars) => (count > 0 + ? shiftTimeByBars(line, time, bars) + : chartApi.shiftDisplayedTime(time, 10)); + const anchorT = shift(line.anchor_t, count); + const endT = shift(line.last_t, count); + const cutoffT = line.cutoff_t == null ? null : shift(line.cutoff_t, count); if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) { - addEvent(`DUPLICATE #${line.number} FAILED · cannot shift 10 bars`, 'error'); + fail('cannot shift'); return; } const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t); diff --git a/static/chart.js b/static/chart.js index 62823d0..dc87c2e 100644 --- a/static/chart.js +++ b/static/chart.js @@ -2585,11 +2585,40 @@ class ConfluenceChart { return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start); } + sourceIndexAt(level, time) { + const source = this.sourceSeries(level); + if (!source) return null; + const exact = ConfluenceChart.timeframeIndexAt(source, time); + if (exact != null) return exact; + const times = ConfluenceChart.sourceTimes(source); + if (!times.length) return null; + let lo = 0; + let hi = times.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (times[mid] <= time) lo = mid + 1; + else hi = mid; + } + return lo === 0 ? null : lo - 1; + } + shiftLineTime(level, time, bars) { const source = this.sourceSeries(level); if (!source) return this.shiftDisplayedTime(time, bars); - const index = ConfluenceChart.timeframeIndexAt(source, time); - return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars); + const index = this.sourceIndexAt(level, time); + if (index == null) return null; + const times = ConfluenceChart.sourceTimes(source); + const target = Math.min(index + bars, times.length - 1); + if (target < 0) return times[0] ?? null; + return ConfluenceChart.timeframeTimeAt(source, target); + } + + maxLineShift(level, time, bars) { + const index = this.sourceIndexAt(level, time); + if (index == null) return null; + const source = this.sourceSeries(level); + const last = ConfluenceChart.sourceTimes(source).length - 1; + return Math.max(0, Math.min(bars, last - index)); } shiftDisplayedTime(time, slots) { diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 18d8427..2a280d9 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -234,6 +234,8 @@ test('daily future slots and live duration follow the server session calendar', beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1), shifted: c.shiftDisplayedTime(thursday, 1), shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1), + horizonShift: c.maxLineShift(horizonLine, monday, 10), + thursdayShift: c.maxLineShift(horizonLine, thursday, 10), }; }); @@ -241,7 +243,9 @@ test('daily future slots and live duration follow the server session calendar', assert.equal(result.duration, 23 * 3600); assert.equal(result.beyondHorizon, null); assert.equal(result.shifted, 1786917600); - assert.equal(result.shiftBeyondHorizon, null); + assert.equal(result.shiftBeyondHorizon, 1787004000); + assert.equal(result.horizonShift, 0); + assert.ok(result.thursdayShift > 0); assertNoPageErrors(page, assert); }); });