recurrence.ts3.1 KBView on GitHub
/**
 * RRULE ↔ human, in one place.
 *
 * Recurrence is stated three times in this app — the event details panel, the composer that
 * creates an event, and the custom-rule panel behind "Custom…" — and all three have to agree
 * on what `RRULE:FREQ=WEEKLY;BYDAY=TH` reads as. These helpers are that agreement; the control
 * itself is components/RecurrenceSelect.tsx.
 */

export const DAYS_OF_WEEK = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
export const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
export const DAY_ABBRS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];

export function buildWeeklyRRule(date: Date): string {
  const dayCode = DAYS_OF_WEEK[date.getDay()];
  return `RRULE:FREQ=WEEKLY;BYDAY=${dayCode}`;
}

export function rruleLabel(rrule: string): string {
  const rule = rrule.toUpperCase();
  const intervalMatch = rule.match(/INTERVAL=(\d+)/);
  const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
  const countMatch = rule.match(/COUNT=(\d+)/);
  const untilMatch = rule.match(/UNTIL=(\d{8})/);

  let label = '';

  if (rule.includes('FREQ=DAILY')) {
    label = interval === 1 ? 'Daily' : `Every ${interval} days`;
  } else if (rule.includes('FREQ=WEEKLY')) {
    const byDayMatch = rule.match(/BYDAY=([A-Z,]+)/);
    const days = byDayMatch ? byDayMatch[1].split(',') : [];
    const weekdayOnly = days.length === 5 && days.every((d) => ['MO', 'TU', 'WE', 'TH', 'FR'].includes(d));
    if (weekdayOnly) {
      label = interval === 1 ? 'Every weekday (Mon–Fri)' : `Every ${interval} weeks, Mon–Fri`;
    } else {
      const dayLabels = days.map((d) => {
        const i = DAYS_OF_WEEK.indexOf(d);
        return i >= 0 ? DAY_ABBRS[i] : d;
      });
      const daysStr = dayLabels.join(', ');
      label = interval === 1
        ? `Weekly on ${daysStr}`
        : `Every ${interval} weeks on ${daysStr}`;
    }
  } else if (rule.includes('FREQ=MONTHLY')) {
    label = interval === 1 ? 'Monthly' : `Every ${interval} months`;
  } else if (rule.includes('FREQ=YEARLY')) {
    label = interval === 1 ? 'Annually' : `Every ${interval} years`;
  } else {
    label = 'Custom';
  }

  if (countMatch) label += `, ${countMatch[1]} times`;
  else if (untilMatch) {
    const y = untilMatch[1].slice(0, 4);
    const m = untilMatch[1].slice(4, 6);
    const d = untilMatch[1].slice(6, 8);
    label += `, until ${m}/${d}/${y}`;
  }

  return label;
}

export function recurrenceOptions(startDate?: Date) {
  const dayName = startDate ? DAY_NAMES[startDate.getDay()] : 'the selected day';
  return [
    { value: 'none', label: 'Does not repeat' },
    { value: 'RRULE:FREQ=DAILY', label: 'Daily' },
    {
      value: startDate ? buildWeeklyRRule(startDate) : 'RRULE:FREQ=WEEKLY',
      label: `Weekly on ${dayName}`,
    },
    { value: 'RRULE:FREQ=MONTHLY', label: 'Monthly' },
    { value: 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', label: 'Every weekday (Mon–Fri)' },
    { value: 'custom', label: 'Custom…' },
  ];
}

export function parseRecurrenceLabel(recurrence?: string[] | null): string | null {
  if (!recurrence || recurrence.length === 0) return null;
  return rruleLabel(recurrence[0]);
}