When building a system that manages automated phone campaigns, the problem is much more complicated than simply looping through a list of phone numbers and calling them. A real campaign system needs to answer questions such as: When is a customer allowed to be called? How many calls can run simultaneously? What happens when a call fails? When should a failed call be retried? How many minutes can we spend calling customers per day? What happens when the campaign is paused? How do we handle different timezones? How can we test time-dependent behavior without waiting for real time? This article explains how to design and implement a Call Campaign Simulator using TypeScript. The goal is not only to implement the feature, but also to understand the software engineering concepts behind it. 1. What Is a Call Campaign Simulator? A call campaign simulator receives a list of customers and attempts to call them according to a set of business rules. For example: Customers: 555-0001 555-0002 555-0003 555-0004

Working Hours: 09:00 → 17:00

Maximum Concurrent Calls: 3

Daily Call Limit: 120 minutes

Maximum Retries: 2 The system needs to coordinate all of these constraints. Conceptually: ┌────────────────────┐ │ Customer Queue │ └─────────┬──────────┘ │ ▼ ┌────────────────────┐ │ Campaign Scheduler │ └─────────┬──────────┘ │ ┌─────────────┼─────────────┐ │ │ │ ▼ ▼ ▼ Call #1 Call #2 Call #3 │ │ │ └─────────────┼─────────────┘ │ ▼ ┌─────────────────┐ │ Call Result │ └────────┬────────┘ │ ┌─────────┴─────────┐ │ │ Success Failure │ │ ▼ ▼ Processed Retry │ ▼ Retry Scheduler The important part is that the system is essentially a scheduler + state machine + concurrency controller . 2. Core Requirements Our simulator supports the following features: Sequential Processing Customers are processed in the order they appear in the list. [ " 555-0001 " , " 555-0002 " , " 555-0003 " ] The system should not randomly reorder customers. Working Hours Calls are only allowed during a configured time window. Example: 09:00 → 17:00 If the current time is: 08:30 the campaign must wait. If the current time is: 18:00 the campaign must also wait until the next valid working period. Concurrency Control Suppose: maxConcurrentCalls = 3 ; The system can have at most three active calls: Call A → active Call B → active Call C → active

Call D → waiting Call E → waiting This is a classic concurrency limiting problem. Daily Call Limit Suppose: maxDailyMinutes = 120 ; The campaign cannot consume more than 120 minutes of call time during a calendar day. For example: Call 1 = 30 minutes Call 2 = 40 minutes Call 3 = 20 minutes Call 4 = 30 minutes Total: 30 + 40 + 20 + 30 = 120 minutes The daily limit has been reached. 3. Why This Problem Is Interesting At first glance, the implementation seems simple: for ( const customer of customers ) { await call ( customer ); } But this ignores almost every important requirement. We need to coordinate: Time + Concurrency + Retries + Daily Limits + Pause / Resume + Timezone + State Management This makes the problem a good example of asynchronous system design . 4. State Machine One of the most important concepts in the project is the campaign state. The campaign can have states such as: type CampaignState = | " idle " | " running " | " paused " | " completed " ; The state machine looks like: start() │ ▼ ┌───────┐ │ idle │ └───┬───┘ │ ▼ ┌─────────┐ ┌────▶│ running │──────┐ │ └────┬────┘ │ │ │ │ resume() pause() completed │ │ │ │ ▼ ▼ └──────┌────────┐ ┌───────────┐ │ paused │ │ completed │ └────────┘ └───────────┘ This is a Finite State Machine (FSM) . The important idea is that not every operation should be allowed from every state. For example: idle → start() valid

running → pause() valid

paused → resume() valid

