fix: address review feedback for today reminders

This commit is contained in:
kolaente
2025-09-03 13:42:51 +02:00
parent a519312d55
commit 301fdfcfca
17 changed files with 293 additions and 128 deletions

66
PLAN.md Normal file
View 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 endofday 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 users 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 duetoday categorisation and that duetoday 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 duetoday 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 duetoday 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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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 = ''

View File

@@ -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>

View File

@@ -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`

View File

@@ -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

View File

@@ -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'

View File

@@ -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": {

View 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 },
})
}

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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

View File

@@ -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",

View File

@@ -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()