Skip to content

Commit 61835c0

Browse files
rita-codesclaude
andcommitted
Clamp a repositioned event dropped before its predecessor's end
Aligns the drop gesture with the hard-constraint behavior of established Gantt tools: a successor dropped in violation snaps forward to the first valid position instead of keeping a violated arrow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
1 parent bb44bf1 commit 61835c0

6 files changed

Lines changed: 361 additions & 61 deletions

File tree

docs/data/scheduler/experiments/experiments.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Finish-to-Start dependencies on the timeline. The dataset covers every route sha
2222

2323
## Timeline auto-scheduling (#22857, #22858)
2424

25-
The Finish-to-Start auto-scheduling engine. Moving or resizing an event pushes its violated successors forward on the drop, transitively, as one atomic change — and only when the relationship is actually broken: dragging `Plan` slightly to the right lands inside `Build`'s slack and moves nothing, dragging it past `Build`'s start pushes the whole chain. Moving a predecessor earlier never pulls its successors back. The `Design` diamond reconverges on `Integrate`, which settles once behind the later of its two branches. `Audit` is read-only: pushing `Setup` into it leaves it in place with the violated arrow visible, and nothing beyond it moves. The all-day pair shifts by whole days and stays all-day. Dragging a terminal to close a cycle (for example `Deploy` onto `Plan`) is rejected with a transient toast. The feature has no public API yet, so the demo feeds the internal store parameters.
25+
The Finish-to-Start auto-scheduling engine. Moving or resizing an event pushes its violated successors forward on the drop, transitively, as one atomic change — and only when the relationship is actually broken: dragging `Plan` slightly to the right lands inside `Build`'s slack and moves nothing, dragging it past `Build`'s start pushes the whole chain. Moving a predecessor earlier never pulls its successors back. Dropping a successor onto or before its predecessor snaps it forward to the first valid position: the drop cannot create a violated arrow, while violations already present in the data are left as-is. The `Design` diamond reconverges on `Integrate`, which settles once behind the later of its two branches. `Audit` is read-only: pushing `Setup` into it leaves it in place with the violated arrow visible, and nothing beyond it moves. The all-day pair shifts by whole days and stays all-day. Dragging a terminal to close a cycle (for example `Deploy` onto `Plan`) is rejected with a transient toast. The feature has no public API yet, so the demo feeds the internal store parameters.
2626

2727
{{"demo": "TimelineAutoScheduling.js", "bg": "inline", "defaultCodeOpen": false}}
2828

packages/x-scheduler-internals-premium/src/internals/plugins/SchedulerSchedulingPlugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ export class SchedulerSchedulingPlugin<
123123
activeDependenciesBySource: eventTimelinePremiumDependencySelectors.activeModelListBySource(
124124
this.store.state,
125125
),
126+
activeDependenciesByTarget: eventTimelinePremiumDependencySelectors.activeModelListByTarget(
127+
this.store.state,
128+
),
126129
isEventReadOnly: (eventId) => schedulerEventSelectors.isReadOnly(this.store.state, eventId),
127130
updated,
128131
deleted: new Set(deleted),

packages/x-scheduler-internals-premium/src/internals/utils/auto-scheduling.test.ts

Lines changed: 199 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ function buildLookup(events: SchedulerProcessedEvent[]) {
1414
return new Map(events.map((event) => [event.id, event]));
1515
}
1616

