erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-TAREAS-RECURRENTES.md

32 KiB

SPEC-TAREAS-RECURRENTES

Metadatos

Campo Valor
Código SPEC-TRANS-015
Versión 1.0.0
Fecha 2025-01-15
Autor Requirements-Analyst Agent
Estado DRAFT
Prioridad P0
Módulos Afectados MGN-011 (Proyectos)
Gaps Cubiertos GAP-MGN-011-001

Resumen Ejecutivo

Esta especificación define el sistema de tareas recurrentes para ERP Core:

  1. Recurrencia Automática: Generación de tareas en intervalos configurables
  2. Patrones de Recurrencia: Diario, semanal, mensual, anual con reglas personalizadas
  3. Templates de Tareas: Plantillas ocultas para configuración de recurrencia
  4. Scheduler: Cron job para creación automática de tareas
  5. Condiciones de Fin: Forever, hasta fecha, o número máximo de repeticiones

Referencia Odoo 18

Basado en análisis del módulo project de Odoo 18:

  • project.task.recurrence: Modelo de reglas de recurrencia
  • project.task con recurring_task=True: Templates ocultos
  • Diseño mejorado en PR #98088: templates separados de instancias

Parte 1: Arquitectura del Sistema

1.1 Visión General

┌─────────────────────────────────────────────────────────────────────────┐
│                    SISTEMA DE TAREAS RECURRENTES                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐               │
│  │   Usuario    │───▶│ Configura    │───▶│  Recurrence  │               │
│  │              │    │ Recurrencia  │    │   Rules      │               │
│  └──────────────┘    └──────────────┘    └──────┬───────┘               │
│                                                  │                       │
│                                                  ▼                       │
│                             ┌────────────────────────────────┐          │
│                             │   Template Task (hidden)       │          │
│                             │   recurring_task = true        │          │
│                             └────────────────┬───────────────┘          │
│                                              │                          │
│         ┌────────────────────────────────────┼──────────────┐           │
│         │                                    │              │           │
│         │              Scheduled Job (Cron)  │              │           │
│         │                                    │              │           │
│         ▼                                    ▼              ▼           │
│  ┌─────────────┐              ┌─────────────┐      ┌─────────────┐      │
│  │   Task #1   │              │   Task #2   │      │   Task #N   │      │
│  │  (visible)  │              │  (visible)  │ ...  │  (visible)  │      │
│  └─────────────┘              └─────────────┘      └─────────────┘      │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

1.2 Flujo de Recurrencia

1. CONFIGURAR RECURRENCIA
   ├─ Usuario abre tarea
   ├─ Activa botón "(Recurrent)"
   └─ Define patrón (cada X días/semanas/meses/años)
        │
        ▼
2. CREAR TEMPLATE
   ├─ Sistema crea task_recurrence record
   ├─ Marca tarea como template (recurring_task=true)
   └─ Template queda oculto en vistas
        │
        ▼
3. SCHEDULER EJECUTA
   ├─ Cron job revisa recurrencias activas
   ├─ Calcula próxima fecha de ocurrencia
   └─ Si fecha <= hoy → crear nueva tarea
        │
        ▼
4. GENERAR TAREA
   ├─ Copiar campos del template
   ├─ Calcular nuevo deadline
   └─ Nueva tarea visible para usuario
        │
        ▼
5. COMPLETAR / REPETIR
   ├─ Usuario completa tarea
   ├─ Scheduler genera siguiente
   └─ Loop hasta condición de fin

Parte 2: Modelo de Datos

2.1 Diagrama Entidad-Relación

┌─────────────────────────┐
│   task_recurrences      │
│─────────────────────────│
│ id (PK)                 │
│ name                    │
│ repeat_interval         │
│ repeat_unit             │
│ repeat_type             │
│ repeat_until            │
│ repeat_count            │
│ repeat_day              │◀────────────┐
│ repeat_weekdays         │             │
│ template_task_id (FK)   │─────┐       │
│ last_occurrence         │     │       │
│ next_occurrence         │     │       │
│ active                  │     │       │
└─────────────────────────┘     │       │
                                │       │
              ┌─────────────────┘       │
              ▼                         │
┌─────────────────────────┐             │
│   project_tasks         │             │
│─────────────────────────│             │
│ id (PK)                 │             │
│ name                    │             │
│ project_id (FK)         │             │
│ user_id (FK)            │             │
│ date_deadline           │             │
│ state                   │             │
│ ...                     │             │
│ recurring_task (bool)   │─────────────┘
│ recurrence_id (FK)      │──────────────▶ task_recurrences
└─────────────────────────┘

2.2 Definición de Tablas

task_recurrences (Reglas de Recurrencia)

-- Reglas de recurrencia para tareas
CREATE TABLE task_recurrences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación
    name VARCHAR(255),

    -- Intervalo de repetición
    repeat_interval INTEGER NOT NULL DEFAULT 1,
    repeat_unit recurrence_unit NOT NULL DEFAULT 'week',

    -- Condición de fin
    repeat_type recurrence_end_type NOT NULL DEFAULT 'forever',
    repeat_until DATE, -- Usado si repeat_type = 'until'
    repeat_count INTEGER, -- Usado si repeat_type = 'count'
    occurrence_count INTEGER NOT NULL DEFAULT 0, -- Contador actual

    -- Configuración específica por unidad
    -- Para repeat_unit = 'month'
    repeat_day INTEGER CHECK (repeat_day BETWEEN 1 AND 31),
    repeat_on_month repeat_month_basis DEFAULT 'date',
    -- 'date' = mismo día del mes (ej: día 15)
    -- 'day' = mismo día de semana (ej: segundo martes)

    -- Para repeat_unit = 'week'
    repeat_weekdays SMALLINT DEFAULT 0, -- Bitmask: Dom=1, Lun=2, Mar=4...
    -- Ejemplo: Lun+Mié+Vie = 2+8+32 = 42

    -- Para repeat_unit = 'year'
    repeat_month INTEGER CHECK (repeat_month BETWEEN 1 AND 12),

    -- Anticipación
    create_before_deadline INTEGER DEFAULT 0, -- Días antes del deadline

    -- Template
    template_task_id UUID NOT NULL REFERENCES project_tasks(id) ON DELETE CASCADE,

    -- Control de ejecución
    last_occurrence_date DATE,
    next_occurrence_date DATE NOT NULL,
    active BOOLEAN NOT NULL DEFAULT true,

    -- Auditoría
    company_id UUID NOT NULL REFERENCES companies(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by UUID NOT NULL REFERENCES users(id),
    updated_by UUID NOT NULL REFERENCES users(id)
);

CREATE TYPE recurrence_unit AS ENUM ('day', 'week', 'month', 'year');
CREATE TYPE recurrence_end_type AS ENUM ('forever', 'until', 'count');
CREATE TYPE repeat_month_basis AS ENUM ('date', 'day');

CREATE INDEX idx_task_recurrences_next ON task_recurrences(next_occurrence_date)
    WHERE active = true;
CREATE INDEX idx_task_recurrences_template ON task_recurrences(template_task_id);

Extensión de project_tasks

-- Agregar campos de recurrencia a tareas
ALTER TABLE project_tasks ADD COLUMN IF NOT EXISTS
    recurring_task BOOLEAN NOT NULL DEFAULT false;

ALTER TABLE project_tasks ADD COLUMN IF NOT EXISTS
    recurrence_id UUID REFERENCES task_recurrences(id) ON DELETE SET NULL;

-- Índice para filtrar templates
CREATE INDEX idx_project_tasks_recurring ON project_tasks(recurring_task);

-- Las vistas deben filtrar: WHERE recurring_task = false
COMMENT ON COLUMN project_tasks.recurring_task IS
    'True para tareas template (ocultas). False para tareas normales visibles.';

Parte 3: Servicios de Aplicación

3.1 TaskRecurrenceService

// src/modules/project/services/task-recurrence.service.ts

import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, LessThanOrEqual } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { TaskRecurrence, RecurrenceUnit, RecurrenceEndType } from '../entities/task-recurrence.entity';
import { ProjectTask } from '../entities/project-task.entity';

@Injectable()
export class TaskRecurrenceService {
  constructor(
    @InjectRepository(TaskRecurrence)
    private readonly recurrenceRepo: Repository<TaskRecurrence>,
    @InjectRepository(ProjectTask)
    private readonly taskRepo: Repository<ProjectTask>,
    private readonly dataSource: DataSource,
  ) {}

