{"slug":"business-hours-logic","title":"Business Hours Logic — Time-of-Day, Day-of-Week, and Holiday Routing","tags":["business-hours","time-routing","swml","scheduling","after-hours","holiday"],"agent_summary":"Route calls differently based on time-of-day, day-of-week, holidays, and timezone. SignalWire SWML uses `cond` blocks with date/time variables, or fetches a schedule from an external API. Handle DST correctly with IANA timezone names, treat 5 PM EST as 4 PM EST during DST. Holidays require an external calendar source — hardcoding is brittle.","trigger_phrases":["business hours routing","after hours call flow","time of day call routing","open closed hours SWML","holiday call routing","timezone call flow"],"runnable":true,"markdown":"\n# Business Hours Logic\n\nRoute calls differently depending on the time. The same DID rings sales during business hours, voicemail after hours, an answering service on weekends, and a holiday message on December 25th. This pattern is one of the most common requests in call flow design and one of the easiest to get wrong because of timezones and DST.\n\n## The four time dimensions\n\n1. **Time of day** — 9 AM to 5 PM is business hours, otherwise after-hours.\n2. **Day of week** — Mon-Fri vs Sat-Sun.\n3. **Date** — specific dates like 2026-12-25 (Christmas) or 2026-07-04.\n4. **Timezone** — the customer's local timezone, which shifts the wall-clock by 1 hour during DST in many zones.\n\nMishandling any one creates \"the phone says we're closed when we're open\" bugs that are infuriating to the customer and trust-destroying.\n\n## Always use IANA timezone names\n\nWrong: `EST`, `PST`, `Eastern`, `+0500`\n\nRight: `America/New_York`, `America/Los_Angeles`, `America/Chicago`, `America/Denver`, `America/Phoenix`\n\nIANA names encode the timezone's DST rules. `America/New_York` is EST (UTC-5) in winter and EDT (UTC-4) in summer. `America/Phoenix` is MST (UTC-7) year-round because Arizona doesn't observe DST.\n\nHardcoding `EST` or `+05:00` will be wrong six months a year.\n\n## SWML — basic open/closed branching\n\n```yaml\nversion: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - cond:\n        when: \"hour(now('America/New_York')) >= 9 && hour(now('America/New_York')) < 17 && weekday(now('America/New_York')) >= 1 && weekday(now('America/New_York')) <= 5\"\n        then:\n          - connect:\n              to: +12125551111\n              timeout: 30\n        else:\n          - play: say:We're closed right now. Please leave a message.\n          - record_call: {}\n```\n\n`weekday()` returns 0 (Sunday) through 6 (Saturday). Mon-Fri = 1-5. `hour()` returns 0-23.\n\n## SWML — fetch schedule from external API\n\nFor complex schedules (holidays, varied hours, multiple offices), keep the schedule in an external service:\n\n```yaml\n- request:\n    url: https://your.api/business-hours?location=denver\n    method: GET\n    save: schedule\n- cond:\n    when: \"${schedule.is_open}\"\n    then:\n      - connect:\n          to: \"${schedule.live_number}\"\n    else:\n      - play: say:${schedule.closed_message}\n      - record_call: {}\n```\n\nThe external API returns:\n\n```json\n{\n  \"is_open\": true,\n  \"live_number\": \"+13035551111\",\n  \"closed_message\": \"We're closed. Office reopens at 9 AM Mountain time.\"\n}\n```\n\nThis lets non-developers update hours in a dashboard without redeploying SWML.\n\n## Holiday calendar\n\nHardcoding holidays inline is brittle — easy to miss MLK Day, Juneteenth, Indigenous Peoples Day depending on jurisdiction. Use a library or external data source.\n\nPattern: store the holiday calendar as a JSON file or Supabase table:\n\n```json\n{\n  \"2026-01-01\": \"New Year's Day\",\n  \"2026-01-19\": \"Martin Luther King Jr. Day\",\n  \"2026-02-16\": \"Presidents' Day\",\n  \"2026-05-25\": \"Memorial Day\",\n  \"2026-06-19\": \"Juneteenth\",\n  \"2026-07-04\": \"Independence Day\",\n  \"2026-09-07\": \"Labor Day\",\n  \"2026-11-26\": \"Thanksgiving\",\n  \"2026-12-25\": \"Christmas\"\n}\n```\n\nFetch this from the SWML script:\n\n```yaml\n- request:\n    url: https://your.api/holiday-check?date=${call.created_at}&timezone=America/New_York\n    method: GET\n    save: holiday\n- cond:\n    when: \"${holiday.is_holiday}\"\n    then:\n      - play: say:Today is ${holiday.name}. Our office is closed.\n      - record_call: {}\n    else:\n      # Fall through to normal hours logic\n      - cond:\n          when: \"...\"\n```\n\n## Pattern: caller's local timezone\n\nIf the caller's geographic location matters (e.g., \"we're open 9 AM caller time\"), derive the timezone from the caller's area code or use a phone-number-to-timezone API:\n\n```yaml\n- request:\n    url: https://your.api/caller-timezone?phone=${call.from}\n    method: GET\n    save: tz\n- request:\n    url: https://your.api/business-hours?caller_tz=${tz.zone}\n    method: GET\n    save: schedule\n```\n\nArea code is an imperfect signal — mobile users keep area codes after moving. For better accuracy, ask the caller for their location early in the call or fall back to the office's local timezone.\n\n## DST transitions — the gotchas\n\nIn `America/New_York`:\n\n- **Spring forward**: 2026-03-08 at 2 AM, clock jumps to 3 AM. The 2:30 AM hour doesn't exist.\n- **Fall back**: 2026-11-01 at 2 AM, clock falls to 1 AM. The 1:30 AM hour happens twice.\n\nDon't schedule call flow transitions during these windows. If a callback is scheduled for 2:30 AM on a DST transition day, it should fire at either 1:30 AM or 3:30 AM (your choice) — using a proper datetime library that handles ambiguous times is essential.\n\nIn application code, store all timestamps in UTC and convert to local time only for display. SWML's `now('America/New_York')` handles DST automatically; raw date math with offsets does not.\n\n## Pattern: lunch break\n\nSome offices close for lunch 12-1 PM. Avoid sending callers to voicemail during lunch — let them stay in queue:\n\n```yaml\n- cond:\n    when: \"hour(now('America/Chicago')) == 12\"\n    then:\n      - play: say:Our team is on lunch. Please hold, we'll be back in a moment.\n      - connect:\n          to: queue:lunch_hold\n    else:\n      # Normal hours branch\n```\n\nA queue with hold music feels less abandoning than voicemail for a known-short interval.\n\n## Pattern: emergency after-hours number\n\nFor medical, legal, plumbing, locksmiths — after-hours callers may need a different number:\n\n```yaml\n- cond:\n    when: \"is_business_hours()\"\n    then:\n      - connect:\n          to: +13035551111\n    else:\n      - prompt:\n          play: say:Office is closed. Press 1 for emergencies, 2 to leave a message.\n          max_digits: 1\n      - cond:\n          when: \"${prompt_value} == '1'\"\n          then:\n            - connect:\n                to: +13035559999  # on-call emergency line\n          else:\n            - record_call: {}\n```\n\n## Pattern: multi-office routing\n\nDifferent offices in different timezones — route to whichever is currently open:\n\n```yaml\n- cond:\n    when: \"hour(now('America/Los_Angeles')) >= 9 && hour(now('America/Los_Angeles')) < 17\"\n    then:\n      - connect:\n          to: +14155551111  # LA office\n    else:\n      - cond:\n          when: \"hour(now('America/New_York')) >= 9 && hour(now('America/New_York')) < 17\"\n          then:\n            - connect:\n                to: +12125551111  # NY office\n          else:\n            - cond:\n                when: \"hour(now('Asia/Tokyo')) >= 9 && hour(now('Asia/Tokyo')) < 17\"\n                then:\n                  - connect:\n                      to: +81335551111  # Tokyo office\n                else:\n                  - play: say:All offices are closed. Please leave a message.\n                  - record_call: {}\n```\n\nThis is a \"follow the sun\" support routing model.\n\n## Pattern: scheduled message variations\n\nDifferent greetings for different times of day:\n\n```yaml\n- cond:\n    when: \"hour(now('America/New_York')) < 12\"\n    then:\n      - play: say:Good morning! Thanks for calling.\n- cond:\n    when: \"hour(now('America/New_York')) >= 12 && hour(now('America/New_York')) < 17\"\n    then:\n      - play: say:Good afternoon! Thanks for calling.\n- cond:\n    when: \"hour(now('America/New_York')) >= 17\"\n    then:\n      - play: say:Good evening! Thanks for calling.\n```\n\n## Compatibility API (LaML)\n\nLaML doesn't have inline date functions. Pattern is to call out to your own endpoint that returns LaML based on current time:\n\n```python\n@app.route(\"/incoming-call\", methods=[\"POST\"])\ndef incoming_call():\n    now = datetime.now(ZoneInfo(\"America/New_York\"))\n    is_open = now.weekday() < 5 and 9 <= now.hour < 17\n    is_holiday = check_holiday(now.date())\n    \n    if is_open and not is_holiday:\n        return Response(\"\"\"\n            <Response>\n                <Dial>+12125551111</Dial>\n            </Response>\n        \"\"\", mimetype=\"text/xml\")\n    else:\n        return Response(\"\"\"\n            <Response>\n                <Say>We're closed. Please leave a message.</Say>\n                <Record />\n            </Response>\n        \"\"\", mimetype=\"text/xml\")\n```\n\n## Testing business hours logic\n\nCommon pre-prod test cases:\n\n| Test case | Expected behavior |\n|---|---|\n| Tuesday 10 AM ET | Open → sales |\n| Saturday 10 AM ET | Closed → voicemail |\n| Tuesday 8:59 AM ET | Closed (1 minute before open) |\n| Tuesday 9:00 AM ET | Open |\n| Tuesday 4:59 PM ET | Open |\n| Tuesday 5:00 PM ET | Closed (exact boundary) |\n| December 25, any time | Holiday closed |\n| DST transition Sunday 2:30 AM | Should not error |\n\nUse a test harness that lets you spoof \"now\" — your business-hours function should take a `now` parameter instead of calling `datetime.now()` directly, for testability.\n\n## Common pitfalls\n\n- **Hardcoding offsets instead of IANA names** — wrong six months a year due to DST.\n- **Computing hours in server timezone** — server is UTC, office is local. Always pass timezone explicitly.\n- **Missing holidays** — federal vs state holidays vary, religious vs secular vary, recent additions (Juneteenth) get missed.\n- **No fallback when external schedule API is down** — always have a default (typically \"closed\" with a polite message and a callback).\n- **Exact-minute boundaries** — 5:00:00 PM is the last second of open hours; 5:00:01 should already be closed. Test both sides.\n- **Forgetting the lunch hour** — if hours are 9-12 and 1-5, the 12-1 branch needs explicit handling.\n\n## Related patterns\n\n- [Call routing strategies](/topic/call-routing-strategies) — round-robin, skill-based, time-of-day routing\n- [Missed call workflows](/topic/missed-call-workflows) — what happens after-hours\n- [Callback scheduling](/topic/callback-scheduling) — let after-hours callers schedule a callback\n\n## References\n\n- IANA Time Zone Database — official timezone names\n- ICU CLDR — common locale data including timezone aliases\n- RFC 5545 — iCalendar specification (for parsing external holiday calendars)\n","html":"<h1>Business Hours Logic</h1>\n<p>Route calls differently depending on the time. The same DID rings sales during business hours, voicemail after hours, an answering service on weekends, and a holiday message on December 25th. This pattern is one of the most common requests in call flow design and one of the easiest to get wrong because of timezones and DST.</p>\n<h2>The four time dimensions</h2>\n<ol>\n<li><strong>Time of day</strong> — 9 AM to 5 PM is business hours, otherwise after-hours.</li>\n<li><strong>Day of week</strong> — Mon-Fri vs Sat-Sun.</li>\n<li><strong>Date</strong> — specific dates like 2026-12-25 (Christmas) or 2026-07-04.</li>\n<li><strong>Timezone</strong> — the customer's local timezone, which shifts the wall-clock by 1 hour during DST in many zones.</li>\n</ol>\n<p>Mishandling any one creates \"the phone says we're closed when we're open\" bugs that are infuriating to the customer and trust-destroying.</p>\n<h2>Always use IANA timezone names</h2>\n<p>Wrong: <code>EST</code>, <code>PST</code>, <code>Eastern</code>, <code>+0500</code></p>\n<p>Right: <code>America/New_York</code>, <code>America/Los_Angeles</code>, <code>America/Chicago</code>, <code>America/Denver</code>, <code>America/Phoenix</code></p>\n<p>IANA names encode the timezone's DST rules. <code>America/New_York</code> is EST (UTC-5) in winter and EDT (UTC-4) in summer. <code>America/Phoenix</code> is MST (UTC-7) year-round because Arizona doesn't observe DST.</p>\n<p>Hardcoding <code>EST</code> or <code>+05:00</code> will be wrong six months a year.</p>\n<h2>SWML — basic open/closed branching</h2>\n<pre><code class=\"language-yaml\">version: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - cond:\n        when: \"hour(now('America/New_York')) >= 9 &#x26;&#x26; hour(now('America/New_York')) &#x3C; 17 &#x26;&#x26; weekday(now('America/New_York')) >= 1 &#x26;&#x26; weekday(now('America/New_York')) &#x3C;= 5\"\n        then:\n          - connect:\n              to: +12125551111\n              timeout: 30\n        else:\n          - play: say:We're closed right now. Please leave a message.\n          - record_call: {}\n</code></pre>\n<p><code>weekday()</code> returns 0 (Sunday) through 6 (Saturday). Mon-Fri = 1-5. <code>hour()</code> returns 0-23.</p>\n<h2>SWML — fetch schedule from external API</h2>\n<p>For complex schedules (holidays, varied hours, multiple offices), keep the schedule in an external service:</p>\n<pre><code class=\"language-yaml\">- request:\n    url: https://your.api/business-hours?location=denver\n    method: GET\n    save: schedule\n- cond:\n    when: \"${schedule.is_open}\"\n    then:\n      - connect:\n          to: \"${schedule.live_number}\"\n    else:\n      - play: say:${schedule.closed_message}\n      - record_call: {}\n</code></pre>\n<p>The external API returns:</p>\n<pre><code class=\"language-json\">{\n  \"is_open\": true,\n  \"live_number\": \"+13035551111\",\n  \"closed_message\": \"We're closed. Office reopens at 9 AM Mountain time.\"\n}\n</code></pre>\n<p>This lets non-developers update hours in a dashboard without redeploying SWML.</p>\n<h2>Holiday calendar</h2>\n<p>Hardcoding holidays inline is brittle — easy to miss MLK Day, Juneteenth, Indigenous Peoples Day depending on jurisdiction. Use a library or external data source.</p>\n<p>Pattern: store the holiday calendar as a JSON file or Supabase table:</p>\n<pre><code class=\"language-json\">{\n  \"2026-01-01\": \"New Year's Day\",\n  \"2026-01-19\": \"Martin Luther King Jr. Day\",\n  \"2026-02-16\": \"Presidents' Day\",\n  \"2026-05-25\": \"Memorial Day\",\n  \"2026-06-19\": \"Juneteenth\",\n  \"2026-07-04\": \"Independence Day\",\n  \"2026-09-07\": \"Labor Day\",\n  \"2026-11-26\": \"Thanksgiving\",\n  \"2026-12-25\": \"Christmas\"\n}\n</code></pre>\n<p>Fetch this from the SWML script:</p>\n<pre><code class=\"language-yaml\">- request:\n    url: https://your.api/holiday-check?date=${call.created_at}&#x26;timezone=America/New_York\n    method: GET\n    save: holiday\n- cond:\n    when: \"${holiday.is_holiday}\"\n    then:\n      - play: say:Today is ${holiday.name}. Our office is closed.\n      - record_call: {}\n    else:\n      # Fall through to normal hours logic\n      - cond:\n          when: \"...\"\n</code></pre>\n<h2>Pattern: caller's local timezone</h2>\n<p>If the caller's geographic location matters (e.g., \"we're open 9 AM caller time\"), derive the timezone from the caller's area code or use a phone-number-to-timezone API:</p>\n<pre><code class=\"language-yaml\">- request:\n    url: https://your.api/caller-timezone?phone=${call.from}\n    method: GET\n    save: tz\n- request:\n    url: https://your.api/business-hours?caller_tz=${tz.zone}\n    method: GET\n    save: schedule\n</code></pre>\n<p>Area code is an imperfect signal — mobile users keep area codes after moving. For better accuracy, ask the caller for their location early in the call or fall back to the office's local timezone.</p>\n<h2>DST transitions — the gotchas</h2>\n<p>In <code>America/New_York</code>:</p>\n<ul>\n<li><strong>Spring forward</strong>: 2026-03-08 at 2 AM, clock jumps to 3 AM. The 2:30 AM hour doesn't exist.</li>\n<li><strong>Fall back</strong>: 2026-11-01 at 2 AM, clock falls to 1 AM. The 1:30 AM hour happens twice.</li>\n</ul>\n<p>Don't schedule call flow transitions during these windows. If a callback is scheduled for 2:30 AM on a DST transition day, it should fire at either 1:30 AM or 3:30 AM (your choice) — using a proper datetime library that handles ambiguous times is essential.</p>\n<p>In application code, store all timestamps in UTC and convert to local time only for display. SWML's <code>now('America/New_York')</code> handles DST automatically; raw date math with offsets does not.</p>\n<h2>Pattern: lunch break</h2>\n<p>Some offices close for lunch 12-1 PM. Avoid sending callers to voicemail during lunch — let them stay in queue:</p>\n<pre><code class=\"language-yaml\">- cond:\n    when: \"hour(now('America/Chicago')) == 12\"\n    then:\n      - play: say:Our team is on lunch. Please hold, we'll be back in a moment.\n      - connect:\n          to: queue:lunch_hold\n    else:\n      # Normal hours branch\n</code></pre>\n<p>A queue with hold music feels less abandoning than voicemail for a known-short interval.</p>\n<h2>Pattern: emergency after-hours number</h2>\n<p>For medical, legal, plumbing, locksmiths — after-hours callers may need a different number:</p>\n<pre><code class=\"language-yaml\">- cond:\n    when: \"is_business_hours()\"\n    then:\n      - connect:\n          to: +13035551111\n    else:\n      - prompt:\n          play: say:Office is closed. Press 1 for emergencies, 2 to leave a message.\n          max_digits: 1\n      - cond:\n          when: \"${prompt_value} == '1'\"\n          then:\n            - connect:\n                to: +13035559999  # on-call emergency line\n          else:\n            - record_call: {}\n</code></pre>\n<h2>Pattern: multi-office routing</h2>\n<p>Different offices in different timezones — route to whichever is currently open:</p>\n<pre><code class=\"language-yaml\">- cond:\n    when: \"hour(now('America/Los_Angeles')) >= 9 &#x26;&#x26; hour(now('America/Los_Angeles')) &#x3C; 17\"\n    then:\n      - connect:\n          to: +14155551111  # LA office\n    else:\n      - cond:\n          when: \"hour(now('America/New_York')) >= 9 &#x26;&#x26; hour(now('America/New_York')) &#x3C; 17\"\n          then:\n            - connect:\n                to: +12125551111  # NY office\n          else:\n            - cond:\n                when: \"hour(now('Asia/Tokyo')) >= 9 &#x26;&#x26; hour(now('Asia/Tokyo')) &#x3C; 17\"\n                then:\n                  - connect:\n                      to: +81335551111  # Tokyo office\n                else:\n                  - play: say:All offices are closed. Please leave a message.\n                  - record_call: {}\n</code></pre>\n<p>This is a \"follow the sun\" support routing model.</p>\n<h2>Pattern: scheduled message variations</h2>\n<p>Different greetings for different times of day:</p>\n<pre><code class=\"language-yaml\">- cond:\n    when: \"hour(now('America/New_York')) &#x3C; 12\"\n    then:\n      - play: say:Good morning! Thanks for calling.\n- cond:\n    when: \"hour(now('America/New_York')) >= 12 &#x26;&#x26; hour(now('America/New_York')) &#x3C; 17\"\n    then:\n      - play: say:Good afternoon! Thanks for calling.\n- cond:\n    when: \"hour(now('America/New_York')) >= 17\"\n    then:\n      - play: say:Good evening! Thanks for calling.\n</code></pre>\n<h2>Compatibility API (LaML)</h2>\n<p>LaML doesn't have inline date functions. Pattern is to call out to your own endpoint that returns LaML based on current time:</p>\n<pre><code class=\"language-python\">@app.route(\"/incoming-call\", methods=[\"POST\"])\ndef incoming_call():\n    now = datetime.now(ZoneInfo(\"America/New_York\"))\n    is_open = now.weekday() &#x3C; 5 and 9 &#x3C;= now.hour &#x3C; 17\n    is_holiday = check_holiday(now.date())\n    \n    if is_open and not is_holiday:\n        return Response(\"\"\"\n            &#x3C;Response>\n                &#x3C;Dial>+12125551111&#x3C;/Dial>\n            &#x3C;/Response>\n        \"\"\", mimetype=\"text/xml\")\n    else:\n        return Response(\"\"\"\n            &#x3C;Response>\n                &#x3C;Say>We're closed. Please leave a message.&#x3C;/Say>\n                &#x3C;Record />\n            &#x3C;/Response>\n        \"\"\", mimetype=\"text/xml\")\n</code></pre>\n<h2>Testing business hours logic</h2>\n<p>Common pre-prod test cases:</p>\n<p>| Test case | Expected behavior |\n|---|---|\n| Tuesday 10 AM ET | Open → sales |\n| Saturday 10 AM ET | Closed → voicemail |\n| Tuesday 8:59 AM ET | Closed (1 minute before open) |\n| Tuesday 9:00 AM ET | Open |\n| Tuesday 4:59 PM ET | Open |\n| Tuesday 5:00 PM ET | Closed (exact boundary) |\n| December 25, any time | Holiday closed |\n| DST transition Sunday 2:30 AM | Should not error |</p>\n<p>Use a test harness that lets you spoof \"now\" — your business-hours function should take a <code>now</code> parameter instead of calling <code>datetime.now()</code> directly, for testability.</p>\n<h2>Common pitfalls</h2>\n<ul>\n<li><strong>Hardcoding offsets instead of IANA names</strong> — wrong six months a year due to DST.</li>\n<li><strong>Computing hours in server timezone</strong> — server is UTC, office is local. Always pass timezone explicitly.</li>\n<li><strong>Missing holidays</strong> — federal vs state holidays vary, religious vs secular vary, recent additions (Juneteenth) get missed.</li>\n<li><strong>No fallback when external schedule API is down</strong> — always have a default (typically \"closed\" with a polite message and a callback).</li>\n<li><strong>Exact-minute boundaries</strong> — 5:00:00 PM is the last second of open hours; 5:00:01 should already be closed. Test both sides.</li>\n<li><strong>Forgetting the lunch hour</strong> — if hours are 9-12 and 1-5, the 12-1 branch needs explicit handling.</li>\n</ul>\n<h2>Related patterns</h2>\n<ul>\n<li><a href=\"/topic/call-routing-strategies\">Call routing strategies</a> — round-robin, skill-based, time-of-day routing</li>\n<li><a href=\"/topic/missed-call-workflows\">Missed call workflows</a> — what happens after-hours</li>\n<li><a href=\"/topic/callback-scheduling\">Callback scheduling</a> — let after-hours callers schedule a callback</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li>IANA Time Zone Database — official timezone names</li>\n<li>ICU CLDR — common locale data including timezone aliases</li>\n<li>RFC 5545 — iCalendar specification (for parsing external holiday calendars)</li>\n</ul>\n"}