Dynamic Project Management: Syncing HubSpot Tasks When Deadlines Shift

Dynamic Project Management: Syncing HubSpot Tasks When Deadlines Shift

Ever felt that sinking feeling when a major project deadline shifts in HubSpot? You know the drill: the project due date moves by a week, and suddenly, you're staring down a list of dozens, maybe even hundreds, of individual tasks and subtasks, all stubbornly clinging to their old dates. Manually updating each one? That’s not just tedious; it’s a recipe for operational chaos, missed steps, and a whole lot of wasted time.

Here at ESHOPMAN, we talk to a lot of HubSpot users, RevOps leaders, and marketers who are juggling complex projects, often tied to e-commerce initiatives or product launches. This challenge of keeping project timelines aligned is a recurring headache. So, when a brilliant solution popped up in the HubSpot Community, we knew we had to share it. It’s a fantastic example of leveraging HubSpot’s power, specifically Operations Hub, to tackle a very real-world problem.

The Project Deadline Dilemma: Why Manual Updates Fail

The original poster in the HubSpot Community discussion perfectly articulated the core issue: when a parent Project's deadline moves (say, due to a client scope change or an unexpected delay), all its associated child Tasks and Subtasks remain static. This creates a massive disconnect. Your project might be pushed back a week, but your team's tasks are still telling them to hit deadlines that are now completely irrelevant. This broken workflow not only causes confusion but can also lead to critical tasks being forgotten or mismanaged.

Think about a product launch calendar, a website redesign, or even the setup for a new e-commerce storefront. Each of these involves numerous interconnected steps. If the launch date shifts, every single dependency needs to adjust. Without automation, you’re looking at:

  • Massive Manual Effort: Updating countless dates, one by one.
  • Increased Error Rate: The more manual updates, the higher the chance of human error.
  • Stalled Progress: Teams waiting for updated dates, leading to bottlenecks.
  • Loss of Trust: Broken timelines erode confidence in project management.

A Custom-Coded Solution for Dynamic Date Shifting

Fortunately, a clever community member shared their custom-coded workflow solution designed to eliminate this friction. The goal? To ensure that tasks shift dynamically alongside their parent project, all while maintaining work durations, accounting for non-working days, and even adapting for specific holidays.

Key Capabilities That Make This Solution Shine:

  • Relative Date Shifting: This is the core magic. The workflow calculates the exact day difference between the old project deadline and the new one. Then, it shifts all associated tasks by precisely that same number of days. No more guessing or manual calculations!

  • Duration & Start Date Preservation: It’s not just about the due date. The solution intelligently shifts both the hs_start_date and hs_timestamp (the due date) for each task. This means the original planned duration of each task remains intact, preserving the integrity of your work breakdown.

  • Smart Weekend & Holiday Handling: This is a game-changer for realistic project planning. The code ensures that:

    • Due dates that would otherwise land on a weekend or a bank holiday are automatically pulled back to the preceding Friday.
    • Start dates are pushed forward to the next business day (with safety guards to ensure a start date never ends up after its due date).
    • It even fetches live UK Bank Holidays via the official GOV.UK JSON API, making it incredibly robust for regional teams. This concept can easily be adapted for other regions with public holiday APIs.
  • Completed Task Protection: No one wants their historical records messed with. The workflow intelligently skips any tasks marked as COMPLETED, ensuring that your past project data remains accurate and untouched.

  • Safety & Logging: For peace of mind during implementation and ongoing use, the solution includes a DEBUG_ASSOCIATIONS flag for dry runs. This lets you test the shifts without actually making changes. Plus, detailed logs output all shifted dates, and it checks for zero-day differences to prevent unnecessary processing.

How to Implement This in Your HubSpot Portal

