Hi everyone! ![]()
If you manage Projects and Tasks natively using HubSpot Custom Objects or standard Task associations, you’ve likely bumped into a common operational challenge:
The Problem: When a parent Project deadline moves (e.g., pushed back by 1 week due to scope changes), all associated child Tasks and Subtasks remain stuck on their original start and due dates. Manually updating dozens of individual task dates to keep the timeline aligned creates massive operational friction and broken workflows.
At Hatchi Digital, we developed a custom-coded workflow solution to ensure tasks shift dynamically alongside their parent project while maintaining work durations, accounting for weekends, and in our case adjusting for UK Bank Holidays.
Key Capabilities of This Solution
-
Relative Date Shifting: Calculates the exact day difference between the old project deadline and the new project deadline, shifting associated tasks by the same number of days.
-
Duration & Start Date Preservation: Shifts both
hs_start_dateandhs_timestamp(due date) so task duration stays intact. -
Smart Weekend & Holiday Handling:
-
Pulls due dates back to the preceding Friday if they land on a weekend or bank holiday.
-
Pushes start dates forward to the next business day (with safety guards so start dates never exceed due dates).
-
Automatically fetches live UK Bank Holidays via the official GOV.UK JSON API.
-
-
Completed Task Protection: Automatically skips completed tasks (
hs_task_status === 'COMPLETED') to protect historic completion records. -
Safety & Logging: Includes a
DEBUG_ASSOCIATIONSflag for dry runs, detailed logs outputting all shifted dates, and checks for zero-day differences.
How to Set It Up in HubSpot
-
Workflow Enrollment Trigger: Set your Project Workflow to enroll when the Project’s target due date (
hs_target_due_date) is updated. -
Custom Properties Needed:
-
hs_target_due_date(Project Due Date) -
last_processed_due_date(Custom date field on Project used as a baseline to calculate day differences) -
hs_start_date(Task Start Date)
-
-
Workflow Action: Add a Custom Code Action (Node.js 18.x or 20.x).
-
Environment Secrets: Pass your Private App Token under the secret name
Project_editor(requires CRM read/write permissions for objects and associations).
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_ASSOCIATIONS = false;
// 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("\n").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 reasons = [];
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 reasons = [];
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 }
);
}
}
Hope this helps anyone looking to keep complex project structures in sync within HubSpot Operations Hub! Feel free to ask any questions or adapt the code for your specific custom object schemas.
Pete from Hatchi Digital