diff --git a/src/components/date/datemathHelp.vue b/src/components/date/datemathHelp.vue index 4e4319ed8..89e20a065 100644 --- a/src/components/date/datemathHelp.vue +++ b/src/components/date/datemathHelp.vue @@ -36,35 +36,35 @@ s - {{ $t('input.datemathHelp.units.seconds') }} + {{ $tc('time.seconds', 2) }} m - {{ $t('input.datemathHelp.units.minutes') }} + {{ $tc('time.minutes', 2) }} h - {{ $t('input.datemathHelp.units.hours') }} + {{ $tc('time.hours', 2) }} H - {{ $t('input.datemathHelp.units.hours') }} + {{ $tc('time.hours', 2) }} d - {{ $t('input.datemathHelp.units.days') }} + {{ $tc('time.days', 2) }} w - {{ $t('input.datemathHelp.units.weeks') }} + {{ $tc('time.weeks', 2) }} M - {{ $t('input.datemathHelp.units.months') }} + {{ $tc('time.months', 2) }} y - {{ $t('input.datemathHelp.units.years') }} + {{ $tc('time.years', 2) }} diff --git a/src/components/tasks/partials/repeatAfter.vue b/src/components/tasks/partials/repeatAfter.vue index e416e2a6c..147441555 100644 --- a/src/components/tasks/partials/repeatAfter.vue +++ b/src/components/tasks/partials/repeatAfter.vue @@ -48,11 +48,11 @@ @change="updateData" :disabled="disabled || undefined" > - - - - - + + + + + diff --git a/src/helpers/defaultReminder.test.ts b/src/helpers/defaultReminder.test.ts new file mode 100644 index 000000000..d6f4793ec --- /dev/null +++ b/src/helpers/defaultReminder.test.ts @@ -0,0 +1,63 @@ +import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest' +import { + AMOUNTS_IN_SECONDS, + getDefaultReminderSettings, + getSavedReminderSettings, + parseSavedReminderAmount, + saveDefaultReminder, +} from '@/helpers/defaultReminder' +import * as exports from '@/helpers/defaultReminder' + +describe('Default Reminder Save', () => { + it('Should save a default reminder with minutes', () => { + const spy = vi.spyOn(window.localStorage, 'setItem') + saveDefaultReminder(true, 'minutes', 5) + + expect(spy).toHaveBeenCalledWith('defaultReminder', '{"enabled":true,"amount":300}') + }) + it('Should save a default reminder with hours', () => { + const spy = vi.spyOn(window.localStorage, 'setItem') + saveDefaultReminder(true, 'hours', 5) + + expect(spy).toHaveBeenCalledWith('defaultReminder', '{"enabled":true,"amount":18000}') + }) + it('Should save a default reminder with days', () => { + const spy = vi.spyOn(window.localStorage, 'setItem') + saveDefaultReminder(true, 'days', 5) + + expect(spy).toHaveBeenCalledWith('defaultReminder', '{"enabled":true,"amount":432000}') + }) + it('Should save a default reminder with months', () => { + const spy = vi.spyOn(window.localStorage, 'setItem') + saveDefaultReminder(true, 'months', 5) + + expect(spy).toHaveBeenCalledWith('defaultReminder', '{"enabled":true,"amount":12960000}') + }) +}) + +describe('Default Reminder Load', () => { + it('Should parse minutes', () => { + const settings = parseSavedReminderAmount(5 * AMOUNTS_IN_SECONDS.minutes) + + expect(settings.amount).toBe(5) + expect(settings.type).toBe('minutes') + }) + it('Should parse hours', () => { + const settings = parseSavedReminderAmount(5 * AMOUNTS_IN_SECONDS.hours) + + expect(settings.amount).toBe(5) + expect(settings.type).toBe('hours') + }) + it('Should parse days', () => { + const settings = parseSavedReminderAmount(5 * AMOUNTS_IN_SECONDS.days) + + expect(settings.amount).toBe(5) + expect(settings.type).toBe('days') + }) + it('Should parse months', () => { + const settings = parseSavedReminderAmount(5 * AMOUNTS_IN_SECONDS.months) + + expect(settings.amount).toBe(5) + expect(settings.type).toBe('months') + }) +}) diff --git a/src/helpers/defaultReminder.ts b/src/helpers/defaultReminder.ts new file mode 100644 index 000000000..03504611a --- /dev/null +++ b/src/helpers/defaultReminder.ts @@ -0,0 +1,89 @@ +const DEFAULT_REMINDER_KEY = 'defaultReminder' + +export const AMOUNTS_IN_SECONDS: { + [type in SavedReminderSettings['type']]: number +} = { + minutes: 60, + hours: 60 * 60, + days: 60 * 60 * 24, + months: 60 * 60 * 24 * 30, +} as const + +interface DefaultReminderSettings { + enabled: boolean, + amount: number, +} + +interface SavedReminderSettings { + enabled: boolean, + amount: number, + type: 'minutes' | 'hours' | 'days' | 'months', +} + +function calculateDefaultReminderSeconds(type: SavedReminderSettings['type'], amount: number): number { + return amount * (AMOUNTS_IN_SECONDS[type] || 0) +} + +export function saveDefaultReminder(enabled: boolean, type: SavedReminderSettings['type'], amount: number) { + const defaultReminderSeconds = calculateDefaultReminderSeconds(type, amount) + localStorage.setItem(DEFAULT_REMINDER_KEY, JSON.stringify({ + enabled, + amount: defaultReminderSeconds, + })) +} + +export function getDefaultReminderAmount(): number | null { + const settings = getDefaultReminderSettings() + + return settings?.enabled + ? settings.amount + : null +} + +export function getDefaultReminderSettings(): DefaultReminderSettings | null { + const s: string | null = window.localStorage.getItem(DEFAULT_REMINDER_KEY) + if (s === null) { + return null + } + + return JSON.parse(s) +} + +export function parseSavedReminderAmount(amountSeconds: number): SavedReminderSettings { + const amountMinutes = amountSeconds / 60 + const settings: SavedReminderSettings = { + enabled: true, // We're assuming the caller to have checked this properly + amount: amountMinutes, + type: 'minutes', + } + + if ((amountMinutes / 60 / 24) % 30 === 0) { + settings.amount = amountMinutes / 60 / 24 / 30 + settings.type = 'months' + } else if ((amountMinutes / 60) % 24 === 0) { + settings.amount = amountMinutes / 60 / 24 + settings.type = 'days' + } else if (amountMinutes % 60 === 0) { + settings.amount = amountMinutes / 60 + settings.type = 'hours' + } + + return settings +} + +export function getSavedReminderSettings(): SavedReminderSettings | null { + const s = getDefaultReminderSettings() + if (s === null) { + return null + } + + if (!s.enabled) { + return { + enabled: false, + type: 'minutes', + amount: 0, + } + } + + return parseSavedReminderAmount(s.amount) +} diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 4674f6e80..43206c594 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -87,7 +87,11 @@ "language": "Language", "defaultList": "Default List", "timezone": "Time Zone", - "overdueTasksRemindersTime": "Overdue tasks reminder email time" + "overdueTasksRemindersTime": "Overdue tasks reminder email time", + "defaultReminder": "Set a default task reminder", + "defaultReminderHint": "If enabled, Vikunja will automatically create a reminder for a task if you set a due date and the task does not have any reminders yet.", + "defaultReminderAmount": "Default task reminder amount", + "defaultReminderAmountBefore": "before the due date of a task" }, "totp": { "title": "Two Factor Authentication", @@ -547,19 +551,16 @@ "fromto": "{from} to {to}", "ranges": { "today": "Today", - "thisWeek": "This Week", "restOfThisWeek": "The Rest of This Week", "nextWeek": "Next Week", "next7Days": "Next 7 Days", "lastWeek": "Last Week", - "thisMonth": "This Month", "restOfThisMonth": "The Rest of This Month", "nextMonth": "Next Month", "next30Days": "Next 30 Days", "lastMonth": "Last Month", - "thisYear": "This Year", "restOfThisYear": "The Rest of This Year" } @@ -576,15 +577,6 @@ "roundDay": "Round down to the nearest day", "supportedUnits": "Supported time units are:", "someExamples": "Some examples of time expressions:", - "units": { - "seconds": "Seconds", - "minutes": "Minutes", - "hours": "Hours", - "days": "Days", - "weeks": "Weeks", - "months": "Months", - "years": "Years" - }, "examples": { "now": "Right now", "in24h": "In 24h", @@ -596,6 +588,15 @@ } } }, + "time": { + "seconds": "Second | Seconds", + "minutes": "Minute | Minutes", + "hours": "Hour | Hours", + "days": "Day | Days", + "weeks": "Week | Weeks", + "months": "Month | Months", + "years": "Year | Years" + }, "task": { "task": "Task", "new": "Create a new task", diff --git a/src/modelTypes/IUserSettings.ts b/src/modelTypes/IUserSettings.ts index 31e921e0e..9ba54a55e 100644 --- a/src/modelTypes/IUserSettings.ts +++ b/src/modelTypes/IUserSettings.ts @@ -12,4 +12,6 @@ export interface IUserSettings extends IAbstract { weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6 timezone: string language: string + defaultReminder: boolean + defaultReminderAmount: number // The amount of seconds a reminder should be set before a given due date } \ No newline at end of file diff --git a/src/models/userSettings.ts b/src/models/userSettings.ts index ddb6f0296..50dbb3be7 100644 --- a/src/models/userSettings.ts +++ b/src/models/userSettings.ts @@ -2,6 +2,7 @@ import AbstractModel from './abstractModel' import type {IUserSettings} from '@/modelTypes/IUserSettings' import {getCurrentLanguage} from '@/i18n' +import {getDefaultReminderAmount} from '@/helpers/defaultReminder' export default class UserSettingsModel extends AbstractModel implements IUserSettings { name = '' @@ -13,6 +14,8 @@ export default class UserSettingsModel extends AbstractModel impl weekStart = 0 as IUserSettings['weekStart'] timezone = '' language = getCurrentLanguage() + defaultReminder = false + defaultReminderAmount = getDefaultReminderAmount() || 0 constructor(data: Partial = {}) { super() diff --git a/src/services/task.ts b/src/services/task.ts index bb41b6853..f0f098edc 100644 --- a/src/services/task.ts +++ b/src/services/task.ts @@ -6,6 +6,7 @@ import LabelService from './label' import {formatISO} from 'date-fns' import {colorFromHex} from '@/helpers/color/colorFromHex' +import {getDefaultReminderAmount} from '@/helpers/defaultReminder' const parseDate = date => { if (date) { @@ -39,7 +40,7 @@ export default class TaskService extends AbstractService { } processModel(updatedModel) { - const model = { ...updatedModel } + const model = {...updatedModel} model.title = model.title?.trim() @@ -68,6 +69,15 @@ export default class TaskService extends AbstractService { }) } + if (model.dueDate !== null && model.reminderDates.length === 0) { + const defaultReminder = getDefaultReminderAmount() + if (defaultReminder !== null) { + const dueDate = +new Date(model.dueDate) + const reminderDate = new Date(dueDate - (defaultReminder * 1000)) + model.reminderDates.push(formatISO(reminderDate)) + } + } + // Make the repeating amount to seconds let repeatAfterSeconds = 0 if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) { diff --git a/src/styles/theme/form.scss b/src/styles/theme/form.scss index 86c78f2fd..e790d5523 100644 --- a/src/styles/theme/form.scss +++ b/src/styles/theme/form.scss @@ -56,6 +56,7 @@ } } +.field.has-addons .select select, .field.has-addons .control .select select { height: 100%; } diff --git a/src/views/tasks/TaskDetailView.vue b/src/views/tasks/TaskDetailView.vue index ea67956e1..35a624c3c 100644 --- a/src/views/tasks/TaskDetailView.vue +++ b/src/views/tasks/TaskDetailView.vue @@ -697,6 +697,9 @@ export default defineComponent({ } this.task = await this.$store.dispatch('tasks/update', task) + + // Show new fields set from the api or a newly set default reminder + this.$nextTick(() => this.setActiveFields()) if (!showNotification) { return diff --git a/src/views/user/settings/General.vue b/src/views/user/settings/General.vue index 2664fe019..97323f517 100644 --- a/src/views/user/settings/General.vue +++ b/src/views/user/settings/General.vue @@ -18,6 +18,50 @@ +
+ +

+ {{ $t('user.settings.general.defaultReminderHint') }} +

+
+
+ +
+
+ +
+
+ +
+

+ {{ $t('user.settings.general.defaultReminderAmountBefore') }} +

+
+