Show a dispatch estimate at checkout
Use your order cutoff, business time zone, and public holiday calendar to show when preparation should finish.
Working example
ParseAPISet up ParseAPI
Install the SDK in your application and set PARSEAPI_KEY in your server environment.
npm install @parseapi/sdk@1.14.0
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'Create a client and reuse it for the calls below:
import { parseAPI } from '@parseapi/sdk';
const parse = parseAPI();Give checkout a useful dispatch date
Show when an order should finish preparation before the customer commits. Combine your business's processing time and cutoff with the Holiday API, then place the estimated dispatch date beside the checkout choice.
This example prepares orders Monday-Friday in America/New_York, excluding US federal public holidays and December 24, 2026. Standard preparation takes two business days; Priority takes one. A working day counts as day one only when the order arrives before 2pm. At 2pm or later, counting begins on the next working day.
Dispatch means preparation has finished. It does not estimate carrier transit or promise an arrival date.
1. Map a checkout choice to your preparation policy
The form lets the customer choose a service:
const form = `<section class="account"><h2>Your basket</h2><p>Studio notebook · Quantity 1</p></section>
<form method="post" action="/">
<label for="service">Order preparation</label>
<select id="service" name="service" aria-describedby="policy">
<option value="standard"${fields.service !== 'priority' ? ' selected' : ''}>
Standard - 2 business days
</option>
<option value="priority"${fields.service === 'priority' ? ' selected' : ''}>
Priority - 1 business day
</option>
</select>
<p id="policy" class="note">A working day before 2pm counts as day one.
Weekends, US federal holidays, and December 24, 2026 are closed.</p>
<button type="submit">Estimate dispatch</button>
</form>`;The server maps standard and priority to its own processing durations. It also takes the order time from its own clock. The customer supplies neither an arbitrary processing time nor a timestamp that could move the cutoff.
Use your existing checkout's cart and service selection in place of the sample basket. Keep cutoff, working week, time zone, and special closures in your trusted business policy.
2. Fetch each holiday year the estimate needs
The SDK's year operation supplies the published holiday dates:
const calendar = await parse.holiday('US', { year, signal });The response's country and year must match the calculation. Keep public holidays as a set of dates:
const holidays = new Set(calendar.holidays
.filter(holiday => holiday.type === 'public')
.map(holiday => holiday.date));Observances do not close this business. Duplicate names on one date still remove only one day, and published substitute dates stay where they are. The complete source rejects malformed calendars and unexpected regional scope.
December orders may need the next year's calendar. Load each reached year once per estimate. If a required year is unavailable, show an unavailable estimate rather than treating its holidays as working days.
3. Count dates in the business calendar
Node's Intl.DateTimeFormat converts the server's current instant into the known business time zone. An additional Time API call is unnecessary when the runtime already has that zone.
Use whole UTC day numbers only to advance civil dates. The order's local date and clock still come from America/New_York, so a daylight-saving change does not turn a 24-hour step into the wrong date.
const weekday = ![0, 6].includes(new Date(day * DAY).getUTCDay());
const working = weekday && !calendars.get(year).has(date)
&& !POLICY.closedDates.includes(date);
const beforeCutoff = day !== start || clock.minutes < POLICY.cutoffMinutes;
if (working && beforeCutoff && --remaining === 0) {
return {
date, orderDate: clock.date, service: SERVICES[service].label,
processingDays: SERVICES[service].days
};
}For Standard preparation, a working Monday at 1:59pm counts Monday and Tuesday. The same order at 2pm starts counting Tuesday and finishes Wednesday, provided those dates are open.
4. Put the estimate beside the order choice
The server's result becomes the checkout message:
return `<section class="result"><h2>Estimated dispatch:
<time datetime="${escape(estimate.date)}">${escape(estimate.date)}</time></h2>
<p>${escape(estimate.service)} preparation, ${estimate.processingDays}
business ${estimate.processingDays === 1 ? 'day' : 'days'}.</p>
<p class="note">Based on the server's order time in America/New_York.
Dispatch is when preparation finishes, not the arrival date.</p></section>`;Recalculate using the actual receipt time when your existing order handler accepts the order. An estimate shown before the cutoff may change if checkout completes afterward. Store the applied policy with an accepted fulfillment commitment in your own order system.
The standalone app renders the estimate and stores no orders. Changing the preparation choice clears the old result and cancels any pending calculation. Request failures leave the form available to retry.
See the Holiday reference for calendar coverage. For selecting an appointment date instead, recheck a booking date.
Complete example
Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.
View and copy the complete source
import { createServer } from 'node:http';
import { pathToFileURL } from 'node:url';
// example:imports
import { parseAPI } from '@parseapi/sdk';
// /example:imports
class InputError extends Error {}
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
const text = (value, max = 160) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value);
const DAY = 86400000;
function civilDay(value) {
const time = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00Z`) : NaN;
if (!Number.isFinite(time) || value.startsWith('0000') || new Date(time).toISOString().slice(0, 10) !== value) throw new InputError('Choose a real date.');
return time / DAY;
}
const isoDay = day => new Date(day * DAY).toISOString().slice(0, 10);
function businessClock(now) {
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw new Error('Clock unavailable.');
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
}).formatToParts(now).map(part => [part.type, part.value]));
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
}
function readHoliday(holiday, date) {
if (!holiday || holiday.date !== date || !text(holiday.name) || !['public', 'observance'].includes(holiday.type) ||
typeof holiday.substitute !== 'boolean' || !(holiday.regions === null || Array.isArray(holiday.regions) && holiday.regions.length <= 100 && holiday.regions.every(region => text(region, 20)))) throw new Error('Unexpected holiday.');
civilDay(holiday.date);
// This example's policy is US federal only, without state-specific holidays.
if (holiday.type === 'public' && holiday.regions !== null) throw new Error('Calendar scope does not match the policy.');
return holiday;
}
export const POLICY = { country: 'US', zone: 'America/New_York', cutoffMinutes: 14 * 60, closedDates: ['2026-12-24'] };
const SERVICES = { standard: { label: 'Standard', days: 2 }, priority: { label: 'Priority', days: 1 } };
const TITLE = 'Estimate your dispatch date';
const INTRO = 'Your order is prepared on weekdays, excluding US federal holidays and our closed dates. Orders received before 2pm New York time can begin that day.';
const FAILURE = 'A required holiday calendar could not be checked. No dispatch estimate is available. Try again.';
const FIELDS = ['service'];
export async function estimateDispatch(service, parse, signal, now = new Date()) {
if (!Object.hasOwn(SERVICES, service)) throw new InputError('Choose Standard or Priority preparation.');
const clock = businessClock(now), calendars = new Map();
const start = civilDay(clock.date);
let remaining = SERVICES[service].days;
for (let day = start; day <= start + 45; day++) {
const date = isoDay(day), year = Number(date.slice(0, 4));
if (!calendars.has(year)) {
// example:request
const calendar = await parse.holiday('US', { year, signal });
// /example:request
if (!calendar || calendar.country !== 'US' || calendar.year !== year || !Array.isArray(calendar.holidays) || !calendar.holidays.length || calendar.holidays.length > 1000) throw new Error('Unexpected calendar response.');
for (const holiday of calendar.holidays) {
if (!holiday || typeof holiday.date !== 'string' || !holiday.date.startsWith(`${year}-`)) throw new Error('Unexpected holiday year.');
readHoliday(holiday, holiday.date);
}
// example:result
const holidays = new Set(calendar.holidays
.filter(holiday => holiday.type === 'public')
.map(holiday => holiday.date));
// /example:result
if (!holidays.size) throw new Error('US federal calendar has no public dates.');
calendars.set(year, holidays);
}
// example:calculate
const weekday = ![0, 6].includes(new Date(day * DAY).getUTCDay());
const working = weekday && !calendars.get(year).has(date)
&& !POLICY.closedDates.includes(date);
const beforeCutoff = day !== start || clock.minutes < POLICY.cutoffMinutes;
if (working && beforeCutoff && --remaining === 0) {
return {
date, orderDate: clock.date, service: SERVICES[service].label,
processingDays: SERVICES[service].days
};
}
// /example:calculate
}
throw new Error('No estimate within the bounded processing window.');
}
function renderForm(fields = {}) {
// example:form
const form = `<section class="account"><h2>Your basket</h2><p>Studio notebook · Quantity 1</p></section>
<form method="post" action="/">
<label for="service">Order preparation</label>
<select id="service" name="service" aria-describedby="policy">
<option value="standard"${fields.service !== 'priority' ? ' selected' : ''}>
Standard - 2 business days
</option>
<option value="priority"${fields.service === 'priority' ? ' selected' : ''}>
Priority - 1 business day
</option>
</select>
<p id="policy" class="note">A working day before 2pm counts as day one.
Weekends, US federal holidays, and December 24, 2026 are closed.</p>
<button type="submit">Estimate dispatch</button>
</form>`;
// /example:form
return form;
}
async function submit(fields, parse, signal, now) {
const estimate = await estimateDispatch(fields.service, parse, signal, now);
// example:integration
return `<section class="result"><h2>Estimated dispatch:
<time datetime="${escape(estimate.date)}">${escape(estimate.date)}</time></h2>
<p>${escape(estimate.service)} preparation, ${estimate.processingDays}
business ${estimate.processingDays === 1 ? 'day' : 'days'}.</p>
<p class="note">Based on the server's order time in America/New_York.
Dispatch is when preparation finishes, not the arrival date.</p></section>`;
// /example:integration
}
const CSS = `
:root{color-scheme:light dark;font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#202124;background:#f5f5f7}
*{box-sizing:border-box}body{margin:0}main{max-width:720px;margin:0 auto;padding:56px 20px 72px}h1{font-size:clamp(2rem,6vw,2.8rem);line-height:1.12;letter-spacing:-.045em;margin:0 0 16px}h2{font-size:1.35rem;line-height:1.3;letter-spacing:-.025em;margin:0 0 12px}h3{font-size:1rem;margin:0 0 8px}p{margin:0 0 16px}.intro,.note,small{color:#62646a}.intro{max-width:56ch}form,.result,.account{background:#fff;border:1px solid #e2e3e4;border-radius:18px;padding:24px;margin-top:24px}label{display:block;font-weight:600;margin-bottom:8px}input,select,button{font:inherit;min-height:44px;border-radius:8px}input,select{width:100%;border:1px solid #acafb5;background:#fff;color:inherit;padding:10px 12px}select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%2371717a' stroke-width='1.5' d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:36px}button{border:0;background:#087f9c;color:#fff;font-weight:600;padding:10px 18px;margin-top:20px;cursor:pointer}button:disabled{opacity:.65;cursor:wait}:focus-visible{outline:3px solid #0891b2;outline-offset:3px}.note{font-size:.875rem;margin-top:12px}.status{min-height:1.5em;margin-top:14px}ul{padding-left:20px}li{overflow-wrap:anywhere}.result{overflow-wrap:anywhere}.result p:last-child,.account p:last-child{margin-bottom:0}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.technology{display:block}time{font-variant-numeric:tabular-nums}
@media(max-width:480px){main{padding-top:32px}form,.result,.account{padding:20px}.grid{grid-template-columns:1fr}button{width:100%}}
@media(prefers-color-scheme:dark){:root{background:#000;color:#f5f5f7}form,.result,.account{background:#1d1d1f;border-color:#424245}input,select{background-color:#1d1d1f;border-color:#69696e}.intro,.note,small{color:#a1a1a6}button{background:#087f9c}}
`;
const CLIENT = `
const form = document.querySelector('form');
const result = document.querySelector('#result');
const status = document.querySelector('#status');
const button = form.querySelector('button');
let controller, revision = 0;
form.addEventListener('input', () => {
revision++; controller?.abort(); result.replaceChildren(); status.textContent = ''; button.disabled = false;
});
form.addEventListener('submit', async event => {
event.preventDefault(); controller?.abort(); controller = new AbortController();
const current = ++revision, signal = controller.signal;
result.replaceChildren(); status.textContent = 'Checking...'; button.disabled = true;
try {
const response = await fetch('/', { method: 'POST', signal, headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'X-Example-Fragment': '1'
}, body: new URLSearchParams(new FormData(form)) });
const html = await response.text();
if (current !== revision || signal.aborted) return;
if (!response.headers.get('content-type')?.startsWith('text/html')) throw new Error();
result.innerHTML = html; status.textContent = '';
} catch {
if (current !== revision || signal.aborted) return;
status.textContent = 'The check did not finish. Try again.';
} finally { if (current === revision) button.disabled = false; }
});
`;
function page(form, result = '') {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${TITLE}</title><style>${CSS}</style></head><body><main><h1>${TITLE}</h1><p class="intro">${INTRO}</p>${form}<p id="status" class="status" role="status"></p><div id="result" aria-live="polite">${result}</div></main><script>${CLIENT}</script></body></html>`;
}
function readForm(req, allowed) {
if (req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') return Promise.reject(new InputError('Submit this form again.'));
return new Promise((resolve, reject) => {
const chunks = []; let bytes = 0;
const fail = error => { cleanup(); req.resume(); reject(error); };
const chunk = value => {
bytes += value.length;
if (bytes > 4096) return fail(new InputError('The form is too large.'));
chunks.push(value);
};
const finish = () => {
cleanup(); const fields = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
if ([...fields.keys()].some(key => !allowed.includes(key)) || allowed.some(key => fields.getAll(key).length !== 1)) return reject(new InputError('Submit one value for each field.'));
resolve(Object.fromEntries(fields));
};
const aborted = () => fail(new Error('Request ended.'));
const cleanup = () => { req.off('data', chunk); req.off('end', finish); req.off('aborted', aborted); req.off('error', fail); };
req.on('data', chunk); req.once('end', finish); req.once('aborted', aborted); req.once('error', fail);
});
}
export function createApp(parse, { now = () => new Date() } = {}) {
const server = createServer(async (req, res) => {
const controller = new AbortController();
const disconnect = () => { if (!res.writableEnded) controller.abort(); };
res.once('close', disconnect);
const send = (status, html) => {
if (controller.signal.aborted) return;
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" });
res.end(html);
};
let fields = {};
const fragment = req.headers['x-example-fragment'] === '1';
try {
const host = req.headers.host ?? '';
if (!/^(?:127\.0\.0\.1|localhost)(?::[0-9]{1,5})?$/.test(host) ||
req.headers.origin && req.headers.origin !== `http://${host}` || req.headers['sec-fetch-site'] === 'cross-site') return send(403, '<p>Open the app on 127.0.0.1 to use this form.</p>');
if (req.url !== '/') return send(404, '<p>Page not found.</p>');
if (req.method === 'GET') return send(200, page(renderForm({}, now())));
if (req.method !== 'POST') return send(405, '<p>Use the form to submit a check.</p>');
fields = await readForm(req, FIELDS);
const result = await submit(fields, parse, controller.signal, now());
send(200, fragment ? result : page(renderForm(fields, now()), result));
} catch (error) {
if (controller.signal.aborted) return;
const message = error instanceof InputError ? error.message : FAILURE;
const result = `<section class="result" role="alert"><h2>Check unavailable</h2><p>${escape(message)}</p></section>`;
send(error instanceof InputError ? 400 : 502, fragment ? result : page(renderForm(fields, now()), result));
}
});
server.requestTimeout = 10000; server.headersTimeout = 10000;
return server;
}
function main() {
const key = process.env.PARSEAPI_KEY?.trim();
if (!key || key.startsWith('parse_public_') || key.startsWith('parse_app_') || /[\r\n]/.test(key)) throw new Error('Set PARSEAPI_KEY to a server secret key.');
const port = process.env.PORT === undefined ? 8000 : Number(process.env.PORT);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('PORT must be from 1 to 65535.');
// API 2.1.0 is selected by @parseapi/sdk 1.14.0. No automatic retry of a submitted form.
// example:client
const parse = parseAPI(key, { retries: 0 });
// /example:client
const server = createApp(parse);
server.on('error', () => { console.error('The local app could not start. Check PORT.'); process.exitCode = 1; });
server.listen(port, '127.0.0.1', () => console.log(`Open http://127.0.0.1:${port}`));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}
// The website embeds this same app; the standalone server remains unchanged.
export { page, renderForm, submit };
Try the complete example on your computer
After completing the setup above, save the source in your application folder and start the app:
node checkout-dispatch-date.node.mjsOpen 127.0.0.1:8000 to use the form. The server keeps your secret key out of the page.
For every field and parameter, see Holiday API reference.
Build something else
- Estimate a date in business days
- Find an open store nearby
- Show opening hours on a business page
- Recheck a booking date before accepting it