@@ -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
0 commit comments