Skip to content

Schedule Baseline Variance

Every contract schedule is signed off once as a baseline, and every month after that the project drifts away from it. The specific sub-problem this page solves is how a pipeline compares a current schedule update against the approved baseline to compute start, finish, and duration variance for each activity, rolls that variance up the Work Breakdown Structure, and flags material slippage deterministically — so that a slipped critical activity generates a defensible time-impact record instead of a subjective argument in a monthly progress meeting. Baseline variance is the arithmetic foundation of schedule-performance reporting and the numeric backbone of any change-order time-impact analysis: without it, delay claims rest on memory rather than on a signed baseline and a dated update. This layer sits inside Schedule Sync & Critical Path Integration and consumes the typed activity records produced by Primavera P6 export parsing and MS Project schedule parsing. It answers a narrower question than critical path delta detection, which tracks how the longest path re-routes between updates; here we measure how far each activity has moved from the commitment recorded in the baseline snapshot. This page targets Python automation builders, project controls engineers, and schedulers who need repeatable variance numbers under real-world schedule churn.

Prerequisites

Baseline variance sits downstream of schedule parsing and upstream of schedule-performance reporting and time-impact analysis. Before implementing the patterns below, you need:

  • Python 3.11+ with pydantic v2 for typed validation, plus the standard-library decimal, datetime, collections, dataclasses, and logging modules. Durations and variances are integer working-day counts; percent-complete is a Decimal, never a float, so weighted rollups stay exact.
  • Typed activity records from both schedules. Each activity must already be parsed into a record carrying an activity_id, timezone-aware start and finish datetimes, an is_critical flag, and a percent-complete value — the output contract of Primavera P6 export parsing (XER/XML) and MS Project schedule parsing (XML).
  • A baseline snapshot — the approved schedule, stored immutably and keyed by activity so it can be diffed against any later update. The baseline is a fixed reference; a rebaseline is a new snapshot, never an edit of the old one.
  • A WBS binding so leaf-activity variance can aggregate to a scope node. Activities bind to the same Work Breakdown Structure that drives WBS mapping strategies; the activity carries when work happens, the WBS node carries where that work sits in scope and budget.
  • An escalation path for slipped scope. Material and critical slippage hands off to fallback alert routing, and the resulting variance rows are persisted by schedule impact logging so the time-impact history is auditable.

The pipeline assumes both schedules have already been parsed into typed records against a shared project calendar; the work here is alignment, variance arithmetic, rollup, and routing — not XER or XML parsing.

Architecture: alignment, variance, rollup, and routing

Baseline variance is not a subtraction; it is an alignment problem wearing a subtraction’s clothes. Two schedules of the same project rarely contain the same activity set: the current update has added activities that did not exist at baseline, dropped activities that were deleted or merged, and — on a badly managed file — reassigned activity IDs entirely. If you zip the two lists together by row order or match them by activity name, you compute variance against the wrong pair and every downstream number is quietly wrong. So the first stage aligns the two snapshots by a stable activity key, computes variance only for genuinely matched pairs, and reports the symmetric difference (added and deleted activities) as facts rather than null-filling them to zero. The second stage rolls the per-activity variance up the WBS by the worst member, not the average, because a single critical activity fifteen days late must not be diluted by ten on-track neighbours. The routing decision then uses the site-canonical confidence bands: an alignment match score of 0.92 or above auto-publishes the variance report, 0.750.92 flags the rollup for a planner’s review, and below 0.75 the record is quarantined rather than reported against a guessed pairing.

