Skip to content

Commit d8d640b

Browse files
alanbldclaude
andcommitted
feat: Milestones ignore working day rules
Milestones are events, not work - they can now occur on any calendar day including weekends and holidays. When a milestone has a constraint (SNET, FNET, MSO, MFO) on a non-working day, it is scheduled on the exact constraint date rather than being advanced to the next working day. This enables real-world scenarios like Sunday releases where the milestone represents an event rather than work being performed. Implementation: - Add `pinned_date: Option<NaiveDate>` field to TaskNode - For milestones with constraints on non-working days, store exact date - Use pinned_date for all temporal fields in ScheduledTask - Update tests to reflect new behavior Example: `start_no_earlier_than: 2018-06-03` (Sunday) on a milestone now schedules on Sunday instead of Monday. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fc781f8 commit d8d640b

File tree

4 files changed

+154
-74
lines changed

4 files changed

+154
-74
lines changed

Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ members = [
1414
]
1515

1616
[workspace.package]
17-
version = "0.9.3"
17+
version = "0.9.4"
1818
edition = "2021"
1919
rust-version = "1.75"
2020
license = "MIT OR Apache-2.0"
@@ -24,10 +24,10 @@ categories = ["command-line-utilities", "development-tools"]
2424

2525
[workspace.dependencies]
2626
# Internal crates (version required for crates.io publishing)
27-
utf8proj-core = { version = "0.9.3", path = "crates/utf8proj-core" }
28-
utf8proj-parser = { version = "0.9.3", path = "crates/utf8proj-parser" }
29-
utf8proj-solver = { version = "0.9.3", path = "crates/utf8proj-solver" }
30-
utf8proj-render = { version = "0.9.3", path = "crates/utf8proj-render" }
27+
utf8proj-core = { version = "0.9.4", path = "crates/utf8proj-core" }
28+
utf8proj-parser = { version = "0.9.4", path = "crates/utf8proj-parser" }
29+
utf8proj-solver = { version = "0.9.4", path = "crates/utf8proj-solver" }
30+
utf8proj-render = { version = "0.9.4", path = "crates/utf8proj-render" }
3131

3232
# Date/Time
3333
chrono = { version = "0.4", features = ["serde"] }

crates/utf8proj-solver/src/lib.rs

Lines changed: 128 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,11 @@ struct TaskNode<'a> {
400400
baseline_start_days: i64,
401401
/// Baseline finish (original plan, ignoring progress) (RFC-0004)
402402
baseline_finish_days: i64,
403+
/// Pinned date for milestones on non-working days
404+
/// Milestones are events, not work, so they can occur on any day.
405+
/// When a milestone has a constraint on a non-working day (e.g., Sunday release),
406+
/// this stores the exact date rather than advancing to the next working day.
407+
pinned_date: Option<NaiveDate>,
403408
}
404409

