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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-11 10:29:09 -05:00
parent d00b9c50c6
commit 13a69aadc3
11 changed files with 56 additions and 18 deletions
+2 -1
View File
@@ -83,7 +83,8 @@
- `POST /api/recurring-tasks/from-task/:taskId` — crea una tarea recurrente a partir de una tarea - `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 /`. puntual de una actividad (la original no se borra); mismo body que `POST /`.
- Body: `{ name, description?, projectId?, responsibleUserId?, intervalUnit: 'days'|'months'|'years', - 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 - 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 - (`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 reminderDaysBefore` cae hoy, a la hora `reminderHour`. Un solo envío por ciclo
+6
View File
@@ -1,5 +1,11 @@
# Changelog - project.linkedcorp.net # 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 ## [1.12.0] - 2026-09-11
### Added ### Added
- **Tareas recurrentes**: menú propio en la barra lateral, independiente de Proyectos. Cada tarea - **Tareas recurrentes**: menú propio en la barra lateral, independiente de Proyectos. Cada tarea
+1 -1
View File
@@ -1 +1 @@
1.12.0 1.12.1
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "1.12.0", "version": "1.12.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
@@ -3,7 +3,7 @@ import { pool } from '../../config/db.js';
const LIST_SELECT = ` const LIST_SELECT = `
SELECT r.id, r.name, r.description, r.project_id, p.name AS project_name, 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.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.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at,
r.created_by, r.created_at, r.updated_at r.created_by, r.created_at, r.updated_at
FROM recurring_tasks r FROM recurring_tasks r
@@ -25,11 +25,11 @@ export async function create(data) {
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO recurring_tasks `INSERT INTO recurring_tasks
(name, description, project_id, responsible_user_id, interval_unit, interval_value, (name, description, project_id, responsible_user_id, interval_unit, interval_value,
next_due_date, reminder_days_before, reminder_hour, is_active, created_by) next_due_date, end_date, reminder_days_before, reminder_hour, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, 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.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
data.isActive === false ? 0 : 1, data.createdBy, data.isActive === false ? 0 : 1, data.createdBy,
] ]
@@ -41,11 +41,11 @@ export async function update(id, data) {
await pool.query( await pool.query(
`UPDATE recurring_tasks SET `UPDATE recurring_tasks SET
name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?, 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 = ?`, WHERE id = ?`,
[ [
data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, 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.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
data.isActive === false ? 0 : 1, id, data.isActive === false ? 0 : 1, id,
] ]
@@ -60,7 +60,8 @@ export async function remove(id) {
const SQL_UNIT = { days: 'DAY', months: 'MONTH', years: 'YEAR' }; 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 // 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) { export async function advanceCycle(id) {
const [rows] = await pool.query('SELECT interval_unit, interval_value FROM recurring_tasks WHERE id = ?', [id]); const [rows] = await pool.query('SELECT interval_unit, interval_value FROM recurring_tasks WHERE id = ?', [id]);
const row = rows[0]; const row = rows[0];
@@ -72,6 +73,11 @@ export async function advanceCycle(id) {
WHERE id = ?`, WHERE id = ?`,
[row.interval_value, 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); return findById(id);
} }
@@ -89,6 +95,7 @@ export async function findDueForReminder() {
JOIN users cu ON cu.id = r.created_by JOIN users cu ON cu.id = r.created_by
WHERE r.is_active = 1 WHERE r.is_active = 1
AND r.last_reminder_sent_at IS NULL 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 DATE_SUB(r.next_due_date, INTERVAL r.reminder_days_before DAY) = CURDATE()
AND HOUR(NOW()) = r.reminder_hour` AND HOUR(NOW()) = r.reminder_hour`
); );
@@ -15,6 +15,9 @@ function validate(payload) {
if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) { if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) {
throw new HttpError(400, 'La hora del recordatorio debe estar entre 0 y 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(); export const listRecurringTasks = () => repo.findAll();
@@ -69,6 +72,7 @@ export async function promoteFromTask(taskId, payload, currentUser) {
intervalUnit: payload.intervalUnit, intervalUnit: payload.intervalUnit,
intervalValue: payload.intervalValue, intervalValue: payload.intervalValue,
nextDueDate: payload.nextDueDate, nextDueDate: payload.nextDueDate,
endDate: payload.endDate || null,
reminderDaysBefore: payload.reminderDaysBefore, reminderDaysBefore: payload.reminderDaysBefore,
reminderHour: payload.reminderHour, reminderHour: payload.reminderHour,
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "1.12.0", "version": "1.12.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6
View File
@@ -153,6 +153,9 @@ export const messages = {
recurring_task_reminder_days: 'Avisar con (días de anticipación)', recurring_task_reminder_days: 'Avisar con (días de anticipación)',
recurring_task_reminder_hour: 'Hora del recordatorio', recurring_task_reminder_hour: 'Hora del recordatorio',
recurring_task_active: 'Activa', 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_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_complete_confirm: '¿Confirmas que ya completaste este ciclo? Se calcula el próximo vencimiento sumando la periodicidad configurada.',
recurring_task_promote: 'Convertir en recurrente', recurring_task_promote: 'Convertir en recurrente',
@@ -383,6 +386,9 @@ export const messages = {
recurring_task_reminder_days: 'Remind (days ahead)', recurring_task_reminder_days: 'Remind (days ahead)',
recurring_task_reminder_hour: 'Reminder hour', recurring_task_reminder_hour: 'Reminder hour',
recurring_task_active: 'Active', 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_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_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', recurring_task_promote: 'Turn into recurring',
@@ -84,7 +84,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
setPromoting(task); setPromoting(task);
setPromoteForm({ setPromoteForm({
name: task.name, responsibleUserId: task.responsible_user_id || '', 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, reminderDaysBefore: 7, reminderHour: 9,
}); });
} }
@@ -93,7 +93,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
if (!promoteForm.nextDueDate) return; if (!promoteForm.nextDueDate) return;
await runAction(async () => { await runAction(async () => {
await promoteTaskToRecurring(promoting.id, { await promoteTaskToRecurring(promoting.id, {
...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, ...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null,
}); });
setPromoting(null); setPromoting(null);
pushToast({ tone: 'success', message: t('common_saved') }); pushToast({ tone: 'success', message: t('common_saved') });
@@ -253,7 +253,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
title={t('recurring_task_promote_title')} title={t('recurring_task_promote_title')}
onClose={() => setPromoting(null)} onClose={() => setPromoting(null)}
width={560} width={560}
height={520} height={600}
> >
{promoteForm && ( {promoteForm && (
<> <>
@@ -269,6 +269,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> <input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field> </Field>
</FieldRow> </FieldRow>
<FieldRow>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
<input className="input" type="date" value={promoteForm.endDate} onChange={(e) => setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow> <FieldRow>
<Field label={t('recurring_task_every')}> <Field label={t('recurring_task_every')}>
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} /> <input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
@@ -19,7 +19,7 @@ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `$
const EMPTY_FORM = { const EMPTY_FORM = {
name: '', description: '', projectId: '', responsibleUserId: '', name: '', description: '', projectId: '', responsibleUserId: '',
intervalUnit: 'months', intervalValue: 1, nextDueDate: '', intervalUnit: 'months', intervalValue: 1, nextDueDate: '', endDate: '',
reminderDaysBefore: 7, reminderHour: 9, reminderDaysBefore: 7, reminderHour: 9,
}; };
@@ -27,7 +27,7 @@ function toForm(r) {
return { return {
name: r.name, description: r.description || '', projectId: r.project_id || '', name: r.name, description: r.description || '', projectId: r.project_id || '',
responsibleUserId: r.responsible_user_id || '', intervalUnit: r.interval_unit, 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, reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour,
}; };
} }
@@ -67,7 +67,7 @@ export default function RecurringTasksPage() {
async function save() { async function save() {
if (!form.name.trim() || !form.nextDueDate) return; if (!form.name.trim() || !form.nextDueDate) return;
await runAction(async () => { 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); if (selected) await updateRecurringTask(selected.id, payload);
else await createRecurringTask(payload); else await createRecurringTask(payload);
setEditing(false); setEditing(false);
@@ -118,6 +118,7 @@ export default function RecurringTasksPage() {
render: (r) => `${t('recurring_task_every')} ${r.interval_value} ${unitLabel[r.interval_unit]}`, 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: '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'), key: 'reminder_hour', label: t('recurring_task_reminder'),
render: (r) => `${r.reminder_days_before}d / ${String(r.reminder_hour).padStart(2, '0')}:00`, 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')} title={selected ? t('common_edit') : t('recurring_task_new')}
onClose={() => setEditing(false)} onClose={() => setEditing(false)}
width={640} width={640}
height={640} height={720}
> >
<Field label={t('common_name')}> <Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> <input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
@@ -182,6 +183,11 @@ export default function RecurringTasksPage() {
<input className="input" type="date" value={form.nextDueDate} onChange={(e) => setForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> <input className="input" type="date" value={form.nextDueDate} onChange={(e) => setForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field> </Field>
</FieldRow> </FieldRow>
<FieldRow>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
<input className="input" type="date" value={form.endDate} onChange={(e) => setForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow> <FieldRow>
<Field label={t('recurring_task_reminder_days')}> <Field label={t('recurring_task_reminder_days')}>
<input className="input" type="number" min={0} value={form.reminderDaysBefore} onChange={(e) => setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} /> <input className="input" type="number" min={0} value={form.reminderDaysBefore} onChange={(e) => setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />