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.
This commit is contained in:
Chris Amow 2026-08-28 15:01:47 -05:00
parent 6f03560e74
commit c1d508cb4f
3 changed files with 69 additions and 8 deletions

View file

@ -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);

View file

@ -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) {

View file

@ -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);
});
});