This powerful solution leverages HubSpot's Operations Hub custom code actions. Here’s a breakdown of how to set it up, based on the community member's instructions:

  1. Workflow Enrollment Trigger: Create a Project-based workflow. Set the enrollment trigger to fire whenever your Project’s target due date (hs_target_due_date) is updated. This ensures the automation kicks in exactly when needed.

  2. Custom Properties Needed: You'll need a few custom date properties on your Project object to make this work:

    • hs_target_due_date (This is your main Project Due Date field)
    • last_processed_due_date (A custom date field on the Project. This acts as a baseline to calculate the day differences. Make sure to seed this field initially with the project's current due date.)
    • hs_start_date (If you're tracking project start dates, ensure this is also available.)
  3. Workflow Action: Within your workflow, add a Custom Code Action. The provided code is written in Node.js (compatible with 18.x or 20.x).

  4. Environment Secrets: You'll need to pass your Private App Token to the custom code action. Name the secret Project_editor. This token needs CRM read/write permissions for objects and associations to allow the code to update your tasks.

Here’s the full Node.js code provided by the community expert. Remember to adapt it carefully to your specific HubSpot custom object schemas if you're using something other than standard Projects and Tasks, or if your association types differ.

const axios = require("axios");

const HUBSPOT_BASE_URL = "https://api.hubapi.com";
const PROJECT_OBJECT_TYPE = "0-970";
const TASK_OBJECT_TYPE = "0-27";
const BATCH_SIZE = 100;
const BANK_HOLIDAY_DIVISION = "england-and-wales"; // Options: "england-and-wales", "scotland", "northern-ireland"

// Set true on initial testing to inspect association shapes without making changes.
const DEBUG_ASSOCIATI
// Set true to isolate top-level tasks and prevent duplicate cascading updates to subtasks.
const EXCLUDE_SUBTASKS = true;

exports.main = async (event, callback) => {
  const token = process.env.Project_editor;
  const projectId = event.object.objectId;
  const debug = [];

  if (!token) throw new Error("Missing Project_editor secret.");

  const headers = {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  };

  try {
    const currentDueRaw = event.inputFields.hs_target_due_date;
    const lastProcessedRaw = event.inputFields.last_processed_due_date;
    const projectStartRaw = event.inputFields.hs_start_date;

    if (!currentDueRaw) return done(callback, "hs_target_due_date was empty.", 0, 0, debug);
    if (!lastProcessedRaw)
      return done(callback, "last_processed_due_date is blank. Seed it first.", 0, 0, debug);

    const currentDue = parseHubSpotDate(currentDueRaw);
    const baselineDue = parseHubSpotDate(lastProcessedRaw);
    const projectStart = parseHubSpotDate(projectStartRaw);

    if (!currentDue || !baselineDue) {
      debug.push(`RAW currentDue=${String(currentDueRaw)} lastProcessed=${String(lastProcessedRaw)}`);
      return done(callback, "Project due date input could not be parsed.", 0, 0, debug);
    }

    debug.push(`PROJECT window: start=${projectStart ? formatDateUtc(projectStart) : "none"} due=${formatDateUtc(currentDue)}`);
    debug.push(`BASELINE: ${formatDateUtc(baselineDue)} -> CURRENT: ${formatDateUtc(currentDue)}`);

    const diffDays = dateDiffInWholeDays(baselineDue, currentDue);
    debug.push(`DIFF: ${diffDays} days`);

    if (diffDays === 0) return done(callback, "No day difference detected.", 0, 0, debug);

    const holidaySet = await getBankHolidaySet(BANK_HOLIDAY_DIVISION);
    debug.push(`HOLIDAYS loaded: ${holidaySet.size} dates`);

    const assocRows = await getAssociatedTaskRows(projectId, headers);
    debug.push(`ASSOCIATIONS returned ${assocRows.length} rows`);

    let taskIds = assocRows.map((r) => String(r.toObjectId)).filter(Boolean);
    taskIds = [...new Set(taskIds)];
    debug.push(`UNIQUE task ids: ${taskIds.length}`);

    if (!taskIds.length) return done(callback, "No associated tasks found.", diffDays, 0, debug);

    // Identify subtasks by checking each task for parent-task associations.
    let subtaskIds = new Set();
    if (EXCLUDE_SUBTASKS) {
      subtaskIds = await identifySubtasks(taskIds, headers, debug);
      taskIds = taskIds.filter((id) => !subtaskIds.has(id));
      debug.push(`TOP-LEVEL tasks after filter: ${taskIds.length}`);
    }

    if (!taskIds.length) return done(callback, "No top-level tasks to update.", diffDays, 0, debug);

    const tasks = await batchReadTasks(taskIds, headers);
    debug.push(`READ ${tasks.length} task records`);

    const { updates, logs } = shiftChildren({
      children: tasks,
      diffDays,
      holidaySet,
      parentStart: projectStart,
      parentDue: currentDue,
      dueProp: "hs_timestamp",
      startProp: "hs_start_date",
      nameProp: "hs_task_subject",
      parentLabel: "project",
    });

    debug.push(...logs);

    if (DEBUG_ASSOCIATIONS) {
      return done(
        callback,
        "DEBUG MODE - no writes performed. Review task_log.",
        diffDays,
        updates.length,
        debug
      );
    }

    if (!updates.length) return done(callback, "No tasks needed updating.", diffDays, 0, debug);

    await batchUpdateTasks(updates, headers);
    debug.push(`WROTE ${updates.length} task updates`);

    return done(callback, "Success", diffDays, updates.length, debug);
  } catch (err) {
    console.error(err.response?.data || err.message);
    debug.push(`ERROR: ${JSON.stringify(err.response?.data || err.message)}`);
    throw err;
  }
};

function done(callback, status, diffDays, count, debug) {
  callback({
    outputFields: {
      status,
      days_shifted: String(diffDays),
      tasks_updated: String(count),
      task_log: debug.join("
").slice(0, 65000),
    },
  });
}

function shiftChildren({
  children,
  diffDays,
  holidaySet,
  parentStart,
  parentDue,
  dueProp,
  startProp,
  nameProp,
  parentLabel,
}) {
  const updates = [];
  const logs = [];

  for (const child of children) {
    const name = child.properties?.[nameProp] || `Record ${child.id}`;
    const status = child.properties?.hs_task_status;

    if (status === "COMPLETED") {
      logs.push(`[SKIP] ${name} | already completed`);
      continue;
    }

    const dueRaw = child.properties?.[dueProp];
    const startRaw = child.properties?.[startProp];

    if (!dueRaw && !startRaw) {
      logs.push(`[SKIP] ${name} | no start or due date`);
      continue;
    }

    const origDue = dueRaw ? parseHubSpotDate(dueRaw) : null;
    const origStart = startRaw ? parseHubSpotDate(startRaw) : null;

    if (dueRaw && !origDue) {
      logs.push(`[SKIP] ${name} | unparseable due: ${String(dueRaw)}`);
      continue;
    }
    if (startRaw && !origStart) {
      logs.push(`[SKIP] ${name} | unparseable start: ${String(startRaw)}`);
      continue;
    }

    const trace = [];
    const props = {};

    // ---- DUE DATE: Shift & pull BACK off weekend/holiday ----
    let finalDue = null;
    if (origDue) {
      const shifted = addDays(origDue, diffDays);
      const adj = adjustBack(shifted, holidaySet);
      finalDue = adj.date;
      trace.push(
        `due ${formatDateUtc(origDue)} +${diffDays}d = ${formatDateUtc(shifted)}` +
          (adj.reasons.length ? ` -> back to ${formatDateUtc(finalDue)} (${adj.reasons.join("+")})` : "")
      );

      // Clamp to parent due
      if (parentDue && finalDue.getTime() > parentDue.getTime()) {
        const clamped = adjustBack(new Date(parentDue.getTime()), holidaySet);
        trace.push(`CLAMP due -> ${parentLabel} due ${formatDateUtc(clamped.date)}`);
        finalDue = clamped.date;
      }
    }

    // ---- START DATE: Shift & push FORWARD off weekend/holiday ----
    let finalStart = null;
    if (origStart) {
      const shifted = addDays(origStart, diffDays);
      const adj = adjustForward(shifted, holidaySet);
      finalStart = adj.date;
      trace.push(
        `start ${formatDateUtc(origStart)} +${diffDays}d = ${formatDateUtc(shifted)}` +
          (adj.reasons.length ? ` -> fwd to ${formatDateUtc(finalStart)} (${adj.reasons.join("+")})` : "")
      );

      // Clamp to parent start
      if (parentStart && finalStart.getTime() < parentStart.getTime()) {
        const clamped = adjustForward(new Date(parentStart.getTime()), holidaySet);
        trace.push(`CLAMP start -> ${parentLabel} start ${formatDateUtc(clamped.date)}`);
        finalStart = clamped.date;
      }
    }

    // Zero-length or inverted check guard
    let flagged = false;
    if (finalStart && finalDue && finalStart.getTime() > finalDue.getTime()) {
      trace.push(
        `*** FLAG: start ${formatDateUtc(finalStart)} is AFTER due ${formatDateUtc(finalDue)} - collapsing start onto due ***`
      );
      finalStart = new Date(finalDue.getTime());
      flagged = true;
    } else if (finalStart && finalDue && finalStart.getTime() === finalDue.getTime()) {
      trace.push(`*** FLAG: zero-length task on ${formatDateUtc(finalDue)} ***`);
      flagged = true;
    }

    if (finalDue) props[dueProp] = finalDue.getTime().toString();
    if (finalStart) props[startProp] = finalStart.getTime().toString();

    if (!Object.keys(props).length) {
      logs.push(`[SKIP] ${name} | nothing to write`);
      continue;
    }

    let duration = "";
    if (origStart && origDue && finalStart && finalDue) {
      const before = dateDiffInWholeDays(origStart, origDue);
      const after = dateDiffInWholeDays(finalStart, finalDue);
      duration = ` | duration ${before}d -> ${after}d`;
    }

    updates.push({ id: child.id, properties: props });
    logs.push(`${flagged ? "[FLAG]" : "[OK]"} ${name} | ${trace.join(" | ")}${duration}`);
  }

  return { updates, logs };
}

function parseHubSpotDate(value) {
  if (!value) return null;
  const s = String(value);
  if (/^\d+$/.test(s)) {
    const d = new Date(Number(s));
    return isNaN(d.getTime()) ? null : d;
  }
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
    const d = new Date(`${s}T00:00:00.000Z`);
    return isNaN(d.getTime()) ? null : d;
  }
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}

function addDays(date, days) {
  const d = new Date(date.getTime());
  d.setUTCDate(d.getUTCDate() + days);
  return d;
}

function dateDiffInWholeDays(a, b) {
  const aU = Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), a.getUTCDate());
  const bU = Date.UTC(b.getUTCFullYear(), b.getUTCMonth(), b.getUTCDate());
  return Math.round((bU - aU) / 86400000);
}

function formatDateUtc(d) {
  return d.toISOString().slice(0, 10);
}

function isWeekend(d) {
  const day = d.getUTCDay();
  return day === 0 || day === 6;
}

function isBankHoliday(d, set) {
  return set.has(formatDateUtc(d));
}

function adjustBack(date, holidaySet) {
  const d = new Date(date.getTime());
  const reas
  let guard = 0;
  while ((isWeekend(d) || isBankHoliday(d, holidaySet)) && guard++ < 30) {
    if (isWeekend(d) && !reasons.includes("weekend")) reasons.push("weekend");
    if (isBankHoliday(d, holidaySet) && !reasons.includes("bank holiday")) reasons.push("bank holiday");
    d.setUTCDate(d.getUTCDate() - 1);
  }
  return { date: d, reasons };
}

function adjustForward(date, holidaySet) {
  const d = new Date(date.getTime());
  const reas
  let guard = 0;
  while ((isWeekend(d) || isBankHoliday(d, holidaySet)) && guard++ < 30) {
    if (isWeekend(d) && !reasons.includes("weekend")) reasons.push("weekend");
    if (isBankHoliday(d, holidaySet) && !reasons.includes("bank holiday")) reasons.push("bank holiday");
    d.setUTCDate(d.getUTCDate() + 1);
  }
  return { date: d, reasons };
}

