diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..f2d1ebc1d --- /dev/null +++ b/PLAN.md @@ -0,0 +1,66 @@ +# Plan for showing tasks due today in reminder emails + +## Overview +Extend the daily overdue reminder email so it can also list tasks that are due later the same day. The user can switch this behaviour on or off in their settings. The mail will contain two sections: + +1. **Overdue tasks** – tasks whose due date/time passed. +2. **Due today** – tasks due later today (in the user's timezone). + +A task appearing in both categories must only be listed as overdue. + +--- + +## Backend +1. **Configuration & model changes** + - Add config key `defaultsettings.today_tasks_reminders_enabled` with a default (initially `false` to preserve current behaviour). + - Create DB migration adding column `today_tasks_reminders_enabled` to `users` table with index and default pulled from config. + - Extend structs (`pkg/user/user.go`, `pkg/routes/api/v1/user_settings.go`, `pkg/user/user_create.go`) with field `TodayTasksRemindersEnabled` / JSON `today_tasks_reminders_enabled`. + - Expose field in API routes; update Swagger docs. + +2. **Collecting tasks** + - Rename `getUndoneOverdueTasks` to something like `getTasksForDailyReminder` and extend it to also fetch tasks due later today. + - Query tasks with due dates up to end‑of‑day across time zones (≈ now + 38h) and categorise per user into overdue vs due today using their timezone and current reminder time. + - Only include "due today" section when the user has `TodayTasksRemindersEnabled` set. + +3. **Notifications** + - Replace `UndoneTasksOverdueNotification`/`UndoneTaskOverdueNotification` with a new notification struct (e.g. `DailyTasksReminderNotification`) holding two task lists: overdue and due today. + - Build email with two sections and appropriate headings, handling cases where only one of the sections has tasks. + - Add translation strings in `pkg/i18n/lang/en.json` for the new subject line and section titles/messages. + +4. **Cron job** + - Adjust `RegisterOverdueReminderCron` to call the new task collector and send the new notification type. The cron should trigger when the user’s configured reminder time is reached. + +5. **Tests** + - Extend fixtures with a task that is due later on the same day. + - Update `pkg/models/task_overdue_reminder_test.go` to assert both overdue and due‑today categorisation and that due‑today tasks are only included for users with the new setting enabled. + +--- + +## Frontend +1. **User settings model** + - Add `todayTasksRemindersEnabled` to `IUserSettings` and `UserSettingsModel` with default `false`. + +2. **Settings UI** + - In `frontend/src/views/user/settings/General.vue`, expose separate checkboxes for overdue reminders and "Include tasks due today in reminder email" (translation key `user.settings.general.todayReminders`). + - Show the reminder time input when either overdue or today reminders are enabled. + +3. **Translations** + - Add the new label to `frontend/src/i18n/lang/en.json` and placeholders in other languages. + +4. **Store/Service** + - Ensure the settings store and `UserSettingsService` send/receive the new field (interfaces handle most of it). + - Add/adjust tests or Cypress spec to cover toggling the setting. + +--- + +## Email & localisation +- Update backend translation strings (`pkg/i18n/lang/en.json`) for: + - Subject when only due‑today tasks exist. + - Section headers "Overdue tasks" and "Tasks due today". + - Introductory texts for each section. +- Ensure translation keys are referenced in notification builder. + +--- + +## Summary +The change adds a new user preference to include tasks due today in the daily reminder. Backend collects due‑today tasks alongside overdue ones and sends a combined email. Frontend exposes independent toggles for both overdue and today reminders. Tests and translations verify the new behaviour. diff --git a/config-raw.json b/config-raw.json index fabdadbfd..ac7e4d863 100644 --- a/config-raw.json +++ b/config-raw.json @@ -940,6 +940,11 @@ "default_value": "true", "comment": "If set to true will send an email every day with all overdue tasks at a configured time." }, + { + "key": "today_tasks_reminders_enabled", + "default_value": "false", + "comment": "If set to true, include tasks due today in the daily overdue reminder email." + }, { "key": "overdue_tasks_reminders_time", "default_value": "9:00", diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index bc19590b7..8575e4ee2 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -97,6 +97,7 @@ "savedSuccess": "The settings were successfully updated.", "emailReminders": "Send me reminders for tasks via email", "overdueReminders": "Send me a summary of my undone overdue tasks every day", + "todayReminders": "Include tasks due today in reminder email", "discoverableByName": "Allow other users to add me as a member to teams or projects when they search for my name", "discoverableByEmail": "Allow other users to add me as a member to teams or projects when they search for my full email", "playSoundWhenDone": "Play a sound when marking tasks as done", diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index 833638c93..05b9df979 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -41,9 +41,10 @@ export interface IUserSettings extends IAbstract { emailRemindersEnabled: boolean discoverableByName: boolean discoverableByEmail: boolean - overdueTasksRemindersEnabled: boolean - overdueTasksRemindersTime: undefined | string | Date - defaultProjectId: undefined | IProject['id'] + overdueTasksRemindersEnabled: boolean + overdueTasksRemindersTime: undefined | string | Date + todayTasksRemindersEnabled: boolean + defaultProjectId: undefined | IProject['id'] weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6 timezone: string language: SupportedLocale | null diff --git a/frontend/src/models/userSettings.ts b/frontend/src/models/userSettings.ts index a3411706f..5af9c61b2 100644 --- a/frontend/src/models/userSettings.ts +++ b/frontend/src/models/userSettings.ts @@ -16,6 +16,7 @@ export default class UserSettingsModel extends AbstractModel impl discoverableByEmail = false overdueTasksRemindersEnabled = true overdueTasksRemindersTime = undefined + todayTasksRemindersEnabled = false defaultProjectId = undefined weekStart = 0 as IUserSettings['weekStart'] timezone = '' diff --git a/frontend/src/views/user/settings/General.vue b/frontend/src/views/user/settings/General.vue index 25ae1bef4..a16aa83c6 100644 --- a/frontend/src/views/user/settings/General.vue +++ b/frontend/src/views/user/settings/General.vue @@ -123,26 +123,34 @@ {{ $t('user.settings.general.overdueReminders') }} -
-
diff --git a/pkg/config/config.go b/pkg/config/config.go index af529b757..3d624e12a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -208,6 +208,7 @@ const ( DefaultSettingsDiscoverableByName Key = `defaultsettings.discoverable_by_name` DefaultSettingsDiscoverableByEmail Key = `defaultsettings.discoverable_by_email` DefaultSettingsOverdueTaskRemindersEnabled Key = `defaultsettings.overdue_tasks_reminders_enabled` + DefaultSettingsTodayTasksRemindersEnabled Key = `defaultsettings.today_tasks_reminders_enabled` DefaultSettingsDefaultProjectID Key = `defaultsettings.default_project_id` DefaultSettingsWeekStart Key = `defaultsettings.week_start` DefaultSettingsLanguage Key = `defaultsettings.language` diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index e8298d7ed..905ecd550 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -412,12 +412,15 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 + +# Task due later today for reminders - id: 47 - title: 'task #47 with reminders outside window' + title: 'task #47 due today' done: false created_by_id: 1 project_id: 1 - index: 32 + index: 13 + due_date: 2018-12-01 23:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - id: 48 diff --git a/pkg/db/fixtures/users.yml b/pkg/db/fixtures/users.yml index e3f23c58d..33411da60 100644 --- a/pkg/db/fixtures/users.yml +++ b/pkg/db/fixtures/users.yml @@ -7,6 +7,7 @@ updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 export_file_id: 1 + today_tasks_reminders_enabled: true - id: 2 username: 'user2' diff --git a/pkg/i18n/lang/en.json b/pkg/i18n/lang/en.json index 9cf69f1c6..8cbea9b6d 100644 --- a/pkg/i18n/lang/en.json +++ b/pkg/i18n/lang/en.json @@ -100,6 +100,11 @@ "overdue_since": "since %[1]s", "overdue_now": "now", "overdue": "overdue %[1]s" + }, + "reminder": { + "only_due_today_subject": "Tasks due today", + "overdue_intro": "You have the following overdue tasks:", + "today_intro": "You have the following tasks due today:" } }, "project": { diff --git a/pkg/migration/20250903072808.go b/pkg/migration/20250903072808.go new file mode 100644 index 000000000..c2a125911 --- /dev/null +++ b/pkg/migration/20250903072808.go @@ -0,0 +1,40 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +// usersTodayTasksRemindersEnabled20250903072808 adds the today_tasks_reminders_enabled column to the users table. +type usersTodayTasksRemindersEnabled20250903072808 struct { + TodayTasksRemindersEnabled bool `xorm:"not null default false index"` +} + +func (usersTodayTasksRemindersEnabled20250903072808) TableName() string { return "users" } + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20250903072808", + Description: "Add today_tasks_reminders_enabled setting", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(usersTodayTasksRemindersEnabled20250903072808{}) + }, + Rollback: func(tx *xorm.Engine) error { return nil }, + }) +} diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 252f754fb..90cb9288a 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -295,83 +295,72 @@ func getOverdueSinceString(until time.Duration, language string) (overdueSince s } // UndoneTaskOverdueNotification represents a UndoneTaskOverdueNotification notification -type UndoneTaskOverdueNotification struct { - User *user.User - Task *Task - Project *Project +type DailyTasksReminderNotification struct { + User *user.User + OverdueTasks map[int64]*Task + DueToday map[int64]*Task + Projects map[int64]*Project } -// ToMail returns the mail notification for UndoneTaskOverdueNotification -func (n *UndoneTaskOverdueNotification) ToMail(lang string) *notifications.Mail { - until := time.Until(n.Task.DueDate).Round(1*time.Hour) * -1 - return notifications.NewMail(). - IncludeLinkToSettings(lang). - Subject(i18n.T(lang, "notifications.task.overdue.subject", n.Task.Title, n.Project.Title)). - Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())). - Line(i18n.T(lang, "notifications.task.overdue.message", n.Task.Title, n.Project.Title, getOverdueSinceString(until, n.User.Language))). - Action(i18n.T(lang, "notifications.common.actions.open_task"), config.ServicePublicURL.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)). - Line(i18n.T(lang, "notifications.common.have_nice_day")) -} +// ToMail returns the mail notification for DailyTasksReminderNotification +func (n *DailyTasksReminderNotification) ToMail(lang string) *notifications.Mail { -// ToDB returns the UndoneTaskOverdueNotification notification in a format which can be saved in the db -func (n *UndoneTaskOverdueNotification) ToDB() interface{} { - return nil -} - -// Name returns the name of the notification -func (n *UndoneTaskOverdueNotification) Name() string { - return "task.undone.overdue" -} - -// ThreadID returns the thread ID for email threading -func (n *UndoneTaskOverdueNotification) ThreadID() string { - return getThreadID(n.Task.ID) -} - -// UndoneTasksOverdueNotification represents a UndoneTasksOverdueNotification notification -type UndoneTasksOverdueNotification struct { - User *user.User - Tasks map[int64]*Task - Projects map[int64]*Project -} - -// ToMail returns the mail notification for UndoneTasksOverdueNotification -func (n *UndoneTasksOverdueNotification) ToMail(lang string) *notifications.Mail { - - sortedTasks := make([]*Task, 0, len(n.Tasks)) - for _, task := range n.Tasks { - sortedTasks = append(sortedTasks, task) + sortedOverdue := make([]*Task, 0, len(n.OverdueTasks)) + for _, task := range n.OverdueTasks { + sortedOverdue = append(sortedOverdue, task) } + sort.Slice(sortedOverdue, func(i, j int) bool { + return sortedOverdue[i].DueDate.Before(sortedOverdue[j].DueDate) + }) - sort.Slice(sortedTasks, func(i, j int) bool { - return sortedTasks[i].DueDate.Before(sortedTasks[j].DueDate) + sortedToday := make([]*Task, 0, len(n.DueToday)) + for _, task := range n.DueToday { + sortedToday = append(sortedToday, task) + } + sort.Slice(sortedToday, func(i, j int) bool { + return sortedToday[i].DueDate.Before(sortedToday[j].DueDate) }) overdueLine := "" - for _, task := range sortedTasks { + for _, task := range sortedOverdue { until := time.Until(task.DueDate).Round(1*time.Hour) * -1 overdueLine += `* [` + task.Title + `](` + config.ServicePublicURL.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + n.Projects[task.ProjectID].Title + `), ` + i18n.T("notifications.task.overdue.overdue", getOverdueSinceString(until, n.User.Language)) + "\n" } - return notifications.NewMail(). + todayLine := "" + for _, task := range sortedToday { + todayLine += `* [` + task.Title + `](` + config.ServicePublicURL.GetString() + "tasks/" + strconv.FormatInt(task.ID, 10) + `) (` + n.Projects[task.ProjectID].Title + `)\n` + } + + subject := i18n.T(lang, "notifications.task.overdue.multiple_subject") + if len(n.OverdueTasks) == 0 { + subject = i18n.T(lang, "notifications.task.reminder.only_due_today_subject") + } + + m := notifications.NewMail(). IncludeLinkToSettings(lang). - Subject(i18n.T(lang, "notifications.task.overdue.multiple_subject")). - Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())). - Line(i18n.T(lang, "notifications.task.overdue.multiple_message")). - Line(overdueLine). - Action(i18n.T(lang, "notifications.common.actions.open_vikunja"), config.ServicePublicURL.GetString()). + Subject(subject). + Greeting(i18n.T(lang, "notifications.greeting", n.User.GetName())) + + if overdueLine != "" { + m.Line(i18n.T(lang, "notifications.task.reminder.overdue_intro")) + m.Line(overdueLine) + } + if todayLine != "" { + m.Line(i18n.T(lang, "notifications.task.reminder.today_intro")) + m.Line(todayLine) + } + + m.Action(i18n.T(lang, "notifications.common.actions.open_vikunja"), config.ServicePublicURL.GetString()). Line(i18n.T(lang, "notifications.common.have_nice_day")) + return m } -// ToDB returns the UndoneTasksOverdueNotification notification in a format which can be saved in the db -func (n *UndoneTasksOverdueNotification) ToDB() interface{} { - return nil -} +// ToDB returns the DailyTasksReminderNotification notification in a format which can be saved in the db +func (n *DailyTasksReminderNotification) ToDB() interface{} { return nil } // Name returns the name of the notification -func (n *UndoneTasksOverdueNotification) Name() string { - return "task.undone.overdue" -} +func (n *DailyTasksReminderNotification) Name() string { return "task.daily.reminder" } // UserMentionedInTaskNotification represents a UserMentionedInTaskNotification notification type UserMentionedInTaskNotification struct { diff --git a/pkg/models/task_overdue_reminder.go b/pkg/models/task_today_reminder.go similarity index 53% rename from pkg/models/task_overdue_reminder.go rename to pkg/models/task_today_reminder.go index e10ef126e..77b1afc1e 100644 --- a/pkg/models/task_overdue_reminder.go +++ b/pkg/models/task_today_reminder.go @@ -32,13 +32,13 @@ import ( "xorm.io/xorm" ) -func getUndoneOverdueTasks(s *xorm.Session, now time.Time, cond builder.Cond) (usersWithTasks map[int64]*userWithTasks, err error) { +func getTasksForDailyReminder(s *xorm.Session, now time.Time, cond builder.Cond) (usersWithTasks map[int64]*userWithTasks, err error) { now = utils.GetTimeWithoutSeconds(now) nextMinute := now.Add(1 * time.Minute) var tasks []*Task err = s. - Where("due_date is not null AND due_date < ? AND projects.is_archived = false", nextMinute.Add(time.Hour*14).Format(dbTimeFormat)). + Where("due_date is not null AND due_date < ? AND projects.is_archived = false", nextMinute.Add(time.Hour*38).Format(dbTimeFormat)). Join("LEFT", "projects", "projects.id = tasks.project_id"). And("done = false"). Find(&tasks) @@ -85,19 +85,28 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time, cond builder.Cond) (u if err != nil { return nil, err } - overdueMailTime := time.Date(now.Year(), now.Month(), now.Day(), tm.Hour(), tm.Minute(), 0, 0, tz) - isTimeForReminder := overdueMailTime.After(now) || overdueMailTime.Equal(now.In(tz)) - wasTimeForReminder := overdueMailTime.Before(nextMinute) - taskIsOverdueInUserTimezone := overdueMailTime.After(t.Task.DueDate.In(tz)) - if isTimeForReminder && wasTimeForReminder && taskIsOverdueInUserTimezone { + reminderTime := time.Date(now.Year(), now.Month(), now.Day(), tm.Hour(), tm.Minute(), 0, 0, tz) + isTimeForReminder := reminderTime.After(now) || reminderTime.Equal(now.In(tz)) + wasTimeForReminder := reminderTime.Before(nextMinute) + taskDue := t.Task.DueDate.In(tz) + endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, tz) + if isTimeForReminder && wasTimeForReminder { _, exists := uts[t.User.ID] if !exists { uts[t.User.ID] = &userWithTasks{ - user: t.User, - tasks: make(map[int64]*Task), + user: t.User, + overdue: make(map[int64]*Task), + dueToday: make(map[int64]*Task), } } - uts[t.User.ID].tasks[t.Task.ID] = t.Task + + if t.User.OverdueTasksRemindersEnabled && reminderTime.After(taskDue) { + uts[t.User.ID].overdue[t.Task.ID] = t.Task + continue + } + if t.User.TodayTasksRemindersEnabled && taskDue.After(reminderTime) && taskDue.Before(endOfDay) { + uts[t.User.ID].dueToday[t.Task.ID] = t.Task + } } } @@ -105,11 +114,12 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time, cond builder.Cond) (u } type userWithTasks struct { - user *user.User - tasks map[int64]*Task + user *user.User + overdue map[int64]*Task + dueToday map[int64]*Task } -// RegisterOverdueReminderCron registers a function which checks once a day for tasks that are overdue and not done. +// RegisterOverdueReminderCron registers a function which checks once a day for overdue tasks and tasks due today and sends reminders. func RegisterOverdueReminderCron() { webhookEnabled := config.WebhooksEnabled.GetBool() emailEnabled := config.ServiceEnableEmailReminders.GetBool() && config.MailerEnabled.GetBool() @@ -130,12 +140,15 @@ func RegisterOverdueReminderCron() { var cond builder.Cond if emailEnabled && !webhookEnabled { - cond = builder.Eq{"users.overdue_tasks_reminders_enabled": true} + cond = builder.Or( + builder.Eq{"users.overdue_tasks_reminders_enabled": true}, + builder.Eq{"users.today_tasks_reminders_enabled": true}, + ) } - uts, err := getUndoneOverdueTasks(s, now, cond) + uts, err := getTasksForDailyReminder(s, now, cond) if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not get undone overdue tasks in the next minute: %s", err) + log.Errorf("[Daily Tasks Reminder] Could not get tasks for daily reminder: %s", err) return } @@ -143,80 +156,92 @@ func RegisterOverdueReminderCron() { return } - log.Debugf("[Undone Overdue Tasks Reminder] Sending reminders to %d users", len(uts)) + log.Debugf("[Daily Tasks Reminder] Sending reminders to %d users", len(uts)) taskIDs := []int64{} for _, ut := range uts { - for _, t := range ut.tasks { + for _, t := range ut.overdue { + taskIDs = append(taskIDs, t.ID) + } + for _, t := range ut.dueToday { taskIDs = append(taskIDs, t.ID) } } projects, err := GetProjectsMapSimpleByTaskIDs(s, taskIDs) if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not get projects for tasks: %s", err) + log.Errorf("[Daily Tasks Reminder] Could not get projects for tasks: %s", err) return } - for _, ut := range uts { - if emailEnabled && ut.user.OverdueTasksRemindersEnabled { - var n notifications.Notification = &UndoneTasksOverdueNotification{ - User: ut.user, - Tasks: ut.tasks, - Projects: projects, - } - - if len(ut.tasks) == 1 { - for _, t := range ut.tasks { - n = &UndoneTaskOverdueNotification{ - User: ut.user, - Task: t, - Project: projects[t.ProjectID], - } + // Dispatch webhook events, deduplicated by task ID across all users + if webhookEnabled { + dispatchedTasks := make(map[int64]bool) + for _, ut := range uts { + // Per-task overdue events + for _, t := range ut.overdue { + if dispatchedTasks[t.ID] { + continue } - } - - err = notifications.Notify(ut.user, n, s) - if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not notify user %d: %s", ut.user.ID, err) - return - } - } - - // Dispatch webhook events - if webhookEnabled { - // Per-task events - for _, t := range ut.tasks { + dispatchedTasks[t.ID] = true err = events.Dispatch(&TaskOverdueEvent{ Task: t, User: ut.user, Project: projects[t.ProjectID], }) if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not dispatch overdue event for task %d: %s", t.ID, err) + log.Errorf("[Daily Tasks Reminder] Could not dispatch overdue event for task %d: %s", t.ID, err) } } // Batch event - err = events.Dispatch(&TasksOverdueEvent{ - Tasks: mapToSlice(ut.tasks), - User: ut.user, - Projects: projects, - }) - if err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not dispatch batch overdue event for user %d: %s", ut.user.ID, err) + if len(ut.overdue) > 0 { + err = events.Dispatch(&TasksOverdueEvent{ + Tasks: mapToSlice(ut.overdue), + User: ut.user, + Projects: projects, + }) + if err != nil { + log.Errorf("[Daily Tasks Reminder] Could not dispatch batch overdue event for user %d: %s", ut.user.ID, err) + } } } + } - log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder for %d tasks to user %d", len(ut.tasks), ut.user.ID) + if !emailEnabled { + if err := s.Commit(); err != nil { + log.Errorf("[Daily Tasks Reminder] Could not commit: %s", err) + } + return + } + + for _, ut := range uts { + if len(ut.overdue) == 0 && len(ut.dueToday) == 0 { + continue + } + + n := &DailyTasksReminderNotification{ + User: ut.user, + OverdueTasks: ut.overdue, + DueToday: ut.dueToday, + Projects: projects, + } + + err = notifications.Notify(ut.user, n, s) + if err != nil { + log.Errorf("[Daily Tasks Reminder] Could not notify user %d: %s", ut.user.ID, err) + return + } + + log.Debugf("[Daily Tasks Reminder] Sent reminder email to user %d (overdue: %d, today: %d)", ut.user.ID, len(ut.overdue), len(ut.dueToday)) } if err := s.Commit(); err != nil { - log.Errorf("[Undone Overdue Tasks Reminder] Could not commit: %s", err) + log.Errorf("[Daily Tasks Reminder] Could not commit: %s", err) } }) if err != nil { - log.Fatalf("Could not register undone overdue tasks reminder cron: %s", err) + log.Fatalf("Could not register daily tasks reminder cron: %s", err) } } diff --git a/pkg/models/task_overdue_reminder_test.go b/pkg/models/task_today_reminder_test.go similarity index 86% rename from pkg/models/task_overdue_reminder_test.go rename to pkg/models/task_today_reminder_test.go index e02cd2619..8a0fce831 100644 --- a/pkg/models/task_overdue_reminder_test.go +++ b/pkg/models/task_today_reminder_test.go @@ -27,7 +27,7 @@ import ( "xorm.io/builder" ) -func TestGetUndoneOverDueTasks(t *testing.T) { +func TestGetTasksForDailyReminder(t *testing.T) { t.Run("no undone tasks", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -35,34 +35,61 @@ func TestGetUndoneOverDueTasks(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-01-01T01:13:00Z") require.NoError(t, err) - tasks, err := getUndoneOverdueTasks(s, now, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) + tasks, err := getTasksForDailyReminder(s, now, builder.Or( + builder.Eq{"users.overdue_tasks_reminders_enabled": true}, + builder.Eq{"users.today_tasks_reminders_enabled": true}, + )) require.NoError(t, err) assert.Empty(t, tasks) }) - t.Run("undone overdue", func(t *testing.T) { + t.Run("overdue and due today", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() now, err := time.Parse(time.RFC3339Nano, "2018-12-01T09:00:00Z") require.NoError(t, err) - uts, err := getUndoneOverdueTasks(s, now, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) + uts, err := getTasksForDailyReminder(s, now, builder.Or( + builder.Eq{"users.overdue_tasks_reminders_enabled": true}, + builder.Eq{"users.today_tasks_reminders_enabled": true}, + )) require.NoError(t, err) - require.Len(t, uts, 1) - assert.Len(t, uts[1].tasks, 2) - // The tasks don't always have the same order, so we only check their presence, not their position. - var task5Present bool - var task6Present bool - for _, t := range uts[1].tasks { - if t.ID == 5 { - task5Present = true - } - if t.ID == 6 { - task6Present = true - } - } - assert.Truef(t, task5Present, "expected task 5 to be present but was not") - assert.Truef(t, task6Present, "expected task 6 to be present but was not") + assert.Len(t, uts, 1) + assert.Len(t, uts[1].overdue, 2) + assert.Len(t, uts[1].dueToday, 1) + _, ok := uts[1].dueToday[47] + assert.True(t, ok) + + // Disable today reminders and ensure the task is not included + _, err = s.Where("id = ?", 1).Cols("today_tasks_reminders_enabled").Update(&user.User{TodayTasksRemindersEnabled: false}) + require.NoError(t, err) + uts, err = getTasksForDailyReminder(s, now, builder.Or( + builder.Eq{"users.overdue_tasks_reminders_enabled": true}, + builder.Eq{"users.today_tasks_reminders_enabled": true}, + )) + require.NoError(t, err) + assert.Len(t, uts[1].overdue, 2) + assert.Empty(t, uts[1].dueToday) + }) + t.Run("only due today", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // disable overdue reminders, keep today reminders enabled + _, err := s.Where("id = ?", 1).Cols("overdue_tasks_reminders_enabled").Update(&user.User{OverdueTasksRemindersEnabled: false}) + require.NoError(t, err) + + now, err := time.Parse(time.RFC3339Nano, "2018-12-01T09:00:00Z") + require.NoError(t, err) + uts, err := getTasksForDailyReminder(s, now, builder.Or( + builder.Eq{"users.overdue_tasks_reminders_enabled": true}, + builder.Eq{"users.today_tasks_reminders_enabled": true}, + )) + require.NoError(t, err) + assert.Len(t, uts, 1) + assert.Empty(t, uts[1].overdue) + assert.Len(t, uts[1].dueToday, 1) }) t.Run("done overdue", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -71,7 +98,10 @@ func TestGetUndoneOverDueTasks(t *testing.T) { now, err := time.Parse(time.RFC3339Nano, "2018-11-01T01:13:00Z") require.NoError(t, err) - tasks, err := getUndoneOverdueTasks(s, now, builder.Eq{"users.overdue_tasks_reminders_enabled": true}) + tasks, err := getTasksForDailyReminder(s, now, builder.Or( + builder.Eq{"users.overdue_tasks_reminders_enabled": true}, + builder.Eq{"users.today_tasks_reminders_enabled": true}, + )) require.NoError(t, err) assert.Empty(t, tasks) }) diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index afb700065..749b82901 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -48,6 +48,8 @@ type UserSettings struct { DiscoverableByEmail bool `json:"discoverable_by_email"` // If enabled, the user will get an email for their overdue tasks each morning. OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` + // If enabled, includes tasks due later today in the overdue reminder email. + TodayTasksRemindersEnabled bool `json:"today_tasks_reminders_enabled"` // The time when the daily summary of overdue tasks will be sent via email. OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"` // If a task is created without a specified project this value should be used. Applies @@ -209,6 +211,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error { user.DiscoverableByEmail = us.DiscoverableByEmail user.DiscoverableByName = us.DiscoverableByName user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled + user.TodayTasksRemindersEnabled = us.TodayTasksRemindersEnabled user.DefaultProjectID = us.DefaultProjectID user.WeekStart = us.WeekStart user.Language = us.Language diff --git a/pkg/user/user.go b/pkg/user/user.go index e4032d233..c80cb02dc 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -106,6 +106,7 @@ type User struct { DiscoverableByEmail bool `xorm:"bool default false index" json:"-"` OverdueTasksRemindersEnabled bool `xorm:"bool default true index" json:"-"` OverdueTasksRemindersTime string `xorm:"varchar(5) not null default '09:00'" json:"-"` + TodayTasksRemindersEnabled bool `xorm:"bool default false index" json:"-"` DefaultProjectID int64 `xorm:"bigint null index" json:"-"` WeekStart int `xorm:"null" json:"-"` Language string `xorm:"varchar(50) null" json:"-" valid:"language"` @@ -629,6 +630,7 @@ func UpdateUser(s *xorm.Session, user *User, forceOverride bool) (updatedUser *U "discoverable_by_name", "discoverable_by_email", "overdue_tasks_reminders_enabled", + "today_tasks_reminders_enabled", "default_project_id", "week_start", "language", diff --git a/pkg/user/user_create.go b/pkg/user/user_create.go index 63ce7e9a2..8e5a8be47 100644 --- a/pkg/user/user_create.go +++ b/pkg/user/user_create.go @@ -68,6 +68,7 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) { user.DiscoverableByEmail = config.DefaultSettingsDiscoverableByEmail.GetBool() user.OverdueTasksRemindersEnabled = config.DefaultSettingsOverdueTaskRemindersEnabled.GetBool() user.OverdueTasksRemindersTime = config.DefaultSettingsOverdueTaskRemindersTime.GetString() + user.TodayTasksRemindersEnabled = config.DefaultSettingsTodayTasksRemindersEnabled.GetBool() user.DefaultProjectID = config.DefaultSettingsDefaultProjectID.GetInt64() user.WeekStart = config.DefaultSettingsWeekStart.GetInt() user.Timezone = config.DefaultSettingsTimezone.GetString()