diff --git a/src/components/AppSidebar/Alarm/AlarmDateTimePickerModal.vue b/src/components/AppSidebar/Alarm/AlarmDateTimePickerModal.vue new file mode 100644 index 000000000..9eb71b5be --- /dev/null +++ b/src/components/AppSidebar/Alarm/AlarmDateTimePickerModal.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/AppSidebar/Alarm/AlarmList.vue b/src/components/AppSidebar/Alarm/AlarmList.vue new file mode 100644 index 000000000..7c321084d --- /dev/null +++ b/src/components/AppSidebar/Alarm/AlarmList.vue @@ -0,0 +1,192 @@ + + + + + + + diff --git a/src/components/AppSidebar/Alarm/AlarmListItem.vue b/src/components/AppSidebar/Alarm/AlarmListItem.vue new file mode 100644 index 000000000..4cc47f80b --- /dev/null +++ b/src/components/AppSidebar/Alarm/AlarmListItem.vue @@ -0,0 +1,265 @@ + + + + + + + diff --git a/src/components/AppSidebar/Alarm/AlarmListNew.vue b/src/components/AppSidebar/Alarm/AlarmListNew.vue new file mode 100644 index 000000000..06890c221 --- /dev/null +++ b/src/components/AppSidebar/Alarm/AlarmListNew.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/components/AppSidebar/Alarm/AlarmRelativeTimePickerModal.vue b/src/components/AppSidebar/Alarm/AlarmRelativeTimePickerModal.vue new file mode 100644 index 000000000..1391461d8 --- /dev/null +++ b/src/components/AppSidebar/Alarm/AlarmRelativeTimePickerModal.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/components/AppSidebar/Alarm/AlarmTimeUnitSelect.vue b/src/components/AppSidebar/Alarm/AlarmTimeUnitSelect.vue new file mode 100644 index 000000000..9b8d516fd --- /dev/null +++ b/src/components/AppSidebar/Alarm/AlarmTimeUnitSelect.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/SortorderDropdown.vue b/src/components/SortorderDropdown.vue index c8608540e..39192ba6f 100644 --- a/src/components/SortorderDropdown.vue +++ b/src/components/SortorderDropdown.vue @@ -95,7 +95,7 @@ export default { Pencil, Plus, Star, - TagMultiple + TagMultiple, }, directives: { Tooltip, diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue index 70cd7f680..6eb6c3ee2 100644 --- a/src/components/TaskBody.vue +++ b/src/components/TaskBody.vue @@ -91,6 +91,7 @@ License along with this library. If not, see . :aria-label="t('tasks', '{complete} % completed', {complete: task.complete})" :title="t('tasks', '{complete} % completed', {complete: task.complete})" :color="task.calendar.color" /> + .material-design-icon { opacity: .5; + margin-inline: 4px; &.text-box-outline-icon { cursor: pointer; diff --git a/src/models/alarm.js b/src/models/alarm.js new file mode 100644 index 000000000..b10543976 --- /dev/null +++ b/src/models/alarm.js @@ -0,0 +1,135 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { + getAmountAndUnitForTimedEvents, + getAmountHoursMinutesAndUnitForAllDayEvents, + getDateFromDateTimeValue, +} from '../utils/alarms.js' +import { AlarmComponent } from '@nextcloud/calendar-js' + +/** + * Creates a complete alarm object based on given props + * + * @param {object} props The alarm properties already provided + * @return {object} + */ +const getDefaultAlarmObject = (props = {}) => Object.assign({}, { + // The calendar-js alarm component + alarmComponent: null, + // Type of alarm: DISPLAY, EMAIL, AUDIO + type: null, + // Whether or not the alarm is relative + isRelative: false, + // Date object of an absolute alarm (if it's absolute, it must be DATE-TIME and UTC) + absoluteDate: null, + // Whether or not the relative alarm is before the event + relativeIsBefore: null, + // Whether or not the alarm is relative to the event's start + relativeIsRelatedToStart: null, + // TIMED EVENTS: + // Unit (seconds, minutes, hours, ...) if this alarm is inside a timed event + relativeUnitTimed: null, + // The amount of unit if this alarm is inside a timed event + relativeAmountTimed: null, + // ALL-DAY EVENTS: + // Unit (seconds, minutes, hours, ...) if this alarm is inside an all-day event + relativeUnitAllDay: null, + // The amount of unit if this alarm is inside an all-day event + relativeAmountAllDay: null, + // The hours to display alarm for in an all-day event (e.g. 1 day before at 9:00 am) + relativeHoursAllDay: null, + // The minutes to display alarm for in an all-day event (e.g. 1 day before at 9:30 am) + relativeMinutesAllDay: null, + // The total amount of seconds for a relative alarm + relativeTrigger: null, +}, props) + +/** + * Map an alarm component to our alarm object + * + * @param {AlarmComponent} alarmComponent The calendar.js alarm-component to turn into an alarm object + * @return {object} + */ +const mapAlarmComponentToAlarmObject = (alarmComponent) => { + if (alarmComponent.trigger.isRelative()) { + const relativeIsBefore = alarmComponent.trigger.value.isNegative + const relativeIsRelatedToStart = alarmComponent.trigger.related === 'START' + + const { + amount: relativeAmountTimed, + unit: relativeUnitTimed, + } = getAmountAndUnitForTimedEvents(alarmComponent.trigger.value.totalSeconds) + + const { + unit: relativeUnitAllDay, + amount: relativeAmountAllDay, + hours: relativeHoursAllDay, + minutes: relativeMinutesAllDay, + } = getAmountHoursMinutesAndUnitForAllDayEvents(alarmComponent.trigger.value.totalSeconds) + + const relativeTrigger = alarmComponent.trigger.value.totalSeconds + + return getDefaultAlarmObject({ + alarmComponent, + type: alarmComponent.action, + isRelative: alarmComponent.trigger.isRelative(), + relativeIsBefore, + relativeIsRelatedToStart, + relativeUnitTimed, + relativeAmountTimed, + relativeUnitAllDay, + relativeAmountAllDay, + relativeHoursAllDay, + relativeMinutesAllDay, + relativeTrigger, + }) + } else { + const absoluteDate = getDateFromDateTimeValue(alarmComponent.trigger.value) + + return getDefaultAlarmObject({ + alarmComponent, + type: alarmComponent.action, + isRelative: alarmComponent.trigger.isRelative(), + absoluteDate, + }) + } +} + +/** + * @param {number} time Total amount of seconds for the trigger + * @param {boolean} relatedToStart If the alarm is related to the start of the event + * @return {object} The alarm object + */ +export function getAlarmObjectFromTriggerTime(time, relatedToStart) { + const timedData = getAmountAndUnitForTimedEvents(time) + const allDayData = getAmountHoursMinutesAndUnitForAllDayEvents(time) + + return getDefaultAlarmObject({ + isRelative: true, + relativeTrigger: time, + relativeIsBefore: time < 0, + relativeIsRelatedToStart: relatedToStart, + relativeUnitTimed: timedData.unit, + relativeAmountTimed: timedData.amount, + relativeUnitAllDay: allDayData.unit, + relativeAmountAllDay: allDayData.amount, + relativeHoursAllDay: allDayData.hours, + relativeMinutesAllDay: allDayData.minutes, + }) +} + +/** + * @param {Date} date The alarm date + * @return {object} The alarm object + */ +export function getAlarmObjectFromDateTime(date) { + return getDefaultAlarmObject({ + isRelative: false, + relativeTrigger: null, + absoluteDate: date, + }) +} + +export { mapAlarmComponentToAlarmObject } diff --git a/src/models/task.js b/src/models/task.js index d614631fc..d82518170 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -86,6 +86,9 @@ export default class Task { this.vtodo.addPropertyWithValue('uid', uuid()) } + // Define components + this._alarms = this.getAlarms() + // Define properties, so Vue reacts to changes of them this._uid = this.vtodo.getFirstPropertyValue('uid') || '' this._summary = this.vtodo.getFirstPropertyValue('summary') || '' @@ -561,8 +564,60 @@ export default class Task { this._loaded = loadedCompleted } - get reminder() { - return null + get alarms() { + return this._alarms + } + + getAlarms() { + return this.vCalendar.getAllSubcomponents('valarm') || [] + } + + /** + * Add an alarm + * + * @param {{ action: "AUDIO"|"DISPLAY"|"EMAIL"|"PROCEDURE", repeat: number, trigger: { value: ICAL.Duration|ICAL.Time, parameter: object }}} alarm The alarm + */ + addAlarm({ action, repeat, trigger }) { + const valarm = new ICAL.Component('valarm') + valarm.addPropertyWithValue('action', action) + valarm.addPropertyWithValue('repeat', repeat) + const triggerProperty = valarm.addPropertyWithValue('trigger', trigger.value) + if (trigger.parameter) { + triggerProperty.setParameter(trigger.parameter.name, trigger.parameter.value) + } + this.vCalendar.addSubcomponent(valarm) + + this.updateLastModified() + this._alarms = this.getAlarms() + } + + updateAlarm({ action, repeat, trigger }, index) { + const valarms = this.vCalendar.getAllSubcomponents('valarm') + const valarmToUpdate = valarms[index] + + if (valarmToUpdate) { + valarmToUpdate.updatePropertyWithValue('trigger', trigger.value) + + this.updateLastModified() + this._alarms = this.getAlarms() + } + } + + /** + * Remove an alarm + * + * @param {number} index The index of the alarm-list + */ + removeAlarm(index) { + const valarms = this.vCalendar.getAllSubcomponents('valarm') + const valarmToDelete = valarms[index] + + if (valarmToDelete) { + this.vCalendar.removeSubcomponent(valarms[index]) + + this.updateLastModified() + this._alarms = this.getAlarms() + } } /** @@ -588,7 +643,7 @@ export default class Task { /** * Set the tags * - * * @param {string[]} newTags The new tags to set + * @param {string[]} newTags The new tags to set * @memberof Task */ set tags(newTags) { diff --git a/src/store/tasks.js b/src/store/tasks.js index 15603a632..de59fff47 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -471,6 +471,43 @@ const mutations = { task.tags = task.tags.concat([tag]) }, + /** + * Adds an alarm to a task + * + * @param {object} state The store data + * @param {object} data Destructuring object + * @param {Task} data.task The task + * @param {object} data.alarm The alarm to add + */ + addAlarm(state, { task, alarm }) { + task.addAlarm(alarm) + }, + + /** + * Adds an alarm to a task + * + * @param {object} state The store data + * @param {object} data Destructuring object + * @param {Task} data.task The task + * @param {object} data.alarm The alarm to add + * @param {number} data.index The index of the alarm-item to remove + */ + updateAlarm(state, { task, alarm, index }) { + task.updateAlarm(alarm, index) + }, + + /** + * Removes an alarm from a task + * + * @param {object} state The store data + * @param {object} data Destructuring object + * @param {Task} data.task The task + * @param {number} data.index The index of the alarm-item to remove + */ + removeAlarm(state, { task, index }) { + task.removeAlarm(index) + }, + /** * Sets the priority of a task * @@ -1196,6 +1233,39 @@ const actions = { context.dispatch('updateTask', task) }, + /** + * Adds an alarm to a task + * + * @param {object} context The store context + * @param {Task} task The task to update + */ + async addAlarm(context, { task, alarm }) { + context.commit('addAlarm', { task, alarm }) + context.dispatch('updateTask', task) + }, + + /** + * Adds an alarm to a task + * + * @param {object} context The store context + * @param {Task} task The task to update + */ + async updateAlarm(context, { task, alarm, index }) { + context.commit('updateAlarm', { task, alarm, index }) + context.dispatch('updateTask', task) + }, + + /** + * Removes an alarm from a task + * + * @param {object} context The store context + * @param {Task} task The task to update + */ + async removeAlarm(context, { task, index }) { + context.commit('removeAlarm', { task, index }) + context.dispatch('updateTask', task) + }, + /** * Sets the priority of a task * diff --git a/src/utils/alarms.js b/src/utils/alarms.js new file mode 100644 index 000000000..88bbab968 --- /dev/null +++ b/src/utils/alarms.js @@ -0,0 +1,260 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import moment from '@nextcloud/moment' + +/** + * Get the factor for a given unit + * + * @param {string} unit The name of the unit to get the factor of + * @return {number} + */ +export function getFactorForAlarmUnit(unit) { + switch (unit) { + case 'seconds': + return 1 + + case 'minutes': + return 60 + + case 'hours': + return 60 * 60 + + case 'days': + return 24 * 60 * 60 + + case 'weeks': + return 7 * 24 * 60 * 60 + + default: + return 1 + } +} + +/** + * Gets the amount of days / weeks, unit from total seconds + * + * @param {number} totalSeconds Total amount of seconds + * @return {{amount: number, unit: string}} + */ +export function getAmountAndUnitForTimedEvents(totalSeconds) { + // Before or after the event is handled somewhere else, + // so make sure totalSeconds is positive + totalSeconds = Math.abs(totalSeconds) + + // Handle the special case of 0, so we don't show 0 weeks + if (totalSeconds === 0) { + return { + amount: 0, + unit: 'minutes', + } + } + + if (totalSeconds % (7 * 24 * 60 * 60) === 0) { + return { + amount: totalSeconds / (7 * 24 * 60 * 60), + unit: 'weeks', + } + } + if (totalSeconds % (24 * 60 * 60) === 0) { + return { + amount: totalSeconds / (24 * 60 * 60), + unit: 'days', + } + } + if (totalSeconds % (60 * 60) === 0) { + return { + amount: totalSeconds / (60 * 60), + unit: 'hours', + } + } + if (totalSeconds % (60) === 0) { + return { + amount: totalSeconds / (60), + unit: 'minutes', + } + } + + return { + amount: totalSeconds, + unit: 'seconds', + } +} + +/** + * Gets the amount of days / weeks, unit, hours and minutes from total seconds + * + * @param {number} totalSeconds Total amount of seconds + * @return {{amount: *, unit: *, hours: *, minutes: *}} + */ +export function getAmountHoursMinutesAndUnitForAllDayEvents(totalSeconds) { + const dayFactor = getFactorForAlarmUnit('days') + const hourFactor = getFactorForAlarmUnit('hours') + const minuteFactor = getFactorForAlarmUnit('minutes') + const isNegative = totalSeconds < 0 + totalSeconds = Math.abs(totalSeconds) + + let dayPart = Math.floor(totalSeconds / dayFactor) + const hourPart = totalSeconds % dayFactor + + if (hourPart !== 0) { + if (isNegative) { + dayPart++ + } + } + + let amount = 0 + let unit = null + if (dayPart === 0) { + unit = 'days' + } else if (dayPart % 7 === 0) { + amount = dayPart / 7 + unit = 'weeks' + } else { + amount = dayPart + unit = 'days' + } + + let hours = Math.floor(hourPart / hourFactor) + const minutePart = hourPart % hourFactor + let minutes = Math.floor(minutePart / minuteFactor) + + if (isNegative) { + hours = 24 - hours + + if (minutes !== 0) { + hours-- + minutes = 60 - minutes + } + } + + return { + amount, + unit, + hours, + minutes, + } +} + +/** + * Get the total amount of seconds for all-day events + * + * @param {number} amount amount of unit + * @param {number} hours Time of reminder + * @param {number} minutes Time of reminder + * @param {string} unit days/weeks + * @return {number} + */ +export function getTotalSecondsFromAmountHourMinutesAndUnitForAllDayEvents(amount, hours, minutes, unit) { + if (unit === 'weeks') { + amount *= 7 + unit = 'days' + } + + // 0 is on the same day of the all-day event => positive + // 1 ... n before the event is negative + const isNegative = amount > 0 + + if (isNegative) { + // If it's negative, we need to subtract one day + amount-- + // Convert days to seconds + amount *= getFactorForAlarmUnit(unit) + + let invertedHours = 24 - hours + let invertedMinutes = 0 + + if (minutes !== 0) { + invertedHours-- + invertedMinutes = 60 - minutes + } + + amount += (invertedHours * getFactorForAlarmUnit('hours')) + amount += (invertedMinutes * getFactorForAlarmUnit('minutes')) + + amount *= -1 + } else { + // Convert days to seconds + amount *= getFactorForAlarmUnit('days') + + amount += (hours * getFactorForAlarmUnit('hours')) + amount += (minutes * getFactorForAlarmUnit('minutes')) + } + + return amount +} + +/** + * @param {boolean} allDay Is all day? + */ +export function getDefaultAlarms(allDay = false) { + if (allDay) { + return [ + 9 * 60 * 60, // On the day of the event at 9am + -15 * 60 * 60, // 1 day before at 9am + -39 * 60 * 60, // 2 days before at 9am + -159 * 60 * 60, // 1 week before at 9am + ] + } else { + return [ + 0, // At the time of the event + -10 * 60, // 10 minutes before + -30 * 60, // 30 minutes before + -1 * 60 * 60, // 1 hour before + -2 * 60 * 60, // 2 hour before + -1 * 24 * 60 * 60, // 1 day before + -2 * 24 * 60 * 60, // 2 days before + ] + } +} + +/** + * @return {Date[]} + */ +export function getDefaultAbsoluteAlarms() { + return [ + moment().add(1, 'day').startOf('day').add(9, 'hours').toDate(), + ] +} + +/** + * + * @param {Date} date The date of the alarm + * @param {string} timeZone The current user timezone + * @return {Date} + */ +export function convertTimeZone(date, timeZone) { + const utcDate = new Date(Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + )) + + return new Date(utcDate.toLocaleString('en-US', { timeZone })) +} + +/** + * Gets a date object based on the given DateTimeValue + * Ignores given timezone-information + * + * @typedef {import('@nextcloud/calendar-js').DateTimeValue} DateTimeValue + * @param {DateTimeValue} dateTimeValue Value to get date from + * @return {Date} + */ +export function getDateFromDateTimeValue(dateTimeValue) { + return new Date( + dateTimeValue.year, + dateTimeValue.month - 1, + dateTimeValue.day, + dateTimeValue.hour, + dateTimeValue.minute, + 0, + 0, + ) +} diff --git a/src/utils/dateStrings.js b/src/utils/dateStrings.js index aee936fd2..525acfb5f 100644 --- a/src/utils/dateStrings.js +++ b/src/utils/dateStrings.js @@ -20,6 +20,10 @@ * */ +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' +import { convertTimeZone } from './alarms.js' + /** * Returns a formatted string for the due date * @@ -141,3 +145,85 @@ export function startDateString(task) { }) } } + +/** + * Formats an alarm + * + * @param {object} alarm The alarm object to format + * @param {boolean} isAllDay Whether or not the event is all-day + * @param {string} currentUserTimezone The current timezone of the user + * @param {string} locale The locale to format it in + * @return {string} + */ +export function formatAlarm(alarm, isAllDay, currentUserTimezone, locale) { + if (alarm.relativeTrigger !== null) { + // Relative trigger + if (isAllDay && alarm.relativeIsRelatedToStart && alarm.relativeTrigger < 86400) { + if (alarm.relativeTrigger === 0) { + return t('tasks', 'Midnight on the day the task starts') + } + + const date = new Date() + date.setHours(alarm.relativeHoursAllDay) + date.setMinutes(alarm.relativeMinutesAllDay) + date.setSeconds(0) + date.setMilliseconds(0) + const formattedHourMinute = moment(date).locale(locale).format('LT') + + if (alarm.relativeTrigger < 0) { + if (alarm.relativeUnitAllDay === 'days') { + return n('tasks', + '%n day before the task at {formattedHourMinute}', + '%n days before the task at {formattedHourMinute}', + alarm.relativeAmountAllDay, { + formattedHourMinute, + }) + } else { + return n('tasks', + '%n week before the task at {formattedHourMinute}', + '%n weeks before the task at {formattedHourMinute}', + alarm.relativeAmountAllDay, { + formattedHourMinute, + }) + } + } + return t('tasks', 'on the day of the task at {formattedHourMinute}', { + formattedHourMinute, + }) + } else { + // Alarms at the task's start or end + if (alarm.relativeTrigger === 0) { + if (alarm.relativeIsRelatedToStart) { + return t('tasks', 'at the task\'s start') + } else { + return t('tasks', 'when the task is due') + } + } + + const time = moment.duration(Math.abs(alarm.relativeTrigger), 'seconds').locale(locale).humanize() + + if (alarm.relativeTrigger < 0) { + if (alarm.relativeIsRelatedToStart) { + return t('tasks', '{time} before the task starts', { time }) + } else { + return t('tasks', '{time} before the task is due', { time }) + } + } + + if (alarm.relativeIsRelatedToStart) { + return t('tasks', '{time} after the task starts', { time }) + } else { + return t('tasks', '{time} after the task is due', { time }) + } + } + } else { + // Absolute trigger + // There are no timezones in the VALARM component, since dates can only be relative or saved as UTC. + const currentUserTimezoneDate = convertTimeZone(alarm.absoluteDate, currentUserTimezone) + return t('tasks', '{time}', { + time: moment(currentUserTimezoneDate).locale(locale).calendar(null, { + sameElse: 'LLL', // Overwrites the default `DD/MM/YYYY` (which misses the time) + }), + }) + } +} diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index 5fe9bc0b7..68b62f3bf 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -222,6 +222,18 @@ License along with this library. If not, see . icon="TagMultiple" @add-tag="updateTag" @set-tags="updateTags" /> + + + @@ -244,26 +256,11 @@ License along with this library. If not, see . :task="task" @set-value="({task, value}) => setNote({ task, note: value })" /> -