17-
function groupBySource(dependencies: SchedulerDependency[]) {
18-
const bySource = new Map<SchedulerEventId, SchedulerDependency[]>();
17+
function groupByEndpoint(dependencies: SchedulerDependency[], endpoint: 'source' | 'target') {
18+
const grouped = new Map<SchedulerEventId, SchedulerDependency[]>();
1919
for (const dependency of dependencies) {
20-
const group = bySource.get(dependency.source);
20+
const group = grouped.get(dependency[endpoint]);
2121
if (group) {
2222
group.push(dependency);
2323
} else {
24-
bySource.set(dependency.source, [dependency]);
24+
grouped.set(dependency[endpoint], [dependency]);
2525
}
2626
}
27-
return bySource;
27+
return grouped;
2828
}
2929

3030
let dependencyCount = 0;
@@ -47,7 +47,8 @@ function runCascade(
4747
return computeAutoSchedulingCascade({
4848
adapter,
4949
processedEventLookup: buildLookup(events),
50-
activeDependenciesBySource: groupBySource(dependencies),
50+
activeDependenciesBySource: groupByEndpoint(dependencies, 'source'),
51+
activeDependenciesByTarget: groupByEndpoint(dependencies, 'target'),
5152
isEventReadOnly: overrides.isEventReadOnly ?? (() => false),
5253
updated,
5354
deleted: overrides.deleted ?? new Set(),
@@ -401,7 +402,7 @@ describe('computeAutoSchedulingCascade', () => {
401402
expect(result).to.deep.equal([]);
402403
});
403404

404-
it('should not re-emit a successor that is itself updated in the batch', () => {
405+
it("should clamp an updated successor dropped before a moved predecessor's new end", () => {
405406
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
406407
const eventB = EventBuilder.new()
407408
.id('b')
@@ -413,11 +414,201 @@ describe('computeAutoSchedulingCascade', () => {
413414
[fsDependency('a', 'b')],
414415
[
415416
{ id: 'a', start: date('2025-07-03T11:00:00Z'), end: date('2025-07-03T12:00:00Z') },
416-
// The user also placed b themselves, still violating: their placement wins.
417+
// The user also placed b themselves, still violating: b is clamped forward.
417418
{ id: 'b', start: date('2025-07-03T11:30:00Z'), end: date('2025-07-03T12:30:00Z') },
418419
],
419420
);
420421

422+
expect(result).to.have.length(1);
423+
expect(result[0].id).to.equal('b');
424+
expectDates(result[0], '2025-07-03T12:00:00Z', '2025-07-03T13:00:00Z');
425+
});
426+
427+
it('should clamp an updated successor dropped before the end of an unmoved predecessor', () => {
428+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
429+
const eventB = EventBuilder.new()
430+
.id('b')
431+
.span('2025-07-03T11:00:00Z', '2025-07-03T12:00:00Z')
432+
.toProcessed();
433+
434+
const result = runCascade(
435+
[eventA, eventB],
436+
[fsDependency('a', 'b')],
437+
[{ id: 'b', start: date('2025-07-03T09:30:00Z'), end: date('2025-07-03T10:30:00Z') }],
438+
);
439+
440+
expect(result).to.have.length(1);
441+
expect(result[0].id).to.equal('b');
442+
expectDates(result[0], '2025-07-03T10:00:00Z', '2025-07-03T11:00:00Z');
443+
});
444+
445+
it('should not clamp an updated successor dropped at a valid position', () => {
446+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
447+
const eventB = EventBuilder.new()
448+
.id('b')
449+
.span('2025-07-03T12:00:00Z', '2025-07-03T13:00:00Z')
450+
.toProcessed();
451+
452+
const result = runCascade(
453+
[eventA, eventB],
454+
[fsDependency('a', 'b')],
455+
[{ id: 'b', start: date('2025-07-03T10:00:00Z'), end: date('2025-07-03T11:00:00Z') }],
456+
);
457+
458+
expect(result).to.deep.equal([]);
459+
});
460+
461+
it('should cascade from the clamped position of a dropped successor', () => {
462+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
463+
const eventB = EventBuilder.new()
464+
.id('b')
465+
.span('2025-07-03T11:00:00Z', '2025-07-03T12:00:00Z')
466+
.toProcessed();
467+
const eventC = EventBuilder.new()
468+
.id('c')
469+
.span('2025-07-03T10:30:00Z', '2025-07-03T11:30:00Z')
470+
.toProcessed();
471+
472+
const result = runCascade(
473+
[eventA, eventB, eventC],
474+
[fsDependency('a', 'b'), fsDependency('b', 'c')],
475+
[{ id: 'b', start: date('2025-07-03T09:00:00Z'), end: date('2025-07-03T10:00:00Z') }],
476+
);
477+
478+
expect(result).to.have.length(2);
479+
const byId = new Map(result.map((entry) => [entry.id, entry]));
480+
// b clamps to a's end, and c is pushed from b's clamped end, not its dropped one.
481+
expectDates(byId.get('b')!, '2025-07-03T10:00:00Z', '2025-07-03T11:00:00Z');
482+
expectDates(byId.get('c')!, '2025-07-03T11:00:00Z', '2025-07-03T12:00:00Z');
483+
});
484+
485+
it("should clamp a dropped successor against a cascaded predecessor's new end", () => {
486+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
487+
const eventB = EventBuilder.new()
488+
.id('b')
489+
.span('2025-07-03T10:00:00Z', '2025-07-03T11:00:00Z')
490+
.toProcessed();
491+
const eventC = EventBuilder.new()
492+
.id('c')
493+
.span('2025-07-03T13:00:00Z', '2025-07-03T14:00:00Z')
494+
.toProcessed();
495+
496+
const result = runCascade(
497+
[eventA, eventB, eventC],
498+
[fsDependency('a', 'b'), fsDependency('b', 'c')],
499+
[
500+
{ id: 'a', start: date('2025-07-03T11:00:00Z'), end: date('2025-07-03T12:00:00Z') },
501+
// c is dropped where b's *pushed* position (12:00–13:00) still overlaps it.
502+
{ id: 'c', start: date('2025-07-03T10:00:00Z'), end: date('2025-07-03T11:00:00Z') },
503+
],
504+
);
505+
506+
expect(result).to.have.length(2);
507+
const byId = new Map(result.map((entry) => [entry.id, entry]));
508+
expectDates(byId.get('b')!, '2025-07-03T12:00:00Z', '2025-07-03T13:00:00Z');
509+
expectDates(byId.get('c')!, '2025-07-03T13:00:00Z', '2025-07-03T14:00:00Z');
510+
});
511+
512+
it('should not clamp an updated event whose start is unchanged', () => {
513+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
514+
// b arrived already violating; resizing its end does not re-position it.
515+
const eventB = EventBuilder.new()
516+
.id('b')
517+
.span('2025-07-03T08:00:00Z', '2025-07-03T09:00:00Z')
518+
.toProcessed();
519+
520+
const result = runCascade(
521+
[eventA, eventB],
522+
[fsDependency('a', 'b')],
523+
[{ id: 'b', end: date('2025-07-03T09:30:00Z') }],
524+
);
525+
526+
expect(result).to.deep.equal([]);
527+
});
528+
529+
it('should clamp an all-day successor dropped before its predecessor by whole days', () => {
530+
const predecessor = EventBuilder.new().id('a').singleDay('2025-07-04T09:00:00Z').toProcessed();
531+
const successor = EventBuilder.new()
532+
.id('b')
533+
.withDataTimezone('UTC')
534+
.span('2025-07-10T00:00:00', '2025-07-10T23:59:59.999', { allDay: true })
535+
.toProcessed();
536+
537+
const result = runCascade(
538+
[predecessor, successor],
539+
[fsDependency('a', 'b')],
540+
[
541+
{
542+
id: 'b',
543+
start: utcDate('2025-07-04T00:00:00'),
544+
end: utcDate('2025-07-04T23:59:59.999'),
545+
allDay: true,
546+
},
547+
],
548+
);
549+
550+
expect(result).to.have.length(1);
551+
// The dropped day starts before the predecessor's 10:00 end: minimal whole-day
552+
// clamp from the dropped position is one day.
553+
expect(adapter.getTime(result[0].start!)).to.equal(
554+
adapter.getTime(utcDate('2025-07-05T00:00:00')),
555+
);
556+
expect(adapter.getTime(result[0].end!)).to.equal(
557+
adapter.getTime(utcDate('2025-07-05T23:59:59.999')),
558+
);
559+
});
560+
561+
it('should not clamp a read-only updated event', () => {
562+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
563+
const eventB = EventBuilder.new()
564+
.id('b')
565+
.readOnly()
566+
.span('2025-07-03T11:00:00Z', '2025-07-03T12:00:00Z')
567+
.toProcessed();
568+
569+
const result = runCascade(
570+
[eventA, eventB],
571+
[fsDependency('a', 'b')],
572+
[{ id: 'b', start: date('2025-07-03T09:00:00Z'), end: date('2025-07-03T10:00:00Z') }],
573+
{ isEventReadOnly: (eventId) => eventId === 'b' },
574+
);
575+
576+
expect(result).to.deep.equal([]);
577+
});
578+
579+
it('should not clamp against a predecessor deleted in the same batch', () => {
580+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
581+
const eventB = EventBuilder.new()
582+
.id('b')
583+
.span('2025-07-03T11:00:00Z', '2025-07-03T12:00:00Z')
584+
.toProcessed();
585+
586+
const result = runCascade(
587+
[eventA, eventB],
588+
[fsDependency('a', 'b')],
589+
[{ id: 'b', start: date('2025-07-03T09:00:00Z'), end: date('2025-07-03T10:00:00Z') }],
590+
{ deleted: new Set(['a']) },
591+
);
592+
593+
expect(result).to.deep.equal([]);
594+
});
595+
596+
it('should not clamp against a predecessor turning recurring in the same batch', () => {
597+
const eventA = EventBuilder.new().id('a').singleDay('2025-07-03T09:00:00Z').toProcessed();
598+
const eventB = EventBuilder.new()
599+
.id('b')
600+
.span('2025-07-03T11:00:00Z', '2025-07-03T12:00:00Z')
601+
.toProcessed();
602+
603+
const result = runCascade(
604+
[eventA, eventB],
605+
[fsDependency('a', 'b')],
606+
[
607+
{ id: 'a', rrule: { freq: 'DAILY', interval: 1 } },
608+
{ id: 'b', start: date('2025-07-03T09:00:00Z'), end: date('2025-07-03T10:00:00Z') },
609+
],
610+
);
611+
421612
expect(result).to.deep.equal([]);
422613
});
423614

0 commit comments

Comments
 (0)