mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
fix: address review feedback for today reminders
This commit is contained in:
66
PLAN.md
Normal file
66
PLAN.md
Normal file
@@ -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.
|
||||
@@ -879,6 +879,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",
|
||||
|
||||
@@ -89,6 +89,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",
|
||||
|
||||
@@ -35,9 +35,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
|
||||
|
||||
@@ -15,6 +15,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
|
||||
discoverableByEmail = false
|
||||
overdueTasksRemindersEnabled = true
|
||||
overdueTasksRemindersTime = undefined
|
||||
todayTasksRemindersEnabled = false
|
||||
defaultProjectId = undefined
|
||||
weekStart = 0 as IUserSettings['weekStart']
|
||||
timezone = ''
|
||||
|
||||
@@ -123,26 +123,34 @@
|
||||
{{ $t('user.settings.general.overdueReminders') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="settings.overdueTasksRemindersEnabled"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
for="overdueTasksReminderTime"
|
||||
class="two-col"
|
||||
>
|
||||
<span>
|
||||
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
|
||||
</span>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
id="overdueTasksReminderTime"
|
||||
v-model="settings.overdueTasksRemindersTime"
|
||||
class="input"
|
||||
type="time"
|
||||
@keyup.enter="updateSettings"
|
||||
v-model="settings.todayTasksRemindersEnabled"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.todayReminders') }}
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="settings.overdueTasksRemindersEnabled || settings.todayTasksRemindersEnabled">
|
||||
<div class="field">
|
||||
<label
|
||||
for="overdueTasksReminderTime"
|
||||
class="two-col"
|
||||
>
|
||||
<span>
|
||||
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
|
||||
</span>
|
||||
<input
|
||||
id="overdueTasksReminderTime"
|
||||
v-model="settings.overdueTasksRemindersTime"
|
||||
class="input"
|
||||
type="time"
|
||||
@keyup.enter="updateSettings"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -194,6 +194,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`
|
||||
|
||||
@@ -409,3 +409,14 @@
|
||||
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 due today'
|
||||
done: false
|
||||
created_by_id: 1
|
||||
project_id: 1
|
||||
index: 13
|
||||
created: 2018-12-01 01:12:04
|
||||
updated: 2018-12-01 01:12:04
|
||||
due_date: 2018-12-01 23:00:00
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -98,6 +98,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": {
|
||||
|
||||
40
pkg/migration/20250903072808.go
Normal file
40
pkg/migration/20250903072808.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||
@@ -217,78 +217,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"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -31,13 +31,13 @@ import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[int64]*userWithTasks, err error) {
|
||||
func getTasksForDailyReminder(s *xorm.Session, now time.Time) (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)
|
||||
@@ -54,7 +54,10 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[i
|
||||
taskIDs = append(taskIDs, task.ID)
|
||||
}
|
||||
|
||||
users, err := getTaskUsersForTasks(s, taskIDs, builder.Eq{"users.overdue_tasks_reminders_enabled": true})
|
||||
users, err := getTaskUsersForTasks(s, taskIDs, builder.Or(
|
||||
builder.Eq{"users.overdue_tasks_reminders_enabled": true},
|
||||
builder.Eq{"users.today_tasks_reminders_enabled": true},
|
||||
))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -84,19 +87,28 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[i
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,11 +116,12 @@ func getUndoneOverdueTasks(s *xorm.Session, now time.Time) (usersWithTasks map[i
|
||||
}
|
||||
|
||||
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() {
|
||||
if !config.ServiceEnableEmailReminders.GetBool() {
|
||||
return
|
||||
@@ -124,56 +137,52 @@ func RegisterOverdueReminderCron() {
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now()
|
||||
uts, err := getUndoneOverdueTasks(s, now)
|
||||
uts, err := getTasksForDailyReminder(s, now)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
var n notifications.Notification = &UndoneTasksOverdueNotification{
|
||||
User: ut.user,
|
||||
Tasks: ut.tasks,
|
||||
Projects: projects,
|
||||
n := &DailyTasksReminderNotification{
|
||||
User: ut.user,
|
||||
OverdueTasks: ut.overdue,
|
||||
DueToday: ut.dueToday,
|
||||
Projects: projects,
|
||||
}
|
||||
|
||||
if len(ut.tasks) == 1 {
|
||||
// We know there's only one entry in the map so this is actually O(1) and we can use it to get the
|
||||
// first entry without knowing the key of it.
|
||||
for _, t := range ut.tasks {
|
||||
n = &UndoneTaskOverdueNotification{
|
||||
User: ut.user,
|
||||
Task: t,
|
||||
Project: projects[t.ProjectID],
|
||||
}
|
||||
}
|
||||
if len(ut.overdue) == 0 && len(ut.dueToday) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
err = notifications.Notify(ut.user, n)
|
||||
if err != nil {
|
||||
log.Errorf("[Undone Overdue Tasks Reminder] Could not notify user %d: %s", ut.user.ID, err)
|
||||
log.Errorf("[Daily Tasks Reminder] Could not notify user %d: %s", ut.user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("[Undone Overdue Tasks Reminder] Sent reminder email for %d tasks to user %d", len(ut.tasks), ut.user.ID)
|
||||
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 != nil {
|
||||
log.Fatalf("Could not register undone overdue tasks reminder cron: %s", err)
|
||||
log.Fatalf("Could not register daily tasks reminder cron: %s", err)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,12 @@ import (
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -33,34 +34,49 @@ 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)
|
||||
tasks, err := getTasksForDailyReminder(s, now)
|
||||
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)
|
||||
uts, err := getTasksForDailyReminder(s, now)
|
||||
require.NoError(t, err)
|
||||
assert.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].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)
|
||||
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)
|
||||
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)
|
||||
@@ -69,7 +85,7 @@ 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)
|
||||
tasks, err := getTasksForDailyReminder(s, now)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, tasks)
|
||||
})
|
||||
@@ -49,6 +49,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
|
||||
@@ -210,6 +212,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
|
||||
|
||||
@@ -97,6 +97,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"`
|
||||
@@ -602,6 +603,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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user