Schedule baseline variance pipeline A top-down data-flow diagram. A baseline snapshot of approved start and finish dates and a current schedule update carrying the data date, actuals, and forecast both feed an align-by-activity_id stage that uses a stable key rather than name or order. Activities that appear only in one schedule branch left as added or deleted and are flagged and logged, never null-filled. Matched pairs flow down into per-activity variance, computing start, finish, and duration variance as integer working days with percent complete as a Decimal. That variance rolls bottom-up to each WBS node, summing leaf variance to the parent by worst member. A severity and confidence decision diamond then branches three ways: a match confidence at or above 0.92 with an on-track slip publishes the variance report to the schedule impact log; a confidence between 0.75 and 0.92 goes to planner review on the right; and a material slip escalates to the fallback alert router on the left. Baseline snapshot approved start / finish Current schedule update data date · actuals · forecast Align by activity_id stable key, not name / order Added / deleted flag, never null-fill Per-activity variance start / finish / duration int days · % complete Decimal Bottom-up WBS rollup worst leaf variance → parent Severity / confidence band? 0.75 – 0.92 Planner review confirm pairing material slip Escalate fallback alert router ≥ 0.92 · on-track Publish variance report → schedule impact log
The pipeline aligns before it subtracts: only genuinely matched activities produce variance, added and deleted activities are flagged rather than zeroed, and the rolled-up result routes by confidence first, then by slip severity, so a low-confidence pairing never masquerades as a clean report.
Stage Input Output Error branch
Align by activity_id Baseline + current activity lists Matched pairs + added/deleted sets Duplicate activity_id → raise before diffing
Per-activity variance One matched baseline/current pair Typed BaselineVarianceRow, int-day variances Finish precedes start → validation error
WBS rollup Leaf variance rows Per-node worst variance + weighted % complete Empty node → skipped, logged
Severity classify Worst finish variance (int days) on_track / minor / material / critical
Route Rollup + match confidence Publish / planner review / escalate / quarantine < 0.75 confidence → quarantine

Step-by-step implementation

Step 1 — Define the variance row contract

The unit of the whole layer is one activity’s variance, so model it as a frozen, typed contract. Variances are integer working-day counts — a finish that slides across a weekend by two calendar days is zero working-day slip — while percent-complete is a Decimal bounded to 0100, because a duration-weighted rollup of percent-complete must stay exact to the cent of progress. The WBS element is regex-constrained to PROJ-NNN-DIV-NN and the discipline is a closed Literal, so a malformed binding is rejected at construction time rather than corrupting a rollup bucket. All four dates are timezone-aware; a naive timestamp is rejected because variance math across project sites in different zones would otherwise drift by a day.

from __future__ import annotations

import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

logger = logging.getLogger("schedule.variance")

Discipline = Literal["ARCH", "STR", "MEP", "CIV", "ELEC", "PLMB"]
Severity = Literal["on_track", "minor_slip", "material_slip", "critical_slip"]

WBS_PATTERN = r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$"


def _severity_for(slip_days: int) -> Severity:
    """Map the worst finish/start slip (working days) onto a severity band."""
    if slip_days <= 0:
        return "on_track"
    if slip_days <= 5:
        return "minor_slip"
    if slip_days <= 15:
        return "material_slip"
    return "critical_slip"


class BaselineVarianceRow(BaseModel):
    """One activity's variance between the approved baseline and the current update."""

    model_config = ConfigDict(frozen=True)

    activity_id: str = Field(min_length=1, max_length=40)
    wbs_node: str = Field(pattern=WBS_PATTERN)
    discipline: Discipline
    is_critical: bool

    baseline_start: datetime
    baseline_finish: datetime
    current_start: datetime
    current_finish: datetime

    start_variance_days: int       # current_start - baseline_start, working days
    finish_variance_days: int      # current_finish - baseline_finish, working days
    duration_variance_days: int    # current duration - baseline duration, working days
    percent_complete: Decimal = Field(ge=0, le=100, decimal_places=2)

    match_confidence: Decimal = Field(ge=0, le=1, decimal_places=2)

    @field_validator(
        "baseline_start", "baseline_finish", "current_start", "current_finish"
    )
    @classmethod
    def require_tz_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("schedule dates must be timezone-aware (ISO 8601)")
        return v.astimezone(timezone.utc)

    @model_validator(mode="after")
    def finish_after_start(self) -> "BaselineVarianceRow":
        if self.current_finish < self.current_start:
            raise ValueError("current_finish precedes current_start")
        if self.baseline_finish < self.baseline_start:
            raise ValueError("baseline_finish precedes baseline_start")
        return self

    @property
    def severity(self) -> Severity:
        # Positive variance is a slip (later than baseline); the worse of the two
        # boundary movements sets the band so an early start can't mask a late finish.
        return _severity_for(max(self.start_variance_days, self.finish_variance_days))

Step 2 — Align the two snapshots by a stable key

Alignment is where most variance engines quietly go wrong. Two schedules of the same project are two sets of activities, not two parallel lists, so the only safe join is on a stable identity — the activity_id minted at baseline and preserved through every update. Index each side into a dictionary, compute the intersection to get matched pairs, and compute the symmetric difference to get activities added in the current update and deleted since the baseline. Neither difference set is ever null-filled to a zero-variance row: an added activity has no baseline to vary against, and a deleted one has no current status, so both are reported as facts for a planner to reconcile. Variance itself is measured in working days on the project calendar, because construction schedules are worked five (or six) days a week and a calendar-day subtraction would over-report every slip that spans a weekend.

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal


@dataclass(frozen=True)
class ActivitySnapshot:
    """A typed activity as parsed from a P6 (XER/XML) or MS Project (XML) export."""

    activity_id: str
    wbs_node: str
    discipline: Discipline
    is_critical: bool
    start: datetime
    finish: datetime
    percent_complete: Decimal


def net_working_days(start: date, end: date, holidays: frozenset[date]) -> int:
    """Signed working-day count from start to end (Mon–Fri minus holidays).

    Construction slip is measured in working days, not calendar days: a finish
    that moves from Friday to the following Monday is one working day late, not
    three. A later `end` yields a positive count (a slip); an earlier one negative.
    """
    if start == end:
        return 0
    step = 1 if end > start else -1
    lo, hi = (start, end) if end > start else (end, start)
    days = 0
    cursor = lo
    while cursor < hi:
        cursor += timedelta(days=1)
        if cursor.weekday() < 5 and cursor not in holidays:
            days += 1
    return days * step


def align_snapshots(
    baseline: list[ActivitySnapshot],
    current: list[ActivitySnapshot],
) -> tuple[list[tuple[ActivitySnapshot, ActivitySnapshot]], set[str], set[str]]:
    """Align two activity sets by the stable activity_id key.

    Returns matched (baseline, current) pairs plus the symmetric difference:
    ids added in the current update and ids deleted since the baseline. An
    unmatched activity is a reportable fact, never a silently zeroed row.
    """
    base_by_id = {a.activity_id: a for a in baseline}
    curr_by_id = {a.activity_id: a for a in current}
    if len(base_by_id) != len(baseline):
        raise ValueError("duplicate activity_id in baseline snapshot")
    if len(curr_by_id) != len(current):
        raise ValueError("duplicate activity_id in current update")

    matched = base_by_id.keys() & curr_by_id.keys()
    added = curr_by_id.keys() - base_by_id.keys()
    deleted = base_by_id.keys() - curr_by_id.keys()

    pairs = [(base_by_id[i], curr_by_id[i]) for i in sorted(matched)]
    return pairs, set(added), set(deleted)


def build_variance_row(
    base: ActivitySnapshot,
    curr: ActivitySnapshot,
    holidays: frozenset[date],
    match_confidence: Decimal = Decimal("1.00"),
) -> BaselineVarianceRow:
    """Compute the typed variance row for one matched activity pair."""
    base_dur = net_working_days(base.start.date(), base.finish.date(), holidays)
    curr_dur = net_working_days(curr.start.date(), curr.finish.date(), holidays)
    return BaselineVarianceRow(
        activity_id=curr.activity_id,
        wbs_node=curr.wbs_node,
        discipline=curr.discipline,
        is_critical=curr.is_critical,
        baseline_start=base.start,
        baseline_finish=base.finish,
        current_start=curr.start,
        current_finish=curr.finish,
        start_variance_days=net_working_days(base.start.date(), curr.start.date(), holidays),
        finish_variance_days=net_working_days(base.finish.date(), curr.finish.date(), holidays),
        duration_variance_days=curr_dur - base_dur,
        percent_complete=curr.percent_complete,
        match_confidence=match_confidence,
    )

Step 3 — Roll variance up the WBS bottom-up

A per-activity table is too granular for a progress report; the audience reads variance by scope node. Rolling up correctly means two different aggregations at once. Slip rolls up by the worst member — the largest finish variance among the node’s activities — because a WBS element is late if any of its activities is late, and averaging would hide a critical slip behind on-track siblings. Percent-complete, by contrast, rolls up as a duration-weighted mean, so a thirty-day pour is not outvoted by a one-day inspection that happens to be finished. The rollup also carries the worst variance among only the critical activities, because that number, not the overall worst, is what feeds time-impact analysis.

from collections import defaultdict


class WBSRollup(BaseModel):
    """Aggregated baseline variance for a single WBS node."""

    model_config = ConfigDict(frozen=True)

    wbs_node: str = Field(pattern=WBS_PATTERN)
    activity_count: int = Field(gt=0)
    worst_finish_variance_days: int
    critical_finish_variance_days: int      # worst among is_critical members, else 0
    weighted_percent_complete: Decimal = Field(ge=0, le=100, decimal_places=2)
    severity: Severity


def roll_up_by_wbs(
    rows: list[BaselineVarianceRow],
    holidays: frozenset[date],
) -> dict[str, WBSRollup]:
    """Aggregate leaf-activity variance up to each WBS node.

    Slip rolls up by the worst member so one critical activity 20 days late is
    not diluted by ten on-track neighbours. Percent complete rolls up as a
    duration-weighted mean so a long activity is not outvoted by a short one.
    """
    grouped: dict[str, list[BaselineVarianceRow]] = defaultdict(list)
    for row in rows:
        grouped[row.wbs_node].append(row)

    rollups: dict[str, WBSRollup] = {}
    for node, members in grouped.items():
        worst = max(r.finish_variance_days for r in members)
        crit = [r.finish_variance_days for r in members if r.is_critical]
        crit_worst = max(crit) if crit else 0

        # Weight percent complete by baseline working-day duration (floor 1 day).
        weights = [
            net_working_days(r.baseline_start.date(), r.baseline_finish.date(), holidays) or 1
            for r in members
        ]
        weighted = sum(r.percent_complete * w for r, w in zip(members, weights))
        wpc = (weighted / sum(weights)).quantize(Decimal("0.01"))

        rollups[node] = WBSRollup(
            wbs_node=node,
            activity_count=len(members),
            worst_finish_variance_days=worst,
            critical_finish_variance_days=crit_worst,
            weighted_percent_complete=wpc,
            severity=_severity_for(max(worst, crit_worst)),
        )
    return rollups

Step 4 — Route each rollup by confidence, then by severity

The final stage decides where a rolled-up node goes, and it gates on two things in a fixed order. Alignment confidence comes first: if the activity pairing that produced the variance is itself untrustworthy — because IDs churned or a fuzzy re-match was needed — the number is quarantined below 0.75 and sent to a planner between 0.75 and 0.92, exactly the site-canonical bands used everywhere a match decision is made. Only once alignment is trusted does slip severity decide the destination: a material_slip or critical_slip escalates to the fallback alert router and seeds a time-impact record, while an on-track or minor-slip node publishes straight to the variance report. Routing returns an action rather than performing side effects, so it stays pure and a broker retry re-derives the same verdict.

AUTO_ROUTE = Decimal("0.92")     # >= 0.92 alignment -> trust the pairing
REVIEW_FLOOR = Decimal("0.75")   # 0.75–0.92 -> planner review; < 0.75 -> quarantine

RouteAction = Literal["publish_report", "planner_review", "escalate_alert", "quarantine"]


def route_rollup(rollup: WBSRollup, match_confidence: Decimal) -> RouteAction:
    """Route a rolled-up WBS variance by alignment confidence, then slip severity."""
    if match_confidence < REVIEW_FLOOR:
        logger.warning(
            "variance.quarantine wbs=%s conf=%s", rollup.wbs_node, match_confidence
        )
        return "quarantine"
    if match_confidence < AUTO_ROUTE:
        return "planner_review"

    # Alignment is trusted; the slip magnitude now decides the destination.
    if rollup.severity in ("material_slip", "critical_slip"):
        logger.info(
            "variance.escalate wbs=%s worst=%d critical=%d",
            rollup.wbs_node,
            rollup.worst_finish_variance_days,
            rollup.critical_finish_variance_days,
        )
        return "escalate_alert"
    return "publish_report"

The escalate branch is the hand-off point to change-order time-impact analysis: a material or critical slip on a critical activity is precisely the signal that a delay may be compensable, so the escalated rows are persisted by schedule impact logging with the baseline and current dates intact for the record.

Schema and configuration reference

Field Type / pattern Rule Why it matters
activity_id str 1–40 chars Stable across updates The only safe alignment key
wbs_node PROJ-NNN-DIV-NN Must resolve in the master WBS Binds variance to a budgeted scope element
start_variance_days int Working days, signed Positive = later than baseline (a slip)
finish_variance_days int Working days, signed Drives severity and time-impact analysis
duration_variance_days int Current minus baseline duration Separates a delay from a stretch
percent_complete Decimal, 2 dp, 0–100 No floats Keeps the duration-weighted rollup exact
is_critical bool From the parsed schedule Selects the critical-only rollup number
match_confidence Decimal, 2 dp, 0–1 From alignment Gates the routing decision
Severity band Worst finish slip (working days) Meaning Route when confident
on_track <= 0 At or ahead of baseline Publish report
minor_slip 15 Within a normal update swing Publish report
material_slip 615 Reportable; time-impact candidate Escalate alert
critical_slip > 15 Serious; likely change-order driver Escalate alert

Routing constants are site-canonical and appear identically wherever a match decision runs: AUTO_ROUTE = 0.92 and REVIEW_FLOOR = 0.75. The severity thresholds (5 and 15 working days) are per-project configurable and should be pinned in project controls configuration, not hard-coded per run.

Verification and testing

Alignment, variance arithmetic, and rollup are pure functions, so assert them against known inputs — including the weekend that the working-day count must skip and the added activity the alignment must surface. The dates below were chosen so the expected working-day counts are checkable by hand.

from datetime import datetime, timezone
from decimal import Decimal

NO_HOLIDAYS: frozenset = frozenset()


def _dt(y: int, m: int, d: int) -> datetime:
    return datetime(y, m, d, 12, 0, tzinfo=timezone.utc)


def _snap(activity_id, start, finish, *, wbs="PROJ-014-STR-02", crit=True, pc="0.00"):
    return ActivitySnapshot(
        activity_id=activity_id, wbs_node=wbs, discipline="STR",
        is_critical=crit, start=start, finish=finish, percent_complete=Decimal(pc),
    )


def test_alignment_reports_added_and_deleted():
    base = [_snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 6))]
    curr = [
        _snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 6)),
        _snap("A1010", _dt(2026, 3, 9), _dt(2026, 3, 13)),
    ]
    pairs, added, deleted = align_snapshots(base, curr)
    assert {p[0].activity_id for p in pairs} == {"A1000"}
    assert added == {"A1010"}
    assert deleted == set()


def test_finish_slip_counts_working_days_only():
    base = _snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 6))    # Mon–Fri baseline
    curr = _snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 10))   # finish slips to next Tue
    row = build_variance_row(base, curr, NO_HOLIDAYS)
    assert row.finish_variance_days == 2      # Mon + Tue; the weekend is excluded
    assert row.duration_variance_days == 2
    assert row.severity == "minor_slip"


def test_rollup_takes_worst_member_not_average():
    rows = [
        build_variance_row(
            _snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)),
            _snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)), NO_HOLIDAYS,
        ),
        build_variance_row(
            _snap("A2", _dt(2026, 3, 2), _dt(2026, 3, 6)),
            _snap("A2", _dt(2026, 3, 2), _dt(2026, 3, 27)), NO_HOLIDAYS,
        ),
    ]
    rollup = roll_up_by_wbs(rows, NO_HOLIDAYS)["PROJ-014-STR-02"]
    assert rollup.worst_finish_variance_days == 15
    assert rollup.severity == "material_slip"


def test_low_confidence_quarantines():
    row = build_variance_row(
        _snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)),
        _snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)),
        NO_HOLIDAYS, match_confidence=Decimal("0.60"),
    )
    rollup = roll_up_by_wbs([row], NO_HOLIDAYS)["PROJ-014-STR-02"]
    assert route_rollup(rollup, Decimal("0.60")) == "quarantine"

Run the suite with pytest -q. For a fast manual check, serialize a row with model_dump_json() and confirm the three variance integers and the Decimal percent-complete round-trip through BaselineVarianceRow.model_validate_json() unchanged.

Troubleshooting

  • Variance explodes on activities added between baseline and current. The current update introduced activities that never existed at baseline, and a name- or order-based join paired them against unrelated baseline rows. Align by activity_id and route the added set to review instead: an activity with no baseline has no variance to compute, and forcing one produces a fictional slip. The symmetric-difference set from align_snapshots is the audit record of exactly which activities changed shape.
  • Every activity reads as “on-time” after a rebaseline. Someone recomputed variance against the new baseline, which is by definition equal to the current schedule, so all variance collapses to zero. A rebaseline is a fresh, separately stored snapshot; keep the original approved baseline immutable and compute against the contractually approved snapshot for claim math, comparing to the rebaseline only for forward-looking management.
  • Slip is over-reported by exactly the number of weekends in the span. The variance was computed in calendar days rather than working days, so a finish that slid from Friday to Monday shows as three days late instead of one. Run every boundary difference through net_working_days against the project calendar, and load the same holiday set that the schedule tool uses so a bank holiday does not count as a working slip.
  • A duration change appears where only the calendar changed. The current update runs a six-day work week while the baseline ran five, so identical start and finish dates yield different working-day durations. Detect a calendar-definition change between snapshots and record it explicitly; report the calendar delta separately from a true logic or progress delay rather than letting it masquerade as duration variance.
  • Whole WBS branches quarantine after an ID renumber. A scheduler renumbered activities (a P6 “renumber activity IDs” pass), so the current IDs no longer match the baseline and alignment confidence collapses. Recover the mapping from the tool’s old-to-new ID crosswalk if one exists, or fuzzy-match on wbs_node plus name and stamp the resulting match_confidence so the re-paired rows land in the review band rather than auto-publishing against a guessed pairing.

Frequently Asked Questions

Why align by activity_id instead of activity name?

Activity names are edited constantly — a planner reworks wording, fixes a typo, or renames a phase — while the activity_id is minted once and preserved across updates. Aligning by name pairs a renamed activity against nothing (reported as a false delete-plus-add) or, worse, against a different activity that happens to share a name. The stable id is the only join that survives normal schedule maintenance, which is why the symmetric difference is computed on ids, not names.

Why measure variance in working days rather than calendar days?

Construction crews work a five- or six-day week, so slip is a working-day quantity. A finish that moves from Friday to the following Monday is one working day late, but a calendar-day subtraction reports three, over-stating every delay that spans a weekend and corrupting any time-impact number built on it. Running each boundary difference through a working-day count against the project calendar — the same calendar and holiday set the schedule tool uses — keeps the variance honest.

Why roll slip up by the worst member but percent complete by a weighted mean?

They answer different questions. A WBS node is late if any of its activities is late, so slip rolls up by the maximum finish variance — averaging would hide one critical activity fifteen days behind behind ten on-track siblings. Percent complete, by contrast, is a measure of how much of the node’s work is done, so it rolls up as a duration-weighted mean, preventing a one-day inspection from outvoting a thirty-day pour.

How do the confidence bands apply to baseline variance?

They gate on the trustworthiness of the activity pairing before any number is published. When alignment is clean the match confidence is 1.0 and the rollup auto-publishes. When ids churned and a fuzzy re-match was needed, a combined score of 0.750.92 sends the rollup to a planner, and below 0.75 it is quarantined — because a variance computed against a guessed pairing is worse than no variance at all.

How is a rebaseline handled without losing the claim baseline?

A rebaseline is stored as a new, separate snapshot; the original approved baseline is never edited. Claim and time-impact math run against the contractually approved baseline so a delay stays visible, while day-to-day management can compare the current update to the latest rebaseline for a forward-looking view. Keeping both immutable means an auditor can reproduce either comparison from the stored snapshots on any date.

← Back to Schedule Sync & Critical Path Integration