Добавление функционала напоминаний и улучшение сортировки задач

🔧 Изменения:
- Добавлено поле `reminderMinutesBefore` в сущность задачи для настройки времени напоминания.
- Обновлен метод `shouldSendReminder` для учета нового поля при проверке необходимости отправки напоминания.
- Реализован выбор времени напоминания в Telegram-боте при создании задачи.
- Добавлен метод `smartSortTasks` для умной сортировки задач по статусу, приоритету и срокам.
- Обновлены методы получения задач с учетом новой сортировки.

 Теперь пользователи могут настраивать время напоминания и получать задачи в более удобном порядке.
This commit is contained in:
Bivekich
2025-07-27 21:00:37 +03:00
parent b7bf3ab12c
commit 45431ba6c2
4 changed files with 218 additions and 19 deletions

View File

@ -1,5 +1,3 @@
version: '3.8'
services: services:
bivekidaybot: bivekidaybot:
build: build:

View File

@ -69,6 +69,10 @@ export class Task {
@Column({ default: false }) @Column({ default: false })
reminderSent: boolean; reminderSent: boolean;
// Количество минут до дедлайна для напоминания (по умолчанию 60 минут)
@Column({ default: 60 })
reminderMinutesBefore: number;
// Пользователь, которому назначена задача // Пользователь, которому назначена задача
@ManyToOne(() => User, (user) => user.assignedTasks, { nullable: true }) @ManyToOne(() => User, (user) => user.assignedTasks, { nullable: true })
@JoinColumn({ name: 'assigned_to_id' }) @JoinColumn({ name: 'assigned_to_id' })
@ -130,12 +134,38 @@ export class Task {
// Проверка, нужно ли отправить напоминание // Проверка, нужно ли отправить напоминание
shouldSendReminder(): boolean { shouldSendReminder(): boolean {
return ( if (this.reminderSent ||
!this.reminderSent && this.status === TaskStatus.COMPLETED ||
this.reminderDate && this.status === TaskStatus.CANCELLED) {
new Date() >= this.reminderDate && return false;
this.status !== TaskStatus.COMPLETED && }
this.status !== TaskStatus.CANCELLED
); const now = new Date();
// Если установлена конкретная дата напоминания, используем её
if (this.reminderDate) {
return now >= this.reminderDate;
}
// Иначе проверяем по количеству минут до дедлайна
if (this.dueDate) {
const reminderTime = new Date(this.dueDate.getTime() - (this.reminderMinutesBefore * 60 * 1000));
return now >= reminderTime;
}
return false;
}
// Получить время напоминания
getReminderTime(): Date | null {
if (this.reminderDate) {
return this.reminderDate;
}
if (this.dueDate) {
return new Date(this.dueDate.getTime() - (this.reminderMinutesBefore * 60 * 1000));
}
return null;
} }
} }

View File