async function getBankHolidaySet(division) {
  const res = await axios.get("https://www.gov.uk/bank-holidays.json");
  const events = res.data?.[division]?.events || [];
  const set = new Set();
  for (const e of events) if (e.date) set.add(e.date);
  return set;
}

async function getAssociatedTaskRows(projectId, headers) {
  let after = null;
  const rows = [];
  do {
    const res = await axios.get(
      `${HUBSPOT_BASE_URL}/crm/v4/objects/${PROJECT_OBJECT_TYPE}/${projectId}/associations/${TASK_OBJECT_TYPE}`,
      { headers, params: { limit: 500, after: after || undefined } }
    );
    rows.push(...(res.data.results || []));
    after = res.data.paging?.next?.after || null;
  } while (after);
  return rows;
}

async function identifySubtasks(taskIds, headers, debug) {
  const subtaskIds = new Set();
  for (const id of taskIds) {
    try {
      const res = await axios.get(
        `${HUBSPOT_BASE_URL}/crm/v4/objects/${TASK_OBJECT_TYPE}/${id}/associations/${TASK_OBJECT_TYPE}`,
        { headers }
      );
      const results = res.data.results || [];
      for (const r of results) {
        // Look for parent-task association type (1313)
        const isSub = (r.associationTypes || []).some((t) => t.typeId === 1313);
        if (isSub) subtaskIds.add(String(id));
      }
    } catch (e) {
      debug.push(`Task ${id} assoc check failed: ${e.message}`);
    }
  }
  return subtaskIds;
}

