This guide shows how to find the tasks that decide the project's finish date and how much room every other task has. You can get these numbers from a scheduling run, or from dates you already have without scheduling anything.
Analytics are off by default. You ask for them with an AnalyticsOptions object, which has two flags: criticalPath and slack. The same object fits into three option types:
AnalyzeOptions.analytics, for Project#analyze. The analytics describe dates you supply, or the project's current dates, as they are. Nothing gets scheduled.
The engine computes the numbers from two plans. The first plan is the dates being analyzed. For the second, the engine schedules the same tasks backward from the latest finish, which gives each task its latest possible start. Slack is the gap between the two:
Total slack is how far a task can slip before the project's finish date moves. It's the latest start minus the planned start.
Free slack is how far a task can slip before it pushes any successor. It's the smallest margin over the task's outgoing links and never more than the total slack. A task with no successors has free slack equal to its total slack.
A task is critical when its total slack is zero or less. A link is critical when both of its tasks are critical and the link is binding, so its target starts exactly when the link allows. Analytics.criticalPath is a Critical object with the critical task ids in tasks and link ids in links. Analytics.slack is a Vec<TaskSlack> with one entry per task.
A few rules shape the output:
Slack is a double in config.durationUnit (days by default) and counts working time on each task's calendar. A weekend between two tasks doesn't add slack.
Only leaf tasks and milestones get a slack entry. A summary has no slack. It's critical when any of its non-summary descendants is.
With useProgress, completed tasks (progress == 1) report zero total and free slack, but they're never critical.
A dependency cycle makes the numbers meaningless. status is then AnalyticsStatus.INVALID, and both criticalPath and slack are null.
Set the flags in SolveOptions to get analytics together with the schedule.
importjava.util.List;importorg.dhtmlx.plen.*;publicclassSolveAnalytics{staticTaskDatatask(Stringid,Momentstart,doubleduration){returnnewTaskData(Id.str(id)).withStartDate(start).withDuration(duration);}staticLinkDatafs(Stringid,Stringsource,Stringtarget){returnnewLinkData().withId(Id.str(id)).withSource(Id.str(source)).withTarget(Id.str(target));}publicstaticvoidmain(String[]args)throwsException{Moment monday =Moment.fromYmdHm(2026,8,10,8,0);ProjectData data =newProjectData().withTasks(List.of(task("design", monday,2.0),task("build", monday,3.0),task("docs", monday,1.0))).withLinks(List.of(fs("l1","design","build"),fs("l2","design","docs")));SolveOptions options =newSolveOptions().withAnalytics(newAnalyticsOptions().withCriticalPath(true).withSlack(true));ScheduleResult result =Engine.schedule(data, options);Analytics analytics = result.analytics;for(Id id : analytics.criticalPath.tasks){System.out.println("critical task: "+ id);}for(Id id : analytics.criticalPath.links){System.out.println("critical link: "+ id);}for(TaskSlack s : analytics.slack){System.out.println(s.task +": total "+ s.total +" free "+ s.free);}}}
Both build and docs wait for design. build takes three days and docs takes one, so design and build form the critical path, joined by link l1. docs reports total 2.0 free 2.0: it can slip two working days before the project end moves.
Project#solve takes the same SolveOptions. The solve cache key is the whole SolveOptions object, so solve(null, options) with analytics turned on recomputes from the accepted dates. You don't get back a cached result without them.
Set the flags in PreviewOptions to see how an edit would change the critical path.
importjava.util.List;importjava.util.stream.Collectors;importorg.dhtmlx.plen.*;publicclassPreviewAnalytics{staticTaskDatatask(Stringid,Momentstart,doubleduration){returnnewTaskData(Id.str(id)).withStartDate(start).withDuration(duration);}staticLinkDatafs(Stringid,Stringsource,Stringtarget){returnnewLinkData().withId(Id.str(id)).withSource(Id.str(source)).withTarget(Id.str(target));}publicstaticvoidmain(String[]args)throwsException{Moment monday =Moment.fromYmdHm(2026,8,10,8,0);ProjectData data =newProjectData().withTasks(List.of(task("design", monday,2.0),task("build", monday,3.0),task("docs", monday,1.0))).withLinks(List.of(fs("l1","design","build"),fs("l2","design","docs")));Project project =newProject();
project.solve(data,newSolveOptions());// What if the docs take five days instead of one?
SchedulingChanges changes =newSchedulingChanges();
changes.tasks.update.add(newTaskUpdate(Id.str("docs")).withDuration(Patch.of(5.0)));PreviewOptions options =newPreviewOptions().withAnalytics(newAnalyticsOptions().withCriticalPath(true).withSlack(true));ScheduleResult result = project.preview(changes, options);Analytics analytics = result.analytics;System.out.println("critical: "+ analytics.criticalPath.tasks.stream().map(Id::toString).collect(Collectors.joining(", ")));for(TaskSlack s : analytics.slack){System.out.println(s.task +": total "+ s.total +" free "+ s.free);}}}
With five days of docs, the critical path moves to design, docs, and build now has two days of slack. The project itself doesn't change. For more on previewing, see Previewing changes.
Use Project#analyze when you already have dates, for example from your own database or a gantt chart, and want the critical path for those exact dates.
importjava.util.List;importorg.dhtmlx.plen.*;publicclassAnalyzeDates{staticTaskDatatask(Stringid,Momentstart,doubleduration){returnnewTaskData(Id.str(id)).withStartDate(start).withDuration(duration);}publicstaticvoidmain(String[]args)throwsException{Moment monday =Moment.fromYmdHm(2026,8,10,8,0);Moment thursday =Moment.fromYmdHm(2026,8,13,8,0);// "build" starts a day after "design" ends. A solve would pull it back to Wednesday.
ProjectData data =newProjectData().withTasks(List.of(task("design", monday,2.0),task("build", thursday,3.0))).withLinks(List.of(newLinkData().withId(Id.str("l1")).withSource(Id.str("design")).withTarget(Id.str("build"))));Project project =newProject();AnalyzeOptions options =newAnalyzeOptions().withAnalytics(newAnalyticsOptions().withCriticalPath(true).withSlack(true));ScheduleResult result = project.analyze(data, options);System.out.println("updates: "+ result.updates.size());Analytics analytics = result.analytics;for(Id id : analytics.criticalPath.tasks){System.out.println("critical task: "+ id);}for(TaskSlack s : analytics.slack){System.out.println(s.task +": total "+ s.total +" free "+ s.free);}}}
Only build is critical. design has one day of total and free slack, because build starts a day later than the link requires. Link l1 isn't critical: it isn't binding, and design isn't critical.
analyze never changes the project. With non-null data it validates and reads the snapshot but doesn't install it. Invalid data throws InputException, as described in Error handling. The result's updates list is empty, conflicts is empty, and baselineId is null.
Pass null as the data to analyze the dates the project holds now, including edits that haven't been solved yet.
importjava.util.List;importjava.util.stream.Collectors;importorg.dhtmlx.plen.*;publicclassAnalyzeCurrent{staticTaskDatatask(Stringid,Momentstart,doubleduration){returnnewTaskData(Id.str(id)).withStartDate(start).withDuration(duration);}staticLinkDatafs(Stringid,Stringsource,Stringtarget){returnnewLinkData().withId(Id.str(id)).withSource(Id.str(source)).withTarget(Id.str(target));}publicstaticvoidmain(String[]args)throwsException{Moment monday =Moment.fromYmdHm(2026,8,10,8,0);ProjectData data =newProjectData().withTasks(List.of(task("design", monday,2.0),task("build", monday,3.0),task("docs", monday,1.0))).withLinks(List.of(fs("l1","design","build"),fs("l2","design","docs")));Project project =newProject();
project.solve(data,newSolveOptions());AnalyzeOptions options =newAnalyzeOptions().withAnalytics(newAnalyticsOptions().withCriticalPath(true));// Solved and clean: the result names the baseline it read.
ScheduleResult clean = project.analyze(null, options);System.out.println("clean baseline: "+(clean.baselineId !=null));// Commit an edit without solving.
SchedulingChanges changes =newSchedulingChanges();
changes.tasks.update.add(newTaskUpdate(Id.str("docs")).withDuration(Patch.of(5.0)));
project.apply(changes);ScheduleResult dirty = project.analyze(null, options);System.out.println("dirty baseline: "+(dirty.baselineId !=null));System.out.println("critical: "+ dirty.analytics.criticalPath.tasks.stream().map(Id::toString).collect(Collectors.joining(", ")));}}
After the edit, the critical path is design, docs. analyze doesn't change the project's solved status, so the project still needs solve(null, ...) before preview works again. baselineId is set only when the project is solved and has no unsolved edits. Check it to tell whether the analytics describe the accepted baseline.
Check status first. Then read the lists you asked for. A flag you left off gives null for its list, even when the status is AVAILABLE. In the example above, slack was never requested, so dirty.analytics.slack is null.
importjava.util.List;importjava.util.stream.Collectors;importorg.dhtmlx.plen.*;publicclassReadResult{staticTaskDatatask(Stringid,Momentstart,doubleduration){returnnewTaskData(Id.str(id)).withStartDate(start).withDuration(duration);}publicstaticvoidmain(String[]args)throwsException{Moment monday =Moment.fromYmdHm(2026,8,10,8,0);ProjectData data =newProjectData().withConfig(newSchedulingConfig().withUseProgress(true)).withTasks(List.of(newTaskData(Id.str("phase")).withTaskType(TaskType.SUMMARY),task("spec", monday,1.0).withParent(Id.str("phase")).withProgress(1.0),task("review", monday,2.0).withParent(Id.str("phase")),task("release", monday,1.0))).withLinks(List.of(newLinkData().withId(Id.str("l1")).withSource(Id.str("review")).withTarget(Id.str("release"))));SolveOptions options =newSolveOptions().withAnalytics(newAnalyticsOptions().withCriticalPath(true).withSlack(true));ScheduleResult result =Engine.schedule(data, options);Analytics analytics = result.analytics;if(analytics.status ==AnalyticsStatus.INVALID){System.out.println(result.cycles.size()+" dependency cycle(s), no analytics");return;}System.out.println("critical: "+ analytics.criticalPath.tasks.stream().map(Id::toString).collect(Collectors.joining(", ")));for(TaskSlack s : analytics.slack){System.out.println(s.task +": total "+ s.total +" free "+ s.free);}}}
The output shows each rule from the concept section:
critical: review, phase, release
spec: total 0.0 free 0.0
review: total 0.0 free 0.0
release: total 0.0 free 0.0
phase is critical because its child review is. It has no slack entry, since summaries never get one.
spec is complete, so it reports zero slack. It still isn't on the critical path.
The critical task list follows the scheduling graph order, not input order. Look tasks up by id instead of relying on their position.
If the links form a cycle, the solve returns unchanged dates and lists each cycle in result.cycles. The analytics then come back as INVALID, with criticalPath and slack both null. Fix the cycle and run again. For the rest of the result, see Reading results.