fix(scheduling): Model milestones as CPM nodes to correctly determine critical path#487
Conversation
3210859 to
c364d45
Compare
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
Reviewed against the design system (tokens.css, GanttChart.tsx color resolution pattern, existing GanttBar critical path styling) and the accessibility requirements.
Token Adherence — PASS
criticalBorderColor is resolved via readCssVar('--color-gantt-bar-critical-border') in GanttChart.tsx, the same token already used for critical work item bar borders. This is correct and consistent — the SVG getComputedStyle resolution pattern is the right approach since SVG attributes cannot consume var() references directly.
--color-gantt-bar-critical-border maps to var(--color-orange-400) in light mode and var(--color-orange-300) in dark mode, both confirmed in tokens.css.
Visual Consistency — PASS
The strokeWidth step from 2 → 3 for critical milestones mirrors how GanttBar uses a distinct border treatment for critical work items. The approach is consistent across the chart.
Dark Mode — PASS
Critical color is read fresh on every MutationObserver theme change (the existing useEffect that calls resolveColors() when data-theme changes covers this). Dark mode is handled correctly — no hardcoded color values introduced.
Ghost Diamond Interaction — PASS with note
The ghost diamond early-returns before reaching the isCritical stroke logic (line 177 early return uses lateStroke + strokeWidth={1.5}), so ghost diamonds correctly never receive critical styling even though isCritical and criticalBorderColor are passed as props. The props are harmlessly unused on the ghost path. This is intentional and the PR description confirms it, but a brief inline comment on line 340 would make the intent explicit for future readers. Informational only — not blocking.
Accessibility — PASS
The aria-label addition , critical path for critical milestones is the right pattern; it matches how GanttBar announces critical bars via accessible labels. The label order (title, status, critical flag, target date) is logical for screen reader consumption.
Responsive / Touch — PASS
No layout or touch target changes. HIT_RADIUS_TOUCH = 22 is unchanged and sufficient.
Finding Summary
No blocking issues. One informational suggestion: add a comment on the ghost DiamondMarker call (line ~339) noting that isCritical/criticalBorderColor are passed but intentionally ignored by the ghost early-return, to prevent a future reader from removing them as "dead props".
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
PR #487 reviewed — no security findings.
Scope: Milestone CPM node refactor (, , , , 44 new tests).
Checklist
- No SQL/command/XSS injection vectors in new code
- Authentication/authorization enforced on all new endpoints (no new endpoints added)
- No sensitive data exposed in logs, errors, or client responses
- User input validated and sanitized at API boundaries
- No new dependencies introduced
- No hardcoded credentials or secrets
- CORS configuration unchanged
- Error responses do not leak internal details
Injection analysis — milestone: prefix handling
The parseInt(id.slice('milestone:'.length), 10) call in timelineService.ts:341 operates exclusively on values produced by the scheduling engine itself. The only strings that enter the criticalPath array with the milestone: prefix are constructed in the same service from DB-sourced integer milestone IDs (milestone:${milestoneId} where milestoneId is a number from milestoneMap.keys()). No user-controlled input reaches this code path. parseInt with radix 10 is the correct call and produces a safe integer.
DB-write guard
The scheduled.workItemId.startsWith('milestone:') guard in section 7 of autoReschedule correctly prevents virtual CPM nodes from being persisted. The filter is applied before any DB write loop, so there is no TOCTOU risk.
isCritical field exposure
The new isCritical: boolean field on TimelineMilestone is a computed boolean derived from the CPM result — it contains no PII, internal IDs, or implementation details beyond what the existing criticalPath work-item array already exposes. No new data-exposure risk.
Ghost diamond isolation
The ghost polygon always uses strokeWidth={1.5} and isGhost prop bypasses the isCritical && !isGhost ? 3 : 2 branch correctly — no styling bleed.
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
Architecture Review — PR #487 (Bug #484: Milestones on Critical Path)
Verified
-
Architecture compliance: Modeling milestones as zero-duration CPM nodes with
milestone:<id>prefix is a clean, well-scoped approach. The scheduling engine (schedule()) remains a pure function — milestone nodes are injected as data, not as special-cased logic inside the CPM algorithm. This preserves the engine's generality (ADR-014). -
Shared type consistency:
TimelineMilestone.isCriticalis correctly added as a requiredbooleanfield (not optional), and all test fixtures across 6 files are updated to include it. The shared type is the single source of truth and both server and client consume it correctly. -
Critical path filtering:
milestone:prefixed IDs are correctly stripped from thecriticalPatharray in the API response (API contract sayscriticalPath: string[]contains work item IDs only). The milestone criticality is conveyed separately via each milestone'sisCriticalfield — good separation of concerns. -
DB write guard: The
autoReschedule()function correctly skipsmilestone:prefixed IDs when writing scheduled dates back to the database. Test coverage confirms no phantom rows are created. -
Frontend rendering: Critical milestones get thicker stroke (3 vs 2) with
criticalBorderColor, ghost diamonds are unaffected, aria-labels include "critical path" for accessibility. All props are optional with sensible defaults — backward compatible. -
Test coverage: 44 new tests across 3 test files (scheduling engine CPM, timeline service, GanttMilestones rendering) plus fixture updates in 6 more files. Coverage is thorough: critical/non-critical paths, completed milestones, multiple contributors, cycle detection, mixed rendering, and the DB write guard.
-
CI: All quality gates, test shards, Docker, and E2E smoke tests pass.
Finding — Wiki API Contract Not Updated (Medium)
The TimelineMilestone interface in wiki/API-Contract.md (line ~4391) does not include the new isCritical field. It is also missing the pre-existing projectedDate field (a gap that predates this PR).
Action required: Update the wiki API-Contract.md to add:
interface TimelineMilestone {
id: number;
title: string;
targetDate: string;
isCompleted: boolean;
completedAt: string | null;
color: string | null;
workItemIds: string[];
projectedDate: string | null; // pre-existing gap
isCritical: boolean; // new in this PR
}
This is a documentation-only gap — code and types are correct. Given this is a focused bug fix, I am not requesting changes for this; flagging it so it can be addressed in the current PR or as a follow-up.
Minor Note
The PR description says "Add isCritical?: boolean to TimelineMilestone" but the actual shared type uses required isCritical: boolean. The code is correct; the description is slightly inaccurate.
Code Duplication Note (Informational)
The milestone CPM node construction logic is duplicated between autoReschedule() (schedulingEngine.ts) and getTimeline() (timelineService.ts). Both build the same maps, iterate milestoneIdsWithLinks, and construct identical SchedulingWorkItem nodes and SchedulingDependency arrays. This is acceptable for a bug fix, but a future refactor could extract a shared buildMilestoneCpmNodes(db) helper to keep the two call sites in sync. No action needed now.
Verdict
Approve. The fix is architecturally sound, well-tested, and correctly scoped. The wiki gap should be addressed but does not block merge.
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
Architecture Review — PR #487 (Bug #484: Milestones on Critical Path)
Verified
-
Architecture compliance: Modeling milestones as zero-duration CPM nodes with
milestone:<id>prefix is a clean, well-scoped approach. The scheduling engine (schedule()) remains a pure function — milestone nodes are injected as data, not as special-cased logic inside the CPM algorithm. This preserves the engine's generality (ADR-014). -
Shared type consistency:
TimelineMilestone.isCriticalis correctly added as a requiredbooleanfield (not optional), and all test fixtures across 6 files are updated to include it. The shared type is the single source of truth and both server and client consume it correctly. -
Critical path filtering:
milestone:prefixed IDs are correctly stripped from thecriticalPatharray in the API response (API contract sayscriticalPath: string[]contains work item IDs only). The milestone criticality is conveyed separately via each milestone'sisCriticalfield — good separation of concerns. -
DB write guard: The
autoReschedule()function correctly skipsmilestone:prefixed IDs when writing scheduled dates back to the database. Test coverage confirms no phantom rows are created. -
Frontend rendering: Critical milestones get thicker stroke (3 vs 2) with
criticalBorderColor, ghost diamonds are unaffected, aria-labels include "critical path" for accessibility. All props are optional with sensible defaults — backward compatible. -
Test coverage: 44 new tests across 3 test files (scheduling engine CPM, timeline service, GanttMilestones rendering) plus fixture updates in 6 more files. Coverage is thorough: critical/non-critical paths, completed milestones, multiple contributors, cycle detection, mixed rendering, and the DB write guard.
-
CI: All quality gates, test shards, Docker, and E2E smoke tests pass.
Finding — Wiki API Contract Not Updated (Medium)
The TimelineMilestone interface in wiki/API-Contract.md (line ~4391) does not include the new isCritical field. It is also missing the pre-existing projectedDate field (a gap that predates this PR).
Action required: Update the wiki API-Contract.md to add:
interface TimelineMilestone {
id: number;
title: string;
targetDate: string;
isCompleted: boolean;
completedAt: string | null;
color: string | null;
workItemIds: string[];
projectedDate: string | null; // pre-existing gap
isCritical: boolean; // new in this PR
}
This is a documentation-only gap — code and types are correct. Given this is a focused bug fix, I am not requesting changes for this; flagging it so it can be addressed in the current PR or as a follow-up.
Minor Note
The PR description says "Add isCritical?: boolean to TimelineMilestone" but the actual shared type uses required isCritical: boolean. The code is correct; the description is slightly inaccurate.
Code Duplication Note (Informational)
The milestone CPM node construction logic is duplicated between autoReschedule() (schedulingEngine.ts) and getTimeline() (timelineService.ts). Both build the same maps, iterate milestoneIdsWithLinks, and construct identical SchedulingWorkItem nodes and SchedulingDependency arrays. This is acceptable for a bug fix, but a future refactor could extract a shared buildMilestoneCpmNodes(db) helper to keep the two call sites in sync. No action needed now.
Verdict
Approve. The fix is architecturally sound, well-tested, and correctly scoped. The wiki gap should be addressed but does not block merge.
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
PR #487 reviewed — no security findings.
Scope: Milestone CPM node refactor (schedulingEngine.ts, timelineService.ts, shared/src/types/timeline.ts, GanttMilestones.tsx, 44 new tests).
Checklist
- No SQL/command/XSS injection vectors in new code
- Authentication/authorization enforced on all new endpoints (no new endpoints added)
- No sensitive data exposed in logs, errors, or client responses
- User input validated and sanitized at API boundaries
- No new dependencies introduced
- No hardcoded credentials or secrets
- CORS configuration unchanged
- Error responses do not leak internal details
Injection analysis — milestone: prefix handling
The parseInt(id.slice('milestone:'.length), 10) call in timelineService.ts:341 operates exclusively on values produced by the scheduling engine itself. The only strings that enter the criticalPath array with the milestone: prefix are constructed in the same service from DB-sourced integer milestone IDs (milestone:${milestoneId} where milestoneId is a number from milestoneMap.keys()). No user-controlled input reaches this code path. parseInt with radix 10 is the correct call and produces a safe integer.
DB-write guard
The scheduled.workItemId.startsWith('milestone:') guard in section 7 of autoReschedule correctly prevents virtual CPM nodes from being persisted. The filter is applied before any DB write loop, so there is no TOCTOU risk.
isCritical field exposure
The new isCritical: boolean field on TimelineMilestone is a computed boolean derived from the CPM result — it contains no PII, internal IDs, or implementation details beyond what the existing criticalPath work-item array already exposes. No new data-exposure risk.
Ghost diamond isolation
The ghost polygon always uses strokeWidth=1.5 and the isGhost prop correctly bypasses the isCritical && !isGhost conditional — no critical styling bleed onto the ghost diamond.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… critical path When a milestone sits on the longest path through the project, it must be identified as part of the critical path. The previous implementation expanded milestones into synthetic work item→work item dependencies, causing milestones to disappear from the CPM graph entirely. This fix models each milestone as a zero-duration CPM node with ID prefix `milestone:<id>`. The CPM engine naturally computes float for these nodes and includes them in the critical path when appropriate. Changes: - Add `isCritical?: boolean` to TimelineMilestone shared type - Replace milestone dependency expansion in autoReschedule() with milestone-as-CPM-node logic - Create one zero-duration scheduling node per milestone with contributors or dependents - Create FS dependencies: contributor→milestone and milestone→dependent - Skip writing milestone nodes back to the database (they are virtual CPM nodes only) - Update getTimeline() critical path computation to include milestone CPM nodes - Build milestone nodes and synthetic deps in the same way as autoReschedule() - Extract critical milestone IDs from CPM result and filter milestone nodes from returned critical path - Propagate isCritical to each TimelineMilestone in the response Fixes #484 Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com>
Adds three new test suites verifying the milestone critical path refactoring:
1. server/src/services/schedulingEngine.milestoneCpm.test.ts (new file, 15 tests)
- Milestone on critical path: zero-duration node with milestone:<id> appears in
criticalPath when it is the sole linear path
- Milestone NOT on critical path: positive float when a longer sibling path
converges to the same terminal node (CPM backward-pass float > 0)
- Completed milestone: CPM node uses actualStartDate/actualEndDate, not today-floor
- Multiple contributors: milestone waits for the latest contributor's EF
- autoReschedule DB writes: verifies no milestone: prefixed row is written to
the work_items table after autoReschedule()
2. server/src/services/timelineService.test.ts (9 new tests added)
- isCritical is true/false per milestone based on engine criticalPath
- Multiple milestones independently classified as critical/non-critical
- isCritical is false for all milestones when a cycle is detected
- Every milestone in the response has isCritical as a boolean field
- criticalPath returned to caller strips out all milestone: prefixed IDs
3. client/src/components/GanttChart/GanttMilestones.test.tsx (20 new tests added)
- Critical diamond polygon has strokeWidth 3; non-critical has strokeWidth 2
- Critical milestone uses criticalBorderColor as stroke
- Ghost diamond always uses strokeWidth 1.5 + strokeDasharray regardless of isCritical
- aria-label contains "critical path" for critical milestones only
- Mixed critical/non-critical milestones independently styled in same render
Fixes #484
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
- Fix shared type: add isCritical field to MilestoneTimelineItem - Fix GanttMilestones to read and render critical path from isCritical flag - Fix GanttChart to pass milestone critical path data through correctly - Expand GanttMilestones.test.tsx with critical path rendering coverage Fixes #484 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude backend-developer (Haiku) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
Adds the `isCritical: false` field to all TimelineMilestone test fixtures in GanttChart, CalendarView, CalendarMilestone, MonthGrid, WeekGrid, and calendarUtils tests to match the updated TimelineMilestone type definition. Fixes #484 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8392bbb to
fceaf89
Compare
|
🎉 This PR is included in version 1.12.0-beta.48 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Milestones were not appearing on the critical path, even when they sat on the longest path through the project. This fix models each milestone as a zero-duration CPM node so they are naturally included in critical path calculations.
Root cause: The previous implementation expanded milestones into synthetic work item→work item dependencies, causing milestones to disappear from the CPM graph entirely.
Solution: Model each milestone as a zero-duration CPM node with ID prefix
milestone:<id>. The CPM engine naturally computes float for these nodes and includes them in the critical path when appropriate.Changes
Shared Types (
shared/src/types/timeline.ts)isCritical?: booleantoTimelineMilestoneinterfaceScheduling Engine (
server/src/services/schedulingEngine.ts)Section 3-4: Replace milestone dependency expansion with milestone-as-CPM-node logic
SchedulingWorkItemper milestone with contributors or dependentsmilestone:<id>completedAtdate; for incomplete: usetargetDateSection 7: Skip writing milestone nodes back to database
milestone:when writing scheduled datesTimeline Service (
server/src/services/timelineService.ts)Section 5: Pass milestone CPM nodes to the scheduling engine
autoReschedule()milestone:entries from returnedcriticalPath(API should only contain work item IDs)Section 6: Propagate
isCriticalto milestonesisCriticalfield to eachTimelineMilestonein the response based on critical milestone IDsTest Coverage
The QA agent will verify:
isCritical: falsecriticalPatharray returned by the APIautoReschedule()handles milestone nodes correctly during date updatesFixes #484
🤖 Generated with Claude Code