From 13a69aadc3779915e533fe47737b70ec9544ea58 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Fri, 11 Sep 2026 10:29:09 -0500 Subject: [PATCH] feat: campo "hasta" opcional en Tareas recurrentes Vacio = se repite sin fin (comportamiento previo). Al completar un ciclo cuyo proximo vencimiento supera "hasta", la tarea se desactiva sola. Probado en dev con curl (3 ciclos seguidos, se desactiva justo al superar la fecha) y validacion de que "hasta" no puede ser anterior al proximo vencimiento. Co-Authored-By: Claude Sonnet 5 --- API.md | 3 ++- CHANGELOG.md | 6 ++++++ VERSION | 2 +- .../011_recurring_tasks_end_date.sql | 3 +++ backend/package.json | 2 +- .../src/modules/recurring-tasks/repository.js | 21 ++++++++++++------- .../src/modules/recurring-tasks/service.js | 4 ++++ frontend/package.json | 2 +- frontend/src/i18n/messages.js | 6 ++++++ .../pages/Proyectos/ActivityDetailPanel.jsx | 11 +++++++--- .../RecurringTasks/RecurringTasksPage.jsx | 14 +++++++++---- 11 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 backend/database/migrations/011_recurring_tasks_end_date.sql diff --git a/API.md b/API.md index 0ef71aa..1db188e 100644 --- a/API.md +++ b/API.md @@ -83,7 +83,8 @@ - `POST /api/recurring-tasks/from-task/:taskId` — crea una tarea recurrente a partir de una tarea puntual de una actividad (la original no se borra); mismo body que `POST /`. - Body: `{ name, description?, projectId?, responsibleUserId?, intervalUnit: 'days'|'months'|'years', - intervalValue, nextDueDate, reminderDaysBefore, reminderHour: 0-23 }`. + intervalValue, nextDueDate, endDate?, reminderDaysBefore, reminderHour: 0-23 }`. `endDate` vacío + = se repite sin fin; al completar un ciclo que la supera, `is_active` pasa a `false` solo. - Recordatorio por correo: un job en el propio proceso del backend revisa cada 15 minutos (`src/jobs/recurringTaskReminders.js`) y manda el correo cuando `next_due_date - reminderDaysBefore` cae hoy, a la hora `reminderHour`. Un solo envío por ciclo diff --git a/CHANGELOG.md b/CHANGELOG.md index eb98569..08f2a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog - project.linkedcorp.net +## [1.12.1] - 2026-09-11 +### Added +- Tareas recurrentes: campo opcional "Hasta" (fecha de fin). Vacío = se repite sin fin (como + hasta ahora). Al completar un ciclo cuyo próximo vencimiento supera "Hasta", la tarea se + desactiva sola — no requiere acción manual. + ## [1.12.0] - 2026-09-11 ### Added - **Tareas recurrentes**: menú propio en la barra lateral, independiente de Proyectos. Cada tarea diff --git a/VERSION b/VERSION index 0eed1a2..f8f4f03 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 +1.12.1 diff --git a/backend/database/migrations/011_recurring_tasks_end_date.sql b/backend/database/migrations/011_recurring_tasks_end_date.sql new file mode 100644 index 0000000..450fab7 --- /dev/null +++ b/backend/database/migrations/011_recurring_tasks_end_date.sql @@ -0,0 +1,3 @@ +-- "Hasta cuando" opcional para Tareas recurrentes: vacio = indefinida. +ALTER TABLE recurring_tasks + ADD COLUMN IF NOT EXISTS end_date DATE NULL AFTER next_due_date; diff --git a/backend/package.json b/backend/package.json index 4c89fb8..a161e8a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.12.0", + "version": "1.12.1", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/recurring-tasks/repository.js b/backend/src/modules/recurring-tasks/repository.js index f6a5d4a..56401ff 100644 --- a/backend/src/modules/recurring-tasks/repository.js +++ b/backend/src/modules/recurring-tasks/repository.js @@ -3,7 +3,7 @@ import { pool } from '../../config/db.js'; const LIST_SELECT = ` SELECT r.id, r.name, r.description, r.project_id, p.name AS project_name, r.responsible_user_id, u.name AS responsible_name, - r.interval_unit, r.interval_value, r.next_due_date, + r.interval_unit, r.interval_value, r.next_due_date, r.end_date, r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at, r.created_by, r.created_at, r.updated_at FROM recurring_tasks r @@ -25,11 +25,11 @@ export async function create(data) { const [result] = await pool.query( `INSERT INTO recurring_tasks (name, description, project_id, responsible_user_id, interval_unit, interval_value, - next_due_date, reminder_days_before, reminder_hour, is_active, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + next_due_date, end_date, reminder_days_before, reminder_hour, is_active, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, - data.intervalUnit, data.intervalValue, data.nextDueDate, + data.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null, data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, data.isActive === false ? 0 : 1, data.createdBy, ] @@ -41,11 +41,11 @@ export async function update(id, data) { await pool.query( `UPDATE recurring_tasks SET name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?, - interval_value = ?, next_due_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ? + interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ? WHERE id = ?`, [ data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, - data.intervalUnit, data.intervalValue, data.nextDueDate, + data.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null, data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, data.isActive === false ? 0 : 1, id, ] @@ -60,7 +60,8 @@ export async function remove(id) { const SQL_UNIT = { days: 'DAY', months: 'MONTH', years: 'YEAR' }; // Avanza al siguiente ciclo: suma el intervalo a next_due_date y limpia el recordatorio ya enviado -// (para que el proximo ciclo pueda volver a mandar su propio aviso). +// (para que el proximo ciclo pueda volver a mandar su propio aviso). Si el nuevo vencimiento supera +// "hasta cuando" (end_date), la tarea se desactiva sola — ya cumplio su ultimo ciclo. export async function advanceCycle(id) { const [rows] = await pool.query('SELECT interval_unit, interval_value FROM recurring_tasks WHERE id = ?', [id]); const row = rows[0]; @@ -72,6 +73,11 @@ export async function advanceCycle(id) { WHERE id = ?`, [row.interval_value, id] ); + await pool.query( + `UPDATE recurring_tasks SET is_active = 0 + WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`, + [id] + ); return findById(id); } @@ -89,6 +95,7 @@ export async function findDueForReminder() { JOIN users cu ON cu.id = r.created_by WHERE r.is_active = 1 AND r.last_reminder_sent_at IS NULL + AND (r.end_date IS NULL OR r.next_due_date <= r.end_date) AND DATE_SUB(r.next_due_date, INTERVAL r.reminder_days_before DAY) = CURDATE() AND HOUR(NOW()) = r.reminder_hour` ); diff --git a/backend/src/modules/recurring-tasks/service.js b/backend/src/modules/recurring-tasks/service.js index 750d053..fb9fc08 100644 --- a/backend/src/modules/recurring-tasks/service.js +++ b/backend/src/modules/recurring-tasks/service.js @@ -15,6 +15,9 @@ function validate(payload) { if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) { throw new HttpError(400, 'La hora del recordatorio debe estar entre 0 y 23'); } + if (payload.endDate && payload.endDate < payload.nextDueDate) { + throw new HttpError(400, '"Hasta" no puede ser anterior al proximo vencimiento'); + } } export const listRecurringTasks = () => repo.findAll(); @@ -69,6 +72,7 @@ export async function promoteFromTask(taskId, payload, currentUser) { intervalUnit: payload.intervalUnit, intervalValue: payload.intervalValue, nextDueDate: payload.nextDueDate, + endDate: payload.endDate || null, reminderDaysBefore: payload.reminderDaysBefore, reminderHour: payload.reminderHour, }; diff --git a/frontend/package.json b/frontend/package.json index 65a8593..fe10e9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.12.0", + "version": "1.12.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 3c77eb7..4207464 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -153,6 +153,9 @@ export const messages = { recurring_task_reminder_days: 'Avisar con (días de anticipación)', recurring_task_reminder_hour: 'Hora del recordatorio', recurring_task_active: 'Activa', + recurring_task_end_date: 'Hasta', + recurring_task_unlimited: 'Indefinida', + recurring_task_unlimited_hint: 'Vacío = se repite sin fin. Al pasar esta fecha, la tarea se desactiva sola.', recurring_task_complete_cycle: 'Completar ciclo', recurring_task_complete_confirm: '¿Confirmas que ya completaste este ciclo? Se calcula el próximo vencimiento sumando la periodicidad configurada.', recurring_task_promote: 'Convertir en recurrente', @@ -383,6 +386,9 @@ export const messages = { recurring_task_reminder_days: 'Remind (days ahead)', recurring_task_reminder_hour: 'Reminder hour', recurring_task_active: 'Active', + recurring_task_end_date: 'Until', + recurring_task_unlimited: 'Unlimited', + recurring_task_unlimited_hint: 'Empty = repeats forever. Once past this date, the task deactivates itself.', recurring_task_complete_cycle: 'Complete cycle', recurring_task_complete_confirm: 'Confirm you already completed this cycle? The next due date is calculated by adding the configured recurrence.', recurring_task_promote: 'Turn into recurring', diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx index e96a2d9..ae7e18c 100644 --- a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -84,7 +84,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi setPromoting(task); setPromoteForm({ name: task.name, responsibleUserId: task.responsible_user_id || '', - intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', + intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', endDate: '', reminderDaysBefore: 7, reminderHour: 9, }); } @@ -93,7 +93,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi if (!promoteForm.nextDueDate) return; await runAction(async () => { await promoteTaskToRecurring(promoting.id, { - ...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, + ...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null, }); setPromoting(null); pushToast({ tone: 'success', message: t('common_saved') }); @@ -253,7 +253,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi title={t('recurring_task_promote_title')} onClose={() => setPromoting(null)} width={560} - height={520} + height={600} > {promoteForm && ( <> @@ -269,6 +269,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> + + + setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} /> + + setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} /> diff --git a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx index 739117c..08b7cc6 100644 --- a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx +++ b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx @@ -19,7 +19,7 @@ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `$ const EMPTY_FORM = { name: '', description: '', projectId: '', responsibleUserId: '', - intervalUnit: 'months', intervalValue: 1, nextDueDate: '', + intervalUnit: 'months', intervalValue: 1, nextDueDate: '', endDate: '', reminderDaysBefore: 7, reminderHour: 9, }; @@ -27,7 +27,7 @@ function toForm(r) { return { name: r.name, description: r.description || '', projectId: r.project_id || '', responsibleUserId: r.responsible_user_id || '', intervalUnit: r.interval_unit, - intervalValue: r.interval_value, nextDueDate: r.next_due_date, + intervalValue: r.interval_value, nextDueDate: r.next_due_date, endDate: r.end_date || '', reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, }; } @@ -67,7 +67,7 @@ export default function RecurringTasksPage() { async function save() { if (!form.name.trim() || !form.nextDueDate) return; await runAction(async () => { - const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null }; + const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null, endDate: form.endDate || null }; if (selected) await updateRecurringTask(selected.id, payload); else await createRecurringTask(payload); setEditing(false); @@ -118,6 +118,7 @@ export default function RecurringTasksPage() { render: (r) => `${t('recurring_task_every')} ${r.interval_value} ${unitLabel[r.interval_unit]}`, }, { key: 'next_due_date', label: t('recurring_task_next_due'), render: (r) => String(r.next_due_date).slice(0, 10) }, + { key: 'end_date', label: t('recurring_task_end_date'), render: (r) => r.end_date ? String(r.end_date).slice(0, 10) : t('recurring_task_unlimited') }, { key: 'reminder_hour', label: t('recurring_task_reminder'), render: (r) => `${r.reminder_days_before}d / ${String(r.reminder_hour).padStart(2, '0')}:00`, @@ -155,7 +156,7 @@ export default function RecurringTasksPage() { title={selected ? t('common_edit') : t('recurring_task_new')} onClose={() => setEditing(false)} width={640} - height={640} + height={720} > setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> @@ -182,6 +183,11 @@ export default function RecurringTasksPage() { setForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> + + + setForm((f) => ({ ...f, endDate: e.target.value }))} /> + + setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />