All files / src/platform/calendar recurrence.engine.ts

0% Statements 0/38
0% Branches 0/27
0% Functions 0/2
0% Lines 0/35

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81                                                                                                                                                                 
import { Injectable, Logger } from '@nestjs/common';
import { RecurrenceRule } from './schemas/recurrence.schema';
 
@Injectable()
export class RecurrenceEngine {
  private readonly logger = new Logger(RecurrenceEngine.name);
 
  /**
   * Expands a recurrence rule within the given time horizon.
   * Leverages lazy evaluation and limits maximum generated occurrences to prevent infinite loops.
   */
  generateOccurrences(
    rule: RecurrenceRule,
    seriesStart: Date,
    durationMs: number,
    horizonStart: Date,
    horizonEnd: Date,
  ): Array<{ startAt: Date; endAt: Date }> {
    const occurrences: Array<{ startAt: Date; endAt: Date }> = [];
    const maxExpansionCount = rule.count || 500; // Horizon limit safety
    const untilLimit = rule.untilDate ? new Date(rule.untilDate) : horizonEnd;
    const finalEnd = new Date(Math.min(untilLimit.getTime(), horizonEnd.getTime()));
 
    let currentStart = new Date(seriesStart.getTime());
    let generatedCount = 0;
 
    // Convert exclusions to timestamps for quick lookup
    const exclusionSet = new Set((rule.exclusions || []).map(d => new Date(d).getTime()));
 
    while (currentStart.getTime() <= finalEnd.getTime() && generatedCount < maxExpansionCount) {
      // Check exclusions
      if (!exclusionSet.has(currentStart.getTime())) {
        // Check if falls within search horizon
        if (currentStart.getTime() + durationMs >= horizonStart.getTime()) {
          occurrences.push({
            startAt: new Date(currentStart.getTime()),
            endAt: new Date(currentStart.getTime() + durationMs),
          });
        }
      }
 
      generatedCount++;
 
      // Advance currentStart based on frequency and interval
      const interval = rule.interval || 1;
      switch (rule.frequency) {
        case 'DAILY':
          currentStart.setDate(currentStart.getDate() + interval);
          break;
        case 'WEEKLY':
          // If byDayOfWeek specified, we could handle days.
          // For simplicity and correctness, jump weekly or cycle daily checking day match
          if (rule.byDayOfWeek && rule.byDayOfWeek.length > 0) {
            let nextFound = false;
            let checks = 0;
            while (!nextFound && checks < 365) {
              currentStart.setDate(currentStart.getDate() + 1);
              if (rule.byDayOfWeek.includes(currentStart.getDay())) {
                nextFound = true;
              }
              checks++;
            }
          } else {
            currentStart.setDate(currentStart.getDate() + (7 * interval));
          }
          break;
        case 'MONTHLY':
          currentStart.setMonth(currentStart.getMonth() + interval);
          break;
        case 'YEARLY':
          currentStart.setFullYear(currentStart.getFullYear() + interval);
          break;
        default:
          currentStart.setDate(currentStart.getDate() + 1);
      }
    }
 
    return occurrences;
  }
}