completed → resume() invalid This prevents invalid transitions. 5. Data Model The configuration can be represented using an interface. export interface CampaignConfig { customerList : string []; startTime : string ; endTime : string ; maxConcurrentCalls : number ; maxDailyMinutes : number ; maxRetries ?: number ; retryDelayMs ?: number ; timezone ?: string ; } The configuration describes the rules of the campaign. 6. Call Handler The campaign should not know how the actual call is performed. Instead, we inject a function. export type CallHandler = ( phoneNumber : string ) => Promise < { answered : boolean ; durationMs : number ; } > ; This is an important software design principle: Separate business logic from infrastructure logic. The Campaign class manages: Scheduling Concurrency Retries Limits State The CallHandler manages: Actual call operation This is similar to the Dependency Inversion Principle . 7. Why Dependency Injection Is Useful Here Imagine we directly write: class Campaign { async makeCall ( phoneNumber : string ) { // real telephony API } } Testing becomes difficult because every test would depend on the external telephony system. Instead: class Campaign { constructor ( private callHandler : CallHandler ) {} } Now tests can inject: const fakeCallHandler : CallHandler = async () => ({ answered : true , durationMs : 5000 }); This gives us deterministic tests. 8. The Clock Abstraction Time is another dependency. A naive implementation might use: Date . now (); setTimeout (...); clearTimeout (...); everywhere. That makes testing time-dependent behavior difficult. Instead, we define: export interface IClock { now (): number ; setTimeout ( callback : () => void , delayMs : number ): number ; clearTimeout ( id : number ): void ; } The campaign now depends on an abstraction instead of directly depending on real system time. 9. Injected Clock Production code can use the real clock: const clock : IClock = { now : () => Date . now (), setTimeout : ( callback , delayMs ) => setTimeout ( callback , delayMs ) as unknown as number , clearTimeout : ( id ) => clearTimeout ( id ) }; But tests can provide a fake clock. For example: class FakeClock implements IClock { private currentTime = 0 ; now (): number { return this . currentTime ; } setTimeout ( callback : () => void , delayMs : number ): number { return 1 ; } clearTimeout ( id : number ): void {} } Now tests don't have to wait for real time. 10. Working Hours Suppose: startTime = " 09:00 " ; endTime = " 17:00 " ; We need to determine whether the current time is inside the allowed interval. Conceptually: function isWithinWorkingHours ( currentTime : Date ): boolean { // convert current time to configured timezone // extract hours and minutes // compare against startTime and endTime } For example: 08:59 → false 09:00 → true 12:30 → true 16:59 → true 17:00 → false Boundary conditions are important. 11. Waiting Until Working Hours Suppose the campaign starts at: 07:30 and working hours are: 09:00 → 17:00 The campaign should not immediately call a customer. Instead: 07:30 │ │ wait ▼ 09:00 │ ▼ Start calling The scheduler needs to calculate: delay = nextWorkingStart - currentTime ; Then: clock . setTimeout ( () => processNext (), delay ); 12. Concurrency Control Concurrency is one of the most important parts of the system. Suppose: maxConcurrentCalls = 2 ; and we have: Customer A Customer B Customer C Customer D The system should produce: Time →

A ──────────────── B ────────── C ─────────────── D ────────── But never: A ─────── B ─────── C ─────── ❌ because that would mean three simultaneous calls. 13. Concurrency Counter A simple mechanism is: private activeCalls = 0 ; Before starting a call: if ( this . activeCalls >= this . config . maxConcurrentCalls ) { return ; } When the call starts: this . activeCalls ++ ; When it finishes: this . activeCalls -- ; This gives us a basic semaphore-like mechanism. 14. Semaphore Concept A semaphore controls access to a limited resource. If we have: 3 permits then only three operations can execute simultaneously. Initially: Available permits = 3 Call A: Available = 2 Call B: Available = 1 Call C: Available = 0 Call D: WAIT When A finishes: Available = 1 D can now start. This is exactly the type of resource management our campaign needs. 15. Daily Cap Suppose: maxDailyMinutes = 120 ; We maintain: private dailyMinutesUsed = 0 ; When a call finishes: const minutes = durationMs / 60000 ; this . dailyMinutesUsed += minutes ; Before starting another call, we need to verify that the daily limit hasn't been reached. 16. Important Question: When Do We Count Call Minutes? This is a business-rule decision. Consider: Remaining capacity = 5 minutes and a call lasts: 10 minutes There are several possible policies. Policy A — Allow the call Start the call and count the actual duration afterward. Policy B — Reject the call Don't start a call that could exceed the daily limit. Policy C — Allow but cap accounting Count only the remaining available minutes. For a simulator, the cleanest approach is usually to clearly define the behavior in the requirements and implement it consistently. This illustrates an important engineering principle: Business rules should be explicit rather than hidden inside implementation details. 17. Daily Reset The daily cap is not permanent. For example: Monday: 120 minutes used

Tuesday: 0 minutes used So the system needs to know when a new calendar day begins. This becomes more complicated when timezones are introduced. 18. Why Timezones Matter Imagine: timezone = " America/New_York " ; The application server might actually be running in: UTC The campaign's business rules should still use: New York time For example: Server: 14:00 UTC

New York: 10:00 If working hours are: 09:00 → 17:00 the call is allowed. Therefore, we should never blindly use server-local time. 19. IANA Timezones The project uses IANA timezone identifiers. Examples: America/New_York Europe/London Asia/Tokyo Africa/Cairo These identifiers allow the application to correctly interpret local time. A library such as Luxon makes this easier. Example: import { DateTime } from " luxon " ; const now = DateTime . now () . setZone ( " America/New_York " ); console . log ( now . toISO ()); 20. DST — Daylight Saving Time Timezone handling becomes even more interesting with DST. For example, some countries change their clocks during the year. A hardcoded offset such as: UTC-5 is therefore dangerous. The correct approach is to use: America/New_York instead of manually calculating: UTC - 5 The timezone database can determine the correct offset. This is one reason timezone-aware libraries are useful. 21. Retry Logic Calls can fail. For example: Customer A → success Customer B → failed Customer C → success The system shouldn't necessarily permanently fail Customer B. Instead: Attempt 1 │ ▼ Failed │ ▼ Wait │ ▼ Attempt 2 If it fails again: Attempt 2 │ ▼ Failed │ ▼ Attempt 3 After the maximum retry count: Permanently Failed 22. Retry Configuration The configuration contains: maxRetries : 2 , retryDelayMs : 3600000 This means the system can retry a failed call according to the configured retry policy. The delay: 3600000 ms equals: 1 hour because: 1000 ms = 1 second

60 seconds = 1 minute

60 minutes = 1 hour Therefore: 1000 × 60 × 60 = 3,600,000 ms 23. Retry Queue Instead of immediately retrying: await retry (); we can schedule it: clock . setTimeout ( () => retryCustomer ( customer ), retryDelayMs ); The system can maintain: private pendingRetries = 0 ; When a retry is scheduled: this . pendingRetries ++ ; When the retry starts: this . pendingRetries -- ; 24. Exponential Backoff A more advanced retry strategy is exponential backoff. Instead of: 1 hour 1 hour 1 hour we could use: 1 minute 2 minutes 4 minutes 8 minutes The formula is: delay = baseDelay * Math . pow ( 2 , attempt ); For example: attempt 0 → 1 minute attempt 1 → 2 minutes attempt 2 → 4 minutes attempt 3 → 8 minutes This is commonly used in distributed systems. 25. Pause and Resume The campaign supports: campaign . pause (); and: campaign . resume (); The important business rule is: Pausing the campaign does not necessarily cancel active calls. For example: Call A ────────────────► finishes Call B ───────────► finishes

Campaign pause() │ ▼ PAUSED Existing calls can finish while new calls should not be started. 26. Pause vs Cancellation These concepts are different. Pause Stops starting new work. Cancellation Attempts to terminate existing work. For example: Pause:

Existing calls → continue New calls → blocked Whereas cancellation could mean: Existing calls → terminate if possible New calls → blocked This distinction is important when designing asynchronous systems. 27. Campaign Status The system exposes: interface CampaignStatus { state : | " idle " | " running " | " paused " | " completed " ; totalProcessed : number ; totalFailed : number ; activeCalls : number ; pendingRetries : number ; dailyMinutesUsed : number ; } This gives the outside world a snapshot of the campaign. Example: const status = campaign . getStatus (); console . log ( status ); Possible output: { state: "running", totalProcessed: 25, totalFailed: 2, activeCalls: 3, pendingRetries: 1, dailyMinutesUsed: 87.5 } 28. The Campaign Class The central class can look conceptually like this: export class Campaign { constructor ( private config : CampaignConfig , private callHandler : CallHandler , private clock : IClock ) {} start (): void { // start campaign } pause (): void { // pause campaign } resume (): void { // resume campaign } getStatus (): CampaignStatus { // return campaign state } } This keeps the public API small. 29. Internal State The campaign needs internal state such as: private state : CampaignState = " idle " ; private currentIndex = 0 ; private activeCalls = 0 ; private totalProcessed = 0 ; private totalFailed = 0 ; private pendingRetries = 0 ; private dailyMinutesUsed = 0 ; These variables represent the current state of the campaign. 30. Processing the Customer Queue A basic processing function might look like: private processNext (): void { if ( this . state !== " running " ) { return ; } if ( this . activeCalls >= this . config . maxConcurrentCalls ) { return ; } if ( this . currentIndex >= this . config . customerList . length ) { this . checkCompletion (); return ; } const customer = this . config . customerList [ this . currentIndex ]; this . currentIndex ++ ; this . executeCall ( customer ); } The important idea is that every condition acts as a gate. 31. The Scheduling Pipeline Before starting a call, we conceptually check: Is campaign running? │ ▼ Are we inside working hours? │ ▼ Is concurrency available? │ ▼ Is daily capacity available? │ ▼ Start call This can be represented as: ┌───────────────┐ │ Campaign │ │ running? │ └───────┬───────┘ │ yes ▼ ┌───────────────┐ │ Working │ │ hours? │ └───────┬───────┘ │ yes ▼ ┌───────────────┐ │ Concurrency │ │ available? │ └───────┬───────┘ │ yes ▼ ┌───────────────┐ │ Daily cap │ │ available? │ └───────┬───────┘ │ yes ▼ START CALL This is effectively a scheduling decision tree. 32. Executing a Call The call itself can be handled asynchronously: private async executeCall ( phoneNumber : string ): Promise < void > { this . activeCalls ++ ; try { const result = await this . callHandler ( phoneNumber ); this . handleCallResult ( phoneNumber , result ); } finally { this . activeCalls -- ; this . processAvailableCalls (); } } The finally block is particularly important. Whether the call succeeds or fails: this . activeCalls -- ; must happen. Otherwise the campaign could become permanently stuck. 33. Why finally Matters Bad implementation: try { await call (); activeCalls -- ; } catch { // activeCalls never decremented } If the call throws an exception: activeCalls = 1

call throws

activeCalls stays 1 Eventually the system may believe that the concurrency limit has been reached forever. Better: try { await call (); } finally { activeCalls -- ; } This is a classic resource-cleanup pattern. 34. Handling Success Suppose the call result is: { answered : true , durationMs : 120000 } We can calculate: const minutes = durationMs / 60000 ; Then: this . dailyMinutesUsed += minutes ; this . totalProcessed ++ ; The customer is now considered successfully processed. 35. Handling Failure Suppose: { answered : false , durationMs : 5000 } The business rule can decide that the call should be retried. Conceptually: if ( attempt < maxRetries ) { scheduleRetry (); } else { totalFailed ++ ; } This creates two possible paths: Failure │ ├── retries remaining → Retry │ └── no retries → Permanent failure 36. Attempt Tracking Each customer needs retry information. For example: private retryAttempts = new Map < string , number > (); When a customer fails: const attempts = this . retryAttempts . get ( phoneNumber ) ?? 0 ; this . retryAttempts . set ( phoneNumber , attempts + 1 ); This allows the system to know how many times a particular customer has already been attempted. 37. Important Design Question: Customer Identity Using the phone number as a key is convenient: Map < string , number > But in a real system, phone numbers might not be unique or stable. A production system would usually have: customerId phoneNumber For example: interface Customer { id : string ; phoneNumber : string ; } Then retry state can use: Map < CustomerId , RetryState > This is more robust. 38. Sequential Processing vs Concurrent Execution The requirement says: Process the customer list sequentially. This does not necessarily mean: await customer1 ; await customer2 ; await customer3 ; because that would make concurrency impossible. Instead, sequential processing usually means: Customers are selected from the queue in order, while multiple selected calls may execute concurrently up to the configured limit. For example: Queue:

A B C D E

maxConcurrent = 2

A ────────── B ─────────────

C ───────── D ────────

E ───────── The selection order remains: A → B → C → D → E while execution overlaps. This distinction is very important. 39. Completion Detection The campaign should only become: " completed " when there is no more work. That usually means: Customer queue empty + Active calls = 0 + Pending retries = 0 Therefore: if ( currentIndex >= customerList . length && activeCalls === 0 && pendingRetries === 0 ) { state = " completed " ; } This avoids declaring completion