  /**
   * Configurar recurrencia en una tarea existente
   */
  async setupRecurrence(
    taskId: string,
    dto: SetupRecurrenceDto,
    userId: string
  ): Promise<TaskRecurrence> {
    const task = await this.taskRepo.findOneOrFail({
      where: { id: taskId }
    });

    // Validar que no sea subtarea
    if (task.parentId) {
      throw new BadRequestException('Las subtareas no pueden tener recurrencia');
    }

    // Validar que no tenga ya recurrencia
    if (task.recurrenceId) {
      throw new BadRequestException('La tarea ya tiene recurrencia configurada');
    }

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Calcular primera ocurrencia
      const nextDate = this.calculateNextOccurrence(
        task.dateDeadline || new Date(),
        dto
      );

      // Crear regla de recurrencia
      const recurrence = this.recurrenceRepo.create({
        name: `Recurrencia: ${task.name}`,
        repeatInterval: dto.repeatInterval,
        repeatUnit: dto.repeatUnit,
        repeatType: dto.repeatType,
        repeatUntil: dto.repeatUntil,
        repeatCount: dto.repeatCount,
        repeatDay: dto.repeatDay,
        repeatOnMonth: dto.repeatOnMonth,
        repeatWeekdays: dto.repeatWeekdays,
        repeatMonth: dto.repeatMonth,
        createBeforeDeadline: dto.createBeforeDeadline || 0,
        templateTaskId: task.id,
        nextOccurrenceDate: nextDate,
        companyId: task.companyId,
        createdBy: userId,
        updatedBy: userId,
      });

      const savedRecurrence = await queryRunner.manager.save(recurrence);

      // Marcar tarea como template (oculta)
      task.recurringTask = true;
      task.recurrenceId = savedRecurrence.id;
      task.updatedBy = userId;

      await queryRunner.manager.save(task);

      // Crear primera tarea visible
      await this.createTaskFromTemplate(task, savedRecurrence, queryRunner, userId);

      await queryRunner.commitTransaction();

      return savedRecurrence;
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

  /**
   * Calcular próxima fecha de ocurrencia
   */
  calculateNextOccurrence(
    baseDate: Date,
    config: RecurrenceConfig
  ): Date {
    const date = new Date(baseDate);

    switch (config.repeatUnit) {
      case RecurrenceUnit.DAY:
        date.setDate(date.getDate() + config.repeatInterval);
        break;

      case RecurrenceUnit.WEEK:
        if (config.repeatWeekdays) {
          // Encontrar próximo día habilitado
          date.setDate(date.getDate() + 1);
          while (!this.isDayEnabled(date, config.repeatWeekdays)) {
            date.setDate(date.getDate() + 1);
          }
        } else {
          date.setDate(date.getDate() + (config.repeatInterval * 7));
        }
        break;

      case RecurrenceUnit.MONTH:
        if (config.repeatOnMonth === 'date') {
          // Mismo día del mes
          date.setMonth(date.getMonth() + config.repeatInterval);
          if (config.repeatDay) {
            // Ajustar al día específico, manejando meses cortos
            const maxDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
            date.setDate(Math.min(config.repeatDay, maxDay));
          }
        } else {
          // Mismo día de semana (ej: segundo martes)
          const weekOfMonth = Math.ceil(baseDate.getDate() / 7);
          const dayOfWeek = baseDate.getDay();
          date.setMonth(date.getMonth() + config.repeatInterval);
          date.setDate(1);
          // Encontrar el N-ésimo día de la semana
          while (date.getDay() !== dayOfWeek) {
            date.setDate(date.getDate() + 1);
          }
          date.setDate(date.getDate() + (weekOfMonth - 1) * 7);
        }
        break;

      case RecurrenceUnit.YEAR:
        date.setFullYear(date.getFullYear() + config.repeatInterval);
        if (config.repeatMonth) {
          date.setMonth(config.repeatMonth - 1);
        }
        if (config.repeatDay) {
          const maxDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
          date.setDate(Math.min(config.repeatDay, maxDay));
        }
        break;
    }

    return date;
  }

  /**
   * Verificar si un día está habilitado en bitmask de weekdays
   */
  private isDayEnabled(date: Date, weekdaysMask: number): boolean {
    const dayBit = 1 << date.getDay(); // Dom=1, Lun=2, Mar=4...
    return (weekdaysMask & dayBit) !== 0;
  }

  /**
   * Crear tarea desde template
   */
  async createTaskFromTemplate(
    template: ProjectTask,
    recurrence: TaskRecurrence,
    queryRunner: any,
    userId: string
  ): Promise<ProjectTask> {
    // Calcular deadline de nueva tarea
    let newDeadline: Date | null = null;
    if (template.dateDeadline) {
      // Mantener el delta entre creación y deadline original
      const creationDate = template.createdAt;
      const originalDeadline = template.dateDeadline;
      const deltaMs = originalDeadline.getTime() - creationDate.getTime();

      newDeadline = new Date(recurrence.nextOccurrenceDate);
      newDeadline.setTime(newDeadline.getTime() + deltaMs);
    }

    // Crear nueva tarea (copia de template)
    const newTask = this.taskRepo.create({
      name: template.name,
      description: template.description,
      projectId: template.projectId,
      userId: template.userId,
      assigneeIds: template.assigneeIds,
      partnerId: template.partnerId,
      tagIds: template.tagIds,
      priority: template.priority,
      dateDeadline: newDeadline,
      plannedHours: template.plannedHours,

      // NO copiar:
      // - milestoneId
      // - parentId (subtareas)
      // - timesheets

      recurringTask: false, // Esta es visible
      recurrenceId: recurrence.id,
      companyId: template.companyId,
      createdBy: userId,
      updatedBy: userId,
    });

    return queryRunner.manager.save(newTask);
  }

  /**
   * Job programado para crear tareas recurrentes
   * Ejecuta cada hora
   */
  @Cron(CronExpression.EVERY_HOUR)
  async processRecurringTasks(): Promise<void> {
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    // Buscar recurrencias activas con próxima fecha <= hoy
    const dueRecurrences = await this.recurrenceRepo.find({
      where: {
        active: true,
        nextOccurrenceDate: LessThanOrEqual(today),
      },
      relations: ['templateTask'],
    });

    for (const recurrence of dueRecurrences) {
      await this.processRecurrence(recurrence);
    }
  }

  /**
   * Procesar una recurrencia individual
   */
  private async processRecurrence(recurrence: TaskRecurrence): Promise<void> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Verificar condición de fin
      if (this.shouldStopRecurrence(recurrence)) {
        recurrence.active = false;
        await queryRunner.manager.save(recurrence);
        await queryRunner.commitTransaction();
        return;
      }

      // Crear nueva tarea
      await this.createTaskFromTemplate(
        recurrence.templateTask,
        recurrence,
        queryRunner,
        recurrence.createdBy // Sistema
      );

      // Actualizar recurrencia
      recurrence.lastOccurrenceDate = recurrence.nextOccurrenceDate;
      recurrence.nextOccurrenceDate = this.calculateNextOccurrence(
        recurrence.nextOccurrenceDate,
        {
          repeatInterval: recurrence.repeatInterval,
          repeatUnit: recurrence.repeatUnit,
          repeatDay: recurrence.repeatDay,
          repeatOnMonth: recurrence.repeatOnMonth,
          repeatWeekdays: recurrence.repeatWeekdays,
          repeatMonth: recurrence.repeatMonth,
        }
      );
      recurrence.occurrenceCount += 1;

      await queryRunner.manager.save(recurrence);

      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
      console.error(`Error procesando recurrencia ${recurrence.id}:`, error);
    } finally {
      await queryRunner.release();
    }
  }

  /**
   * Verificar si debe detenerse la recurrencia
   */
  private shouldStopRecurrence(recurrence: TaskRecurrence): boolean {
    switch (recurrence.repeatType) {
      case RecurrenceEndType.FOREVER:
        return false;

      case RecurrenceEndType.UNTIL:
        return recurrence.repeatUntil &&
               recurrence.nextOccurrenceDate > recurrence.repeatUntil;

      case RecurrenceEndType.COUNT:
        return recurrence.repeatCount &&
               recurrence.occurrenceCount >= recurrence.repeatCount;

      default:
        return false;
    }
  }

  /**
   * Detener recurrencia
   */
  async stopRecurrence(taskId: string, userId: string): Promise<void> {
    const task = await this.taskRepo.findOneOrFail({
      where: { id: taskId },
      relations: ['recurrence']
    });

    if (!task.recurrenceId) {
      throw new BadRequestException('La tarea no tiene recurrencia');
    }

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Desactivar recurrencia
      await queryRunner.manager.update(TaskRecurrence,
        { id: task.recurrenceId },
        { active: false, updatedBy: userId }
      );

      // Eliminar template si existe
      const recurrence = task.recurrence;
      if (recurrence?.templateTaskId) {
        // Eliminar template (las tareas hijas quedan con recurrence_id = null)
        await queryRunner.manager.delete(ProjectTask, {
          id: recurrence.templateTaskId
        });
      }

      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

  /**
   * Obtener todas las tareas de una recurrencia
   */
  async getRecurrenceTasks(recurrenceId: string): Promise<ProjectTask[]> {
    return this.taskRepo.find({
      where: {
        recurrenceId,
        recurringTask: false, // Solo visibles
      },
      order: { dateDeadline: 'ASC' },
    });
  }

  /**
   * Actualizar template (afecta futuras ocurrencias)
   */
  async updateRecurrenceTemplate(
    recurrenceId: string,
    dto: UpdateTemplateDto,
    userId: string
  ): Promise<ProjectTask> {
    const recurrence = await this.recurrenceRepo.findOneOrFail({
      where: { id: recurrenceId },
      relations: ['templateTask']
    });

    // Actualizar template
    Object.assign(recurrence.templateTask, dto);
    recurrence.templateTask.updatedBy = userId;

    return this.taskRepo.save(recurrence.templateTask);
  }
}