@ -11,6 +11,7 @@ export interface CreateTaskDto {
type?: TaskType; type?: TaskType;
dueDate: Date; // Обязательное поле dueDate: Date; // Обязательное поле
reminderDate?: Date; reminderDate?: Date;
reminderMinutesBefore?: number; // Напоминание за N минут до дедлайна
assignedToId?: number; assignedToId?: number;
} }
@ -128,7 +129,18 @@ export class TaskService {
} }
async completeTask(id: number): Promise<Task> { async completeTask(id: number): Promise<Task> {
return this.updateTask(id, { status: TaskStatus.COMPLETED }); const task = await this.updateTask(id, { status: TaskStatus.COMPLETED });
// Удаляем задачу после завершения (через 5 секунд для красивого эффекта)
setTimeout(async () => {
try {
await this.deleteTask(id);
} catch (error) {
// Игнорируем ошибки удаления (задача могла быть уже удалена)
}
}, 5000);
return task;
} }
async startTask(id: number): Promise<Task> { async startTask(id: number): Promise<Task> {
@ -272,4 +284,73 @@ export class TaskService {
} : null, } : null,
}; };
} }
// Метод для умной сортировки задач
private smartSortTasks(tasks: Task[]): Task[] {
const priorityOrder = {
[TaskPriority.URGENT]: 4,
[TaskPriority.HIGH]: 3,
[TaskPriority.MEDIUM]: 2,
[TaskPriority.LOW]: 1,
};
const statusOrder = {
[TaskStatus.IN_PROGRESS]: 4, // В процессе - высший приоритет
[TaskStatus.PENDING]: 3, // Ожидающие - второй
[TaskStatus.CANCELLED]: 2, // Отмененные - третий
[TaskStatus.COMPLETED]: 1, // Завершенные - последние
};
return tasks.sort((a, b) => {
// 1. Сначала сортируем по статусу
const statusDiff = statusOrder[b.status] - statusOrder[a.status];
if (statusDiff !== 0) return statusDiff;
// 2. Затем по приоритету (для активных задач)
if (a.status !== TaskStatus.COMPLETED && b.status !== TaskStatus.COMPLETED) {
const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority];
if (priorityDiff !== 0) return priorityDiff;
}
// 3. Затем по дате выполнения (просроченные и близкие по времени - первыми)
if (a.dueDate && b.dueDate) {
const now = new Date();
const aDiff = Math.abs(a.dueDate.getTime() - now.getTime());
const bDiff = Math.abs(b.dueDate.getTime() - now.getTime());
// Если одна из задач просрочена, она идет первой
if (a.dueDate < now && b.dueDate >= now) return -1;
if (b.dueDate < now && a.dueDate >= now) return 1;
// Иначе сортируем по близости к текущему времени
return aDiff - bDiff;
}
// 4. Если у одной задачи есть дата, а у другой нет
if (a.dueDate && !b.dueDate) return -1;
if (!a.dueDate && b.dueDate) return 1;
// 5. Наконец, по дате создания (новые первыми)
return b.createdAt.getTime() - a.createdAt.getTime();
});
}
// Обновляем методы для использования умной сортировки
async findTasksByUserSorted(userId: number): Promise<Task[]> {
const tasks = await this.taskRepository.find({
where: [{ assignedToId: userId }, { createdById: userId }],
relations: ['assignedTo', 'createdBy'],
});
return this.smartSortTasks(tasks);
}
async findAllAccessibleTasksSorted(userId: number, partnerUserId?: number): Promise<Task[]> {
const tasks = await this.findAllAccessibleTasks(userId, partnerUserId);
return this.smartSortTasks(tasks);
}
async findSharedTasksSorted(): Promise<Task[]> {
const tasks = await this.findSharedTasks();
return this.smartSortTasks(tasks);
}
} }

View File

