32 KiB
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:
- Recurrencia Automática: Generación de tareas en intervalos configurables
- Patrones de Recurrencia: Diario, semanal, mensual, anual con reglas personalizadas
- Templates de Tareas: Plantillas ocultas para configuración de recurrencia
- Scheduler: Cron job para creación automática de tareas
- 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