async function batchReadTasks(taskIds, headers) {
  const all = [];
  for (let i = 0; i < taskIds.length; i += BATCH_SIZE) {
    const chunk = taskIds.slice(i, i + BATCH_SIZE);
    const res = await axios.post(
      `${HUBSPOT_BASE_URL}/crm/v3/objects/${TASK_OBJECT_TYPE}/batch/read`,
      {
        properties: ["hs_timestamp", "hs_start_date", "hs_task_subject", "hs_task_status"],
        inputs: chunk.map((id) => ({ id })),
      },
      { headers }
    );
    all.push(...(res.data.results || []));
  }
  return all;
}

async function batchUpdateTasks(updates, headers) {
  for (let i = 0; i < updates.length; i += BATCH_SIZE) {
    const chunk = updates.slice(i, i + BATCH_SIZE);
    await axios.post(
      `${HUBSPOT_BASE_URL}/crm/v3/objects/${TASK_OBJECT_TYPE}/batch/update`,
      { inputs: chunk },
      { headers }
    );
  }
}

ESHOPMAN Team Comment

This custom code solution is an absolute gem for any HubSpot user managing complex projects, especially those in RevOps or agency settings. It directly addresses a critical operational bottleneck that standard HubSpot workflows don't handle out-of-the-box. We particularly love the smart handling of weekends and holidays and the detailed logging – these are hallmarks of a truly robust and thoughtful integration. While it requires some technical know-how to implement, the efficiency gains for project-driven teams are immense, making it a worthwhile investment for operational excellence within HubSpot.

This kind of advanced automation is exactly what unlocks the full potential of HubSpot as a central operating system for your business, extending far beyond just sales and marketing. For e-commerce businesses, imagine managing product development cycles, marketing campaign launches, or even inventory reorder projects with this level of dynamic control. It means less time spent on manual administrative tasks and more time focusing on strategy and growth. If you're currently wrestling with shifting project timelines and manual task updates, exploring a solution like this could be a game-changer for your team's productivity and accuracy. Hats off to the community member for sharing such a valuable contribution!

Share: