Skip to content
Skip to main content
Novel Systems home
Decision log
D-015August 3, 2026Accepted

A grid of times is not availability

Affects: lib/scheduling.ts, components/ui/booking-modal.tsx, components/ui/live-chat.tsx, app/api/health/route.ts, .env.example

What was decided

When NEXT_PUBLIC_SCHEDULING_PROVIDER and NEXT_PUBLIC_SCHEDULING_URL are set and the URL survives validation, the demo CTA is a plain link to the vendor's public booking page. When they are not, it opens the site's own modal, and that modal now says at the point of choice — not only on the receipt — that the times shown are business hours rather than a calendar.

This is the third instance of the same defect and it is worth naming as a pattern. D-013: a number nothing measured. D-014: a queue nothing implemented. Here: sixteen half-hour slots a day, rendered in a grid that looks exactly like every scheduler a buyer has ever used, none of them checked against anybody's calendar. The modal was careful — it said "the slot is requested rather than booked" — but it said it in the confirmation, after the visitor had chosen. What a grid of selectable times *means* is "these are free". Disclaiming that in the next screen does not unmake the claim; it just moves it.

The modal is not deleted, and that is deliberate. Unconfigured, a visitor can still say when they are free, which is better than a mailto. It is now the documented fallback rather than the product.

One link, no embed

Neither vendor's embed script is used. An embed would put a third-party script on the highest-traffic page on the site, add a vendor cookie to declare in the consent banner, and make the conversion path depend on somebody else's CDN staying up. A link costs an <a> and the visitor keeps the tab they were on (target="_blank", rel="noopener noreferrer"). Everything an embed would give us — real availability, a calendar invite, timezone handling, reschedule links — is on the page it links to.

The chat widget needed a fourth ChatAction shape for this. next/link would have prefetched a cross-origin URL we never use, client-navigated something that is a full document load either way, and emitted no rel="noopener".

The URL is validated by allowlist, and self-hosting fails on purpose

This value is interpolated into the site's most-clicked link. A typo, a value copied from another project, or a variable set by somebody who did not know what it fed all end the same way: a primary CTA sending buyers somewhere nobody chose. So the URL must parse, must be https, must carry no embedded credentials (https://calendly.com@evil.example/x parses with host evil.example and reads as a Calendly URL to anyone skimming), and its host must suffix-match the named provider's with a leading dot — endsWith("cal.com") would accept evilcal.com, which is the exact mistake the table exists to prevent.

Self-hosted Cal.com fails this. Widening what our own CTA may point at should cost a code change and a review, which is the right amount of friction for that particular decision.

NEXT_PUBLIC_ here, server-only there

HELPDESK_API_KEY and UPTIME_MONITOR_API_KEY are server-only and enforced as such by scripts/check-secret-modules.mjs. A booking page URL is the opposite kind of value — it ends up in an href, so it is public the moment it works. Reading it on the server would buy nothing and would cost every page mounting the chat widget or the trigger button a drilled prop.

Two consequences follow. Both process.env reads are written out as literal member expressions and must stay that way: Next substitutes NEXT_PUBLIC_ variables textually, so a dynamic read behind a helper — which is how the two server-only modules do it — is undefined in the browser, silently, with the CTA quietly falling back to the modal on a deployment that has a scheduler. And /api/health reads the environment live while the browser holds a build-time copy, so the field there is right about the configuration and right about the site only if nothing changed since the last deploy.

Three states, not two

schedulingHealth() reports configured, unconfigured or misconfigured, carried by an explicit absent boolean rather than inferred from the wording of the reason. This is the correction from D-014 applied before it could be a bug: every failure path ends in the same modal, so a typo in the URL and a deployment that never had a scheduler produce a byte-identical site, and only one of them is routine.

What is not yet true

No scheduler account exists, so the deployed site is on the fallback path. The 36 checks over lib/scheduling.ts — validation, allowlist, normalisation, the absent/wrong split — were run before this was committed; what has not run is a real booking against a real calendar, which is the only thing that verifies the row's actual claim.

PRD row 16 says "20-minute demo" and SLOT_MINUTES in the fallback modal is 30. Rather than reconcile them, the configured path asserts no duration at all: the event length lives in the vendor dashboard and the site cannot read it, so it does not claim one.