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) }))} />