@ -13,7 +13,11 @@ import {
interface BotContext extends Context { interface BotContext extends Context {
session?: { session?: {
step?: string; step?: string;
taskData?: Partial<CreateTaskDto & { dueDateString?: string; dueTimeString?: string }>; taskData?: Partial<CreateTaskDto & {
dueDateString?: string;
dueTimeString?: string;
reminderMinutesBefore?: number;
}>;
taskId?: number; taskId?: number;
[key: string]: any; [key: string]: any;
}; };
@ -183,6 +187,9 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
else if (data.startsWith('time_')) { else if (data.startsWith('time_')) {
await this.handleTimeSelection(ctx, data); await this.handleTimeSelection(ctx, data);
} }
else if (data.startsWith('reminder_')) {
await this.handleReminderSelection(ctx, data);
}
// Просмотр задач // Просмотр задач
else if (data === 'my_tasks') { else if (data === 'my_tasks') {
await this.showMyTasks(ctx); await this.showMyTasks(ctx);
@ -459,6 +466,51 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
const user = await this.getCurrentUser(ctx); const user = await this.getCurrentUser(ctx);
if (!user || !ctx.session?.taskData) return; if (!user || !ctx.session?.taskData) return;
// Показываем выбор времени напоминания перед созданием задачи
ctx.session.step = 'task_reminder';
const keyboard = Markup.inlineKeyboard([
[
Markup.button.callback('🔔 За 15 мин', 'reminder_15'),
Markup.button.callback('🔔 За 30 мин', 'reminder_30'),
],
[
Markup.button.callback('🔔 За 1 час', 'reminder_60'),
Markup.button.callback('🔔 За 2 часа', 'reminder_120'),
],
[
Markup.button.callback('🔕 Без напоминания', 'reminder_none'),
],
[Markup.button.callback('⬅️ Назад', 'task_time_back')],
]);
await ctx.reply(
`📝 *Создание задачи*\n\n` +
`📋 Название: ${ctx.session.taskData.title}\n` +
`📅 Дата: ${ctx.session.taskData.dueDateString}\n` +
`⏰ Время: ${ctx.session.taskData.dueTimeString}\n\n` +
'🔔 Когда напомнить о задаче?',
{
parse_mode: 'Markdown',
...keyboard,
},
);
}
private async handleReminderSelection(ctx: BotContext, data: string) {
const user = await this.getCurrentUser(ctx);
if (!user || !ctx.session?.taskData) return;
const reminderMinutes = data === 'reminder_none' ? 0 : parseInt(data.replace('reminder_', ''));
ctx.session.taskData.reminderMinutesBefore = reminderMinutes;
await this.finalizeTaskCreation(ctx);
}
private async finalizeTaskCreation(ctx: BotContext) {
const user = await this.getCurrentUser(ctx);
if (!user || !ctx.session?.taskData) return;
try { try {
// Парсим дату и время // Парсим дату и время
const dateStr = ctx.session.taskData.dueDateString!; const dateStr = ctx.session.taskData.dueDateString!;
@ -474,6 +526,7 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
type: ctx.session.taskData.type!, type: ctx.session.taskData.type!,
priority: ctx.session.taskData.priority!, priority: ctx.session.taskData.priority!,
dueDate: dueDate, dueDate: dueDate,
reminderMinutesBefore: ctx.session.taskData.reminderMinutesBefore || 60,
assignedToId: ctx.session.taskData.type === TaskType.PERSONAL ? user.id : undefined, assignedToId: ctx.session.taskData.type === TaskType.PERSONAL ? user.id : undefined,
}; };
@ -491,6 +544,10 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
[Markup.button.callback('📋 Мои задачи', 'my_tasks')], [Markup.button.callback('📋 Мои задачи', 'my_tasks')],
]); ]);
const reminderText = task.reminderMinutesBefore > 0
? `🔔 Напоминание: за ${task.reminderMinutesBefore} мин\n`
: '🔕 Напоминание: отключено\n';
await ctx.reply( await ctx.reply(
`✅ *Задача создана!*\n\n` + `✅ *Задача создана!*\n\n` +
`📝 ${task.title}\n` + `📝 ${task.title}\n` +
@ -498,6 +555,7 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
`${priorityEmojis[task.priority]} Приоритет: ${this.getPriorityText(task.priority)}\n` + `${priorityEmojis[task.priority]} Приоритет: ${this.getPriorityText(task.priority)}\n` +
`📅 Дата: ${dueDate.toLocaleDateString('ru-RU')}\n` + `📅 Дата: ${dueDate.toLocaleDateString('ru-RU')}\n` +
`⏰ Время: ${dueDate.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}\n` + `⏰ Время: ${dueDate.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}\n` +
reminderText +
`🆔 ID: ${task.id}`, `🆔 ID: ${task.id}`,
{ {
parse_mode: 'Markdown', parse_mode: 'Markdown',
@ -538,7 +596,7 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
const user = await this.getCurrentUser(ctx); const user = await this.getCurrentUser(ctx);
if (!user) return; if (!user) return;
const tasks = await this.taskService.findTasksByUser(user.id); const tasks = await this.taskService.findTasksByUserSorted(user.id);
if (tasks.length === 0) { if (tasks.length === 0) {
const keyboard = Markup.inlineKeyboard([ const keyboard = Markup.inlineKeyboard([
@ -565,7 +623,7 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
const user = await this.getCurrentUser(ctx); const user = await this.getCurrentUser(ctx);
if (!user) return; if (!user) return;
const tasks = await this.taskService.findSharedTasks(); const tasks = await this.taskService.findSharedTasksSorted();
if (tasks.length === 0) { if (tasks.length === 0) {
const keyboard = Markup.inlineKeyboard([ const keyboard = Markup.inlineKeyboard([
@ -608,8 +666,8 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
return; return;
} }
const tasks = await this.taskService.findAllAccessibleTasks(user.id, partner.id); const allTasks = await this.taskService.findAllAccessibleTasksSorted(user.id, partner.id);
const partnerTasks = tasks.filter(t => const partnerTasks = allTasks.filter(t =>
t.createdById === partner.id || t.createdById === partner.id ||
(t.assignedToId === partner.id && t.type === TaskType.PERSONAL) (t.assignedToId === partner.id && t.type === TaskType.PERSONAL)
); );
@ -654,9 +712,16 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
`${task.dueDate.toLocaleDateString('ru-RU')} в ${task.dueDate.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` : `${task.dueDate.toLocaleDateString('ru-RU')} в ${task.dueDate.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}` :
'Не установлен'; 'Не установлен';
message += `${task.getStatusEmoji()} *${task.title}* (ID: ${task.id})\n`; // Проверяем, просрочена ли задача
const isOverdue = task.dueDate && task.dueDate < new Date() &&
task.status !== TaskStatus.COMPLETED &&
task.status !== TaskStatus.CANCELLED;
const overdueIndicator = isOverdue ? '🚨 ' : '';
message += `${overdueIndicator}${task.getStatusEmoji()} *${task.title}* (ID: ${task.id})\n`;
message += `${task.getPriorityEmoji()} ${task.getTypeEmoji()} Назначено: ${assignedTo}\n`; message += `${task.getPriorityEmoji()} ${task.getTypeEmoji()} Назначено: ${assignedTo}\n`;
message += `📅 Срок: ${dueDate}\n`; message += `📅 Срок: ${dueDate}${isOverdue ? ' ⚠️ ПРОСРОЧЕНА' : ''}\n`;
message += '\n'; message += '\n';
// Добавляем кнопку для каждой задачи // Добавляем кнопку для каждой задачи
@ -741,6 +806,14 @@ ${partner ? `👥 Партнер: ${partnerName} ${partnerEmoji}` : ''}
message += `👤 *Назначено:* ${assignedTo}\n`; message += `👤 *Назначено:* ${assignedTo}\n`;
message += `👨‍💻 *Создал:* ${createdBy} ${task.createdBy.getRoleEmoji()}\n`; message += `👨‍💻 *Создал:* ${createdBy} ${task.createdBy.getRoleEmoji()}\n`;
message += `📅 *Срок:* ${dueDate}\n`; message += `📅 *Срок:* ${dueDate}\n`;
const reminderTime = task.getReminderTime();
if (reminderTime && task.reminderMinutesBefore > 0) {
message += `🔔 *Напоминание:* ${reminderTime.toLocaleDateString('ru-RU')} в ${reminderTime.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} (за ${task.reminderMinutesBefore} мин)\n`;
} else {
message += `🔕 *Напоминание:* отключено\n`;
}
message += `🆔 *ID:* ${task.id}\n`; message += `🆔 *ID:* ${task.id}\n`;
if (task.description) { if (task.description) {
@ -1257,16 +1330,33 @@ ${extendedStats.shared.completed > 0 ? '🎉 Отличная командная
[Markup.button.callback('📋 Подробнее', `task_view_${task.id}`)], [Markup.button.callback('📋 Подробнее', `task_view_${task.id}`)],
]); ]);
// Вычисляем время до дедлайна
const now = new Date();
const timeUntilDeadline = task.dueDate ? task.dueDate.getTime() - now.getTime() : 0;
const minutesUntilDeadline = Math.floor(timeUntilDeadline / (1000 * 60));
let timeText = '';
if (minutesUntilDeadline <= 0) {
timeText = '⚠️ *Задача просрочена!*';
} else if (minutesUntilDeadline < 60) {
timeText = `⏰ *Осталось: ${minutesUntilDeadline} мин*`;
} else {
const hours = Math.floor(minutesUntilDeadline / 60);
const mins = minutesUntilDeadline % 60;
timeText = `⏰ *Осталось: ${hours}ч ${mins}мин*`;
}
const message = ` const message = `
*Напоминание о задаче!* 🔔 *Напоминание о задаче!*
📝 ${task.title} 📝 ${task.title}
${task.getPriorityEmoji()} Приоритет: ${this.getPriorityText(task.priority)} ${task.getPriorityEmoji()} Приоритет: ${this.getPriorityText(task.priority)}
${task.getTypeEmoji()} Тип: ${task.type === TaskType.SHARED ? 'Общая' : 'Личная'} ${task.getTypeEmoji()} Тип: ${task.type === TaskType.SHARED ? 'Общая' : 'Личная'}
${timeText}
🆔 ID: ${task.id} 🆔 ID: ${task.id}
${task.description ? `📄 ${task.description}\n` : ''} ${task.description ? `📄 ${task.description}\n` : ''}
Не забудьте выполнить задачу! 💪 ${minutesUntilDeadline <= 0 ? '🚨 Срочно!' : 'Не забудьте выполнить задачу! 💪'}
`; `;
try { try {