3.2 DTOs y Validaciones

// src/modules/project/dto/task-recurrence.dto.ts

export class SetupRecurrenceDto {
  @IsInt()
  @Min(1)
  @Max(365)
  repeatInterval: number;

  @IsEnum(RecurrenceUnit)
  repeatUnit: RecurrenceUnit;

  @IsEnum(RecurrenceEndType)
  repeatType: RecurrenceEndType;

  @IsDate()
  @IsOptional()
  @ValidateIf(o => o.repeatType === RecurrenceEndType.UNTIL)
  repeatUntil?: Date;

  @IsInt()
  @Min(1)
  @Max(999)
  @IsOptional()
  @ValidateIf(o => o.repeatType === RecurrenceEndType.COUNT)
  repeatCount?: number;

  @IsInt()
  @Min(1)
  @Max(31)
  @IsOptional()
  repeatDay?: number;

  @IsEnum(RepeatMonthBasis)
  @IsOptional()
  repeatOnMonth?: RepeatMonthBasis;

  @IsInt()
  @Min(0)
  @Max(127) // Bitmask para 7 días
  @IsOptional()
  repeatWeekdays?: number;

  @IsInt()
  @Min(1)
  @Max(12)
  @IsOptional()
  repeatMonth?: number;

  @IsInt()
  @Min(0)
  @Max(30)
  @IsOptional()
  createBeforeDeadline?: number;
}

// Helper para weekdays
export const WEEKDAY_FLAGS = {
  SUNDAY: 1,    // 2^0
  MONDAY: 2,    // 2^1
  TUESDAY: 4,   // 2^2
  WEDNESDAY: 8, // 2^3
  THURSDAY: 16, // 2^4
  FRIDAY: 32,   // 2^5
  SATURDAY: 64, // 2^6
};

// Ejemplo: Lunes, Miércoles, Viernes
// MONDAY | WEDNESDAY | FRIDAY = 2 | 8 | 32 = 42

Parte 4: API REST

4.1 Endpoints

// src/modules/project/controllers/task-recurrence.controller.ts

@Controller('api/v1/tasks/:taskId/recurrence')
@UseGuards(JwtAuthGuard)
@ApiTags('Task Recurrence')
export class TaskRecurrenceController {
  constructor(
    private readonly recurrenceService: TaskRecurrenceService,
  ) {}

  @Post()
  @ApiOperation({ summary: 'Configurar recurrencia en tarea' })
  async setupRecurrence(
    @Param('taskId', ParseUUIDPipe) taskId: string,
    @Body() dto: SetupRecurrenceDto,
    @CurrentUser() user: User,
  ): Promise<TaskRecurrenceResponseDto> {
    const recurrence = await this.recurrenceService.setupRecurrence(
      taskId, dto, user.id
    );
    return this.mapToResponse(recurrence);
  }

  @Get()
  @ApiOperation({ summary: 'Obtener configuración de recurrencia' })
  async getRecurrence(
    @Param('taskId', ParseUUIDPipe) taskId: string,
  ): Promise<TaskRecurrenceResponseDto> {
    return this.recurrenceService.getRecurrenceByTask(taskId);
  }

  @Patch()
  @ApiOperation({ summary: 'Actualizar recurrencia' })
  async updateRecurrence(
    @Param('taskId', ParseUUIDPipe) taskId: string,
    @Body() dto: UpdateRecurrenceDto,
    @CurrentUser() user: User,
  ): Promise<TaskRecurrenceResponseDto> {
    return this.recurrenceService.updateRecurrence(taskId, dto, user.id);
  }

  @Delete()
  @ApiOperation({ summary: 'Detener recurrencia' })
  async stopRecurrence(
    @Param('taskId', ParseUUIDPipe) taskId: string,
    @CurrentUser() user: User,
  ): Promise<void> {
    return this.recurrenceService.stopRecurrence(taskId, user.id);
  }

  @Get('tasks')
  @ApiOperation({ summary: 'Listar todas las tareas de la recurrencia' })
  async listRecurrenceTasks(
    @Param('taskId', ParseUUIDPipe) taskId: string,
  ): Promise<ProjectTaskResponseDto[]> {
    const task = await this.recurrenceService.getTaskById(taskId);
    if (!task.recurrenceId) {
      throw new BadRequestException('La tarea no tiene recurrencia');
    }
    return this.recurrenceService.getRecurrenceTasks(task.recurrenceId);
  }

  @Post('generate-next')
  @ApiOperation({ summary: 'Generar siguiente tarea manualmente (debug)' })
  @Roles('admin')
  async generateNext(
    @Param('taskId', ParseUUIDPipe) taskId: string,
    @CurrentUser() user: User,
  ): Promise<ProjectTaskResponseDto> {
    return this.recurrenceService.generateNextManually(taskId, user.id);
  }
}

Parte 5: Interfaz de Usuario

5.1 Componente de Configuración

// Frontend: RecurrenceConfigModal.tsx

interface RecurrenceConfig {
  repeatInterval: number;
  repeatUnit: 'day' | 'week' | 'month' | 'year';
  repeatType: 'forever' | 'until' | 'count';
  repeatUntil?: Date;
  repeatCount?: number;
  repeatDay?: number;
  repeatOnMonth?: 'date' | 'day';
  repeatWeekdays?: number;
}

const WEEKDAYS = [
  { label: 'Dom', value: 1 },
  { label: 'Lun', value: 2 },
  { label: 'Mar', value: 4 },
  { label: 'Mié', value: 8 },
  { label: 'Jue', value: 16 },
  { label: 'Vie', value: 32 },
  { label: 'Sáb', value: 64 },
];

export const RecurrenceConfigModal: React.FC<Props> = ({
  task,
  onSave,
  onCancel
}) => {
  const [config, setConfig] = useState<RecurrenceConfig>({
    repeatInterval: 1,
    repeatUnit: 'week',
    repeatType: 'forever',
    repeatWeekdays: 0,
  });

  const toggleWeekday = (value: number) => {
    setConfig(prev => ({
      ...prev,
      repeatWeekdays: (prev.repeatWeekdays || 0) ^ value
    }));
  };

  return (
    <Modal title="Configurar Recurrencia">
      <Form>
        {/* Intervalo */}
        <FormGroup label="Repetir cada">
          <NumberInput
            value={config.repeatInterval}
            min={1}
            onChange={v => setConfig({...config, repeatInterval: v})}
          />
          <Select
            value={config.repeatUnit}
            onChange={v => setConfig({...config, repeatUnit: v})}
          >
            <Option value="day">Día(s)</Option>
            <Option value="week">Semana(s)</Option>
            <Option value="month">Mes(es)</Option>
            <Option value="year">Año(s)</Option>
          </Select>
        </FormGroup>

        {/* Días de la semana (solo para semanal) */}
        {config.repeatUnit === 'week' && (
          <FormGroup label="Repetir en">
            <div className="weekday-selector">
              {WEEKDAYS.map(day => (
                <Chip
                  key={day.value}
                  selected={(config.repeatWeekdays! & day.value) !== 0}
                  onClick={() => toggleWeekday(day.value)}
                >
                  {day.label}
                </Chip>
              ))}
            </div>
          </FormGroup>
        )}

        {/* Día del mes (solo para mensual) */}
        {config.repeatUnit === 'month' && (
          <FormGroup label="Repetir el">
            <RadioGroup
              value={config.repeatOnMonth}
              onChange={v => setConfig({...config, repeatOnMonth: v})}
            >
              <Radio value="date">
                Día {config.repeatDay || task.dateDeadline?.getDate()}
              </Radio>
              <Radio value="day">
                {getOrdinalWeek(task.dateDeadline)} {getDayName(task.dateDeadline)}
              </Radio>
            </RadioGroup>
          </FormGroup>
        )}

        {/* Condición de fin */}
        <FormGroup label="Termina">
          <RadioGroup
            value={config.repeatType}
            onChange={v => setConfig({...config, repeatType: v})}
          >
            <Radio value="forever">Nunca</Radio>
            <Radio value="until">
              El <DatePicker
                value={config.repeatUntil}
                onChange={d => setConfig({...config, repeatUntil: d})}
                disabled={config.repeatType !== 'until'}
              />
            </Radio>
            <Radio value="count">
              Después de <NumberInput
                value={config.repeatCount}
                min={1}
                onChange={v => setConfig({...config, repeatCount: v})}
                disabled={config.repeatType !== 'count'}
              /> ocurrencias
            </Radio>
          </RadioGroup>
        </FormGroup>

        <ButtonGroup>
          <Button onClick={onCancel}>Cancelar</Button>
          <Button variant="primary" onClick={() => onSave(config)}>
            Guardar
          </Button>
        </ButtonGroup>
      </Form>
    </Modal>
  );
};

Parte 6: Consideraciones Especiales

6.1 Filtrado de Templates

// Todas las queries de tareas deben excluir templates
const tasks = await this.taskRepo.find({
  where: {
    projectId,
    recurringTask: false, // IMPORTANTE: excluir templates
  }
});

// O usar scope global en entidad
@Entity()
@Scope({ recurringTask: false })
export class ProjectTask { ... }

6.2 Manejo de Fechas Límite

// Casos especiales para fechas
function handleEdgeCases(date: Date, config: RecurrenceConfig): Date {
  // Febrero 29 → Febrero 28 en años no bisiestos
  if (config.repeatUnit === 'year' && date.getMonth() === 1 && date.getDate() === 29) {
    const year = date.getFullYear();
    if (!isLeapYear(year)) {
      date.setDate(28);
    }
  }

  // Día 31 en meses con 30 días
  if (config.repeatUnit === 'month' && config.repeatDay === 31) {
    const maxDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
    date.setDate(Math.min(31, maxDay));
  }

  return date;
}

6.3 Migración de Datos

-- Índice para performance del scheduler
CREATE INDEX CONCURRENTLY idx_task_recurrences_pending
ON task_recurrences(next_occurrence_date)
WHERE active = true;

-- Vista para dashboard de recurrencias
CREATE VIEW v_recurring_tasks_summary AS
SELECT
    r.id,
    r.name,
    r.repeat_interval,
    r.repeat_unit,
    r.next_occurrence_date,
    COUNT(t.id) as total_occurrences,
    MAX(t.date_deadline) as last_task_deadline
FROM task_recurrences r
LEFT JOIN project_tasks t ON t.recurrence_id = r.id AND t.recurring_task = false
WHERE r.active = true
GROUP BY r.id;

Apéndice A: Patrones de Recurrencia Comunes

Patrón Configuración
Diario interval=1, unit=day
Cada 3 días interval=3, unit=day
Semanal (mismo día) interval=1, unit=week
Lun-Mié-Vie interval=1, unit=week, weekdays=42
Quincenal interval=2, unit=week
Mensual (día fijo) interval=1, unit=month, onMonth=date
Mensual (2do martes) interval=1, unit=month, onMonth=day
Trimestral interval=3, unit=month
Anual interval=1, unit=year

Apéndice B: Checklist de Implementación

  • Modelo task_recurrences (migración)
  • Extensión project_tasks (campos recurrencia)
  • TaskRecurrenceService
  • Cron job para procesamiento
  • Controlador REST
  • DTOs y validaciones
  • Filtrado de templates en queries
  • UI: Modal de configuración
  • UI: Badge de recurrencia en tarjeta
  • UI: Lista de ocurrencias
  • Tests unitarios
  • Tests del scheduler

Documento generado como parte del análisis de gaps ERP Core vs Odoo 18 Referencia: GAP-MGN-011-001 - Tareas Recurrentes