405410
// =============================================================================
@@ -2658,6 +2663,7 @@ impl Scheduler for CpmSolver {
26582663
remaining_days: duration_days, // Default: full duration (updated in forward pass)
26592664
baseline_start_days: 0, // Computed in forward pass
26602665
baseline_finish_days: 0, // Computed in forward pass
2666+
pinned_date: None, // Set for milestones on non-working days
26612667
},
26622668
);
26632669
}
@@ -2836,44 +2842,85 @@ impl Scheduler for CpmSolver {
28362842
// This chains correctly from in-progress/complete predecessors
28372843
let mut es = forecast_es;
28382844

2845+
// Track pinned date for milestones on non-working days
2846+
let mut milestone_pinned_date: Option<NaiveDate> = None;
2847+
let is_milestone = task.milestone || duration_days == 0;
2848+
28392849
// Apply floor constraints to ES (forward pass)
28402850
let mut min_finish: Option<i64> = None;
28412851
for constraint in &task.constraints {
28422852
match constraint {
28432853
TaskConstraint::MustStartOn(date) => {
2844-
// MustStartOn pins to exact date (or next working day if non-working)
2845-
let effective_date = advance_to_working_day(*date, &calendar);
2846-
let constraint_days =
2847-
date_to_working_days(project.start, effective_date, &calendar);
2848-
es = es.max(constraint_days);
2854+
// Milestones can occur on any day (events, not work)
2855+
// Regular tasks advance to working days
2856+
if is_milestone && !calendar.is_working_day(*date) {
2857+
milestone_pinned_date = Some(*date);
2858+
// Use previous working day for internal calculations
2859+
// but pinned_date will be used for display
2860+
let constraint_days =
2861+
date_to_working_days(project.start, *date, &calendar);
2862+
es = es.max(constraint_days);
2863+
} else {
2864+
let effective_date = advance_to_working_day(*date, &calendar);
2865+
let constraint_days =
2866+
date_to_working_days(project.start, effective_date, &calendar);
2867+
es = es.max(constraint_days);
2868+
}
28492869
}
28502870
TaskConstraint::StartNoEarlierThan(date) => {
2851-
// SNET: if constraint date is non-working, round FORWARD to next working day
2852-
// Example: SNET=Sunday → task starts Monday (not Friday)
2853-
let effective_date = advance_to_working_day(*date, &calendar);
2854-
let constraint_days =
2855-
date_to_working_days(project.start, effective_date, &calendar);
2856-
es = es.max(constraint_days);
2871+
// Milestones can occur on any day (events, not work)
2872+
// Regular tasks advance to working days
2873+
if is_milestone && !calendar.is_working_day(*date) {
2874+
milestone_pinned_date = Some(*date);
2875+
let constraint_days =
2876+
date_to_working_days(project.start, *date, &calendar);
2877+
es = es.max(constraint_days);
2878+
} else {
2879+
let effective_date = advance_to_working_day(*date, &calendar);
2880+
let constraint_days =
2881+
date_to_working_days(project.start, effective_date, &calendar);
2882+
es = es.max(constraint_days);
2883+
}
28572884
}
28582885
TaskConstraint::MustFinishOn(date) => {
2859-
// MustFinishOn pins to exact date (or next working day if non-working)
2860-
let effective_date = advance_to_working_day(*date, &calendar);
2861-
let constraint_days =
2862-
date_to_working_days(project.start, effective_date, &calendar);
2863-
let exclusive_ef = constraint_days + 1;
2864-
min_finish = Some(
2865-
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2866-
);
2886+
// Milestones: use exact date even on non-working days
2887+
if is_milestone && !calendar.is_working_day(*date) {
2888+
milestone_pinned_date = Some(*date);
2889+
let constraint_days =
2890+
date_to_working_days(project.start, *date, &calendar);
2891+
let exclusive_ef = constraint_days + 1;
2892+
min_finish = Some(
2893+
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2894+
);
2895+
} else {
2896+
let effective_date = advance_to_working_day(*date, &calendar);
2897+
let constraint_days =
2898+
date_to_working_days(project.start, effective_date, &calendar);
2899+
let exclusive_ef = constraint_days + 1;
2900+
min_finish = Some(
2901+
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2902+
);
2903+
}
28672904
}
28682905
TaskConstraint::FinishNoEarlierThan(date) => {
2869-
// FNET: if constraint date is non-working, round FORWARD to next working day
2870-
let effective_date = advance_to_working_day(*date, &calendar);
2871-
let constraint_days =
2872-
date_to_working_days(project.start, effective_date, &calendar);
2873-
let exclusive_ef = constraint_days + 1;
2874-
min_finish = Some(
2875-
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2876-
);
2906+
// Milestones: use exact date even on non-working days
2907+
if is_milestone && !calendar.is_working_day(*date) {
2908+
milestone_pinned_date = Some(*date);
2909+
let constraint_days =
2910+
date_to_working_days(project.start, *date, &calendar);
2911+
let exclusive_ef = constraint_days + 1;
2912+
min_finish = Some(
2913+
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2914+
);
2915+
} else {
2916+
let effective_date = advance_to_working_day(*date, &calendar);
2917+
let constraint_days =
2918+
date_to_working_days(project.start, effective_date, &calendar);
2919+
let exclusive_ef = constraint_days + 1;
2920+
min_finish = Some(
2921+
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2922+
);
2923+
}
28772924
}
28782925
_ => {} // Ceiling constraints handled in backward pass
28792926
}
@@ -2890,6 +2937,11 @@ impl Scheduler for CpmSolver {
28902937
}
28912938
}
28922939

2940+
// Store pinned date for milestones on non-working days
2941+
if let Some(node) = nodes.get_mut(id) {
2942+
node.pinned_date = milestone_pinned_date;
2943+
}
2944+
28932945
// Not started: remaining = full duration
28942946
(es, ef, duration_days)
28952947
}
@@ -3119,14 +3171,22 @@ impl Scheduler for CpmSolver {
31193171
let mut scheduled_tasks: HashMap<TaskId, ScheduledTask> = HashMap::new();
31203172

31213173
for (id, node) in &nodes {
3122-
let start_date = working_day_cache.get(node.early_start);
3123-
// Finish date is the last day of work, not the day after
3124-
// So for a 20-day task starting Feb 03, finish is Feb 28 (day 20), not Mar 03
3125-
let finish_date = if node.duration_days > 0 {
3126-
// early_finish - 1 because finish is inclusive (last day of work)
3127-
working_day_cache.get(node.early_finish - 1)
3174+
// For milestones with pinned dates on non-working days, use the pinned date
3175+
// Otherwise, calculate from working day cache as usual
3176+
let (start_date, finish_date) = if let Some(pinned) = node.pinned_date {
3177+
// Milestone pinned to a specific non-working day
3178+
(pinned, pinned)
31283179
} else {
3129-
start_date // Milestone
3180+
let start = working_day_cache.get(node.early_start);
3181+
// Finish date is the last day of work, not the day after
3182+
// So for a 20-day task starting Feb 03, finish is Feb 28 (day 20), not Mar 03
3183+
let finish = if node.duration_days > 0 {
3184+
// early_finish - 1 because finish is inclusive (last day of work)
3185+
working_day_cache.get(node.early_finish - 1)
3186+
} else {
3187+
start // Milestone
3188+
};
3189+
(start, finish)
31303190
};
31313191

31323192
// Build assignments with RFC-0001 cost calculation
@@ -3225,6 +3285,34 @@ impl Scheduler for CpmSolver {
32253285
let start_variance_days = (forecast_start - baseline_start_date).num_days();
32263286
let finish_variance_days = (forecast_finish - baseline_finish_date).num_days();
32273287

3288+
// For milestones with pinned dates, use that date for all temporal fields
3289+
let (es_date, ef_date, ls_date, lf_date, bs_date, bf_date) =
3290+
if let Some(pinned) = node.pinned_date {
3291+
// All dates collapse to the pinned date for milestones on non-working days
3292+
(pinned, pinned, pinned, pinned, pinned, pinned)
3293+
} else {
3294+
(
3295+
working_day_cache.get(node.early_start),
3296+
if node.duration_days > 0 {
3297+
working_day_cache.get(node.early_finish - 1)
3298+
} else {
3299+
working_day_cache.get(node.early_finish)
3300+
},
3301+
working_day_cache.get(node.late_start),
3302+
if node.duration_days > 0 {
3303+
working_day_cache.get(node.late_finish - 1)
3304+
} else {
3305+
working_day_cache.get(node.late_finish)
3306+
},
3307+
working_day_cache.get(node.baseline_start_days),
3308+
if node.original_duration_days > 0 {
3309+
working_day_cache.get(node.baseline_finish_days - 1)
3310+
} else {
3311+
working_day_cache.get(node.baseline_finish_days)
3312+
},
3313+
)
3314+
};
3315+
32283316
scheduled_tasks.insert(
32293317
id.clone(),
32303318
ScheduledTask {
@@ -3235,18 +3323,10 @@ impl Scheduler for CpmSolver {
32353323
assignments,
32363324
slack: Duration::days(node.slack),
32373325
is_critical: node.slack == 0, // Milestones can be critical too
3238-
early_start: working_day_cache.get(node.early_start),
3239-
early_finish: if node.duration_days > 0 {
3240-
working_day_cache.get(node.early_finish - 1)
3241-
} else {
3242-
working_day_cache.get(node.early_finish)
3243-
},
3244-
late_start: working_day_cache.get(node.late_start),
3245-
late_finish: if node.duration_days > 0 {
3246-
working_day_cache.get(node.late_finish - 1)
3247-
} else {
3248-
working_day_cache.get(node.late_finish)
3249-
},
3326+
early_start: es_date,
3327+
early_finish: ef_date,
3328+
late_start: ls_date,
3329+
late_finish: lf_date,
32503330
// Progress tracking fields
32513331
forecast_start,
32523332
forecast_finish,
@@ -3255,12 +3335,8 @@ impl Scheduler for CpmSolver {
32553335
status,
32563336
// Variance fields (baseline vs forecast)
32573337
// Baseline uses original plan (from baseline_start/finish_days)
3258-
baseline_start: working_day_cache.get(node.baseline_start_days),
3259-
baseline_finish: if node.original_duration_days > 0 {
3260-
working_day_cache.get(node.baseline_finish_days - 1)
3261-
} else {
3262-
working_day_cache.get(node.baseline_finish_days)
3263-
},
3338+
baseline_start: bs_date,
3339+
baseline_finish: bf_date,
32643340
start_variance_days,
32653341
finish_variance_days,
32663342
// RFC-0001: Cost range fields

crates/utf8proj-solver/tests/constraint_wiring.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,9 @@ fn snet_on_sunday_rounds_forward_to_monday() {
398398
}
399399

400400
#[test]
401-
fn snet_milestone_on_weekend_rounds_forward() {
402-
// Same bug for milestones (which have zero duration)
401+
fn snet_milestone_on_weekend_stays_on_weekend() {
402+
// Milestones are events, not work - they can occur on any day
403+
// A release milestone on Saturday should stay on Saturday
403404
let mut project = Project::new("SNET Milestone Weekend");
404405
project.start = date(2025, 1, 6); // Monday
405406

@@ -413,17 +414,19 @@ fn snet_milestone_on_weekend_rounds_forward() {
413414
let solver = CpmSolver::new();
414415
let schedule = solver.schedule(&project).unwrap();
415416

416-
// Milestone should be on Monday 2025-01-13 (next working day after weekend)
417+
// Milestone should be on Saturday 2025-01-11 (exact constraint date)
418+
// Milestones ignore working day rules - they are events, not work
417419
assert_eq!(
418420
schedule.tasks["milestone"].start,
419-
date(2025, 1, 13),
420-
"SNET milestone on Saturday should round forward to Monday"
421+
date(2025, 1, 11),
422+
"SNET milestone on Saturday should stay on Saturday (events can occur any day)"
421423
);
422424
}
423425

424426
#[test]
425-
fn nested_milestone_snet_on_weekend() {
426-
// Reproduce the exact bug from CTL PIII: nested milestone with SNET on weekend
427+
fn nested_milestone_snet_on_weekend_stays_on_weekend() {
428+
// Milestones are events, not work - they can occur on any day
429+
// A nested milestone (e.g., Sunday release) should stay on Sunday
427430
let mut project = Project::new("Nested SNET");
428431
project.start = date(2025, 1, 6); // Monday
429432

@@ -448,10 +451,11 @@ fn nested_milestone_snet_on_weekend() {
448451
let solver = CpmSolver::new();
449452
let schedule = solver.schedule(&project).unwrap();
450453

451-
// Nested milestone should be on Monday 2025-01-20 (next working day)
454+
// Nested milestone should be on Sunday 2025-01-19 (exact constraint date)
455+
// Milestones ignore working day rules - they are events, not work
452456
assert_eq!(
453457
schedule.tasks["container.release"].start,
454-
date(2025, 1, 20),
455-
"Nested milestone SNET on Sunday should round forward to Monday"
458+
date(2025, 1, 19),
459+
"Nested milestone SNET on Sunday should stay on Sunday (events can occur any day)"
456460
);
457461
}

0 commit comments

Comments
 (0)