feat: Edit relative reminders #3248

Merged
konrad merged 27 commits from ce72/frontend:main into main 2023-06-10 17:04:10 +00:00
18 changed files with 869 additions and 278 deletions

View File

@ -0,0 +1,26 @@
<template>
<BaseButton class="simple-button">
<slot/>
</BaseButton>
</template>
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
</script>
<style lang="scss" scoped>
.simple-button {
color: var(--text);
padding: .25rem .5rem;
transition: background-color $transition;
border-radius: $radius;
display: block;
margin: .1rem 0;
width: 100%;
text-align: left;
&:hover {
background: var(--white);
}
}
</style>

View File

@ -1,78 +1,15 @@
<template>
<div class="datepicker">
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
<SimpleButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
</BaseButton>
</SimpleButton>
<CustomTransition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
<BaseButton
v-if="(new Date()).getHours() < 21"
class="datepicker__quick-select-date"
@click.stop="setDate('today')"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="text">
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="text">
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
</span>
</BaseButton>
<flat-pickr
:config="flatPickerConfig"
class="input"
v-model="flatPickrDate"
<DatepickerInline
v-model="date"
@update:model-value="updateData"
/>
<x-button
@ -89,19 +26,15 @@
</template>
<script setup lang="ts">
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {ref, onMounted, onBeforeUnmount, toRef, watch, type PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import DatepickerInline from '@/components/input/datepickerInline.vue'
import SimpleButton from '@/components/input/SimpleButton.vue'
import {formatDate, formatDateShort} from '@/helpers/time/formatDate'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {formatDateShort} from '@/helpers/time/formatDate'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const props = defineProps({
@ -125,8 +58,6 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const show = ref(false)
const changed = ref(false)
@ -141,42 +72,6 @@ watch(
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
const flatPickrDate = computed({
set(newValue: string | Date | null) {
if (newValue === null) {
date.value = null
return
}
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
@ -217,29 +112,6 @@ function close() {
}
}, 200)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>
@ -262,42 +134,6 @@ function getWeekdayFromStringInterval(dateString: string) {
}
}
.datepicker__quick-select-date {
display: flex;
align-items: center;
padding: 0 .5rem;
width: 100%;
height: 2.25rem;
color: var(--text);
transition: all $transition;
&:first-child {
border-radius: $radius $radius 0 0;
}
&:hover {
background: var(--grey-100);
}
.text {
width: 100%;
font-size: .85rem;
display: flex;
justify-content: space-between;
padding-right: .25rem;
.weekday {
color: var(--text-light);
text-transform: capitalize;
}
}
.icon {
width: 2rem;
text-align: center;
}
}
.datepicker__close-button {
margin: 1rem;
width: calc(100% - 2rem);

View File

@ -0,0 +1,228 @@
<template>
<BaseButton
v-if="(new Date()).getHours() < 21"
class="datepicker__quick-select-date"
@click.stop="setDate('today')"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="text">
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="text">
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
</span>
</BaseButton>
<div class="flatpickr-container">
<flat-pickr
:config="flatPickerConfig"
v-model="flatPickrDate"
/>
</div>
</template>
<script lang="ts" setup>
import {ref, toRef, watch, computed, type PropType} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import BaseButton from '@/components/base/BaseButton.vue'
import {formatDate} from '@/helpers/time/formatDate'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const props = defineProps({
modelValue: {
type: [Date, null, String] as PropType<Date | null | string>,
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
default: null,
},
})
const emit = defineEmits(['update:modelValue', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const changed = ref(false)
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
setDateValue,
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
const flatPickrDate = computed({
set(newValue: string | Date | null) {
if (newValue === null) {
date.value = null
return
}
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
return
}
date.value = createDateFromString(dateString)
}
function updateData() {
changed.value = true
emit('update:modelValue', date.value)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>
.datepicker__quick-select-date {
display: flex;
align-items: center;
padding: 0 .5rem;
width: 100%;
height: 2.25rem;
color: var(--text);
transition: all $transition;
&:first-child {
border-radius: $radius $radius 0 0;
}
&:hover {
background: var(--grey-100);
}
.text {
width: 100%;
font-size: .85rem;
display: flex;
justify-content: space-between;
padding-right: .25rem;
.weekday {
color: var(--text-light);
text-transform: capitalize;
}
}
.icon {
width: 2rem;
text-align: center;
}
}
.flatpickr-container {
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
:deep(.input) {
border: none;
}
}
</style>

View File

@ -8,7 +8,7 @@
}"
ref="popup"
>
<slot name="content" :isOpen="open"/>
<slot name="content" :isOpen="open" :toggle="toggle"/>
</div>
</template>
@ -23,11 +23,14 @@ const props = defineProps({
},
})
const emit = defineEmits(['close'])
const open = ref(false)
const popup = ref<HTMLElement | null>(null)
function close() {
open.value = false
emit('close')
}
function toggle() {

View File

@ -0,0 +1,269 @@
<template>
<div>
<Popup @close="showFormSwitch = null">
dpschen marked this conversation as resolved Outdated

Nitpicking, but please format this like this:

<ReminderPeriod 
    v-if="showRelativeReminder"
    v-model="reminder"
    :disabled="disabled"
    @update:modelValue="() => updateData()"
/>

That way it's more readable.

Nitpicking, but please format this like this: ``` <ReminderPeriod v-if="showRelativeReminder" v-model="reminder" :disabled="disabled" @update:modelValue="() => updateData()" /> ``` That way it's more readable.

Fixed in my changes, see #3248 (comment)

Fixed in my changes, see https://kolaente.dev/vikunja/frontend/pulls/3248#issuecomment-47907
<template #trigger="{toggle}">
<SimpleButton
@click.prevent.stop="toggle()"
>
{{ reminderText }}
</SimpleButton>
</template>
<template #content="{isOpen, toggle}">
<Card class="reminder-options-popup" :class="{'is-open': isOpen}" :padding="false">
<div class="options" v-if="showFormSwitch === null">
<SimpleButton
v-for="(p, k) in presets"
:key="k"
class="option-button"
:class="{'currently-active': p.relativePeriod === modelValue?.relativePeriod && modelValue?.relativeTo === p.relativeTo}"
@click="setReminderFromPreset(p, toggle)"
>
{{ formatReminder(p) }}
</SimpleButton>
<SimpleButton
@click="showFormSwitch = 'relative'"
class="option-button"
:class="{'currently-active': typeof modelValue !== 'undefined' && modelValue?.relativeTo !== null && presets.find(p => p.relativePeriod === modelValue?.relativePeriod && modelValue?.relativeTo === p.relativeTo) === undefined}"
>
{{ $t('task.reminder.custom') }}
</SimpleButton>
<SimpleButton
@click="showFormSwitch = 'absolute'"
class="option-button"
:class="{'currently-active': modelValue?.relativeTo === null}"
>
{{ $t('task.reminder.dateAndTime') }}
</SimpleButton>
</div>
<ReminderPeriod
v-if="showFormSwitch === 'relative'"
v-model="reminder"
@update:modelValue="updateDataAndMaybeClose(toggle)"
/>
<DatepickerInline
v-if="showFormSwitch === 'absolute'"
v-model="reminderDate"
@update:modelValue="setReminderDate"
/>
<x-button
v-if="showFormSwitch !== null"
class="reminder__close-button"
:shadow="false"
@click="toggle"
dpschen marked this conversation as resolved Outdated

Please use a computed for both show... functions instead.

Please use a `computed` for both `show...` functions instead.
Outdated
Review

ok

ok
>
{{ $t('misc.confirm') }}
</x-button>
</Card>
</template>
</Popup>
</div>
</template>
<script setup lang="ts">
import {computed, ref, watch, type PropType} from 'vue'
import {toRef} from '@vueuse/core'
import {SECONDS_A_DAY} from '@/constants/date'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {useI18n} from 'vue-i18n'
import {PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {formatDateShort} from '@/helpers/time/formatDate'
import DatepickerInline from '@/components/input/datepickerInline.vue'
import ReminderPeriod from '@/components/tasks/partials/reminder-period.vue'
import Popup from '@/components/misc/popup.vue'
import TaskReminderModel from '@/models/taskReminder'
import Card from '@/components/misc/card.vue'
import SimpleButton from '@/components/input/SimpleButton.vue'
const {t} = useI18n({useScope: 'global'})
const props = defineProps({
modelValue: {
type: Object as PropType<ITaskReminder>,
required: false,
},
disabled: {
default: false,
},
clearAfterUpdate: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const reminder = ref<ITaskReminder>(new TaskReminderModel())
const presets: TaskReminderModel[] = [
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 3, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 7, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 30, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
]
const reminderDate = ref(null)
const showFormSwitch = ref<null | 'relative' | 'absolute'>(null)
const reminderText = computed(() => {
if (reminder.value.relativeTo !== null) {
return formatReminder(reminder.value)
}
if (reminder.value.reminder !== null) {
return formatDateShort(reminder.value.reminder)
}
return t('task.addReminder')
})
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
(newReminder) => {
reminder.value = newReminder || new TaskReminderModel()
},
{immediate: true},
)
function updateData() {
emit('update:modelValue', reminder.value)
if (props.clearAfterUpdate) {
reminder.value = new TaskReminderModel()
}
}
function setReminderDate() {
reminder.value.reminder = reminderDate.value === null
? null
: new Date(reminderDate.value)
reminder.value.relativeTo = null
reminder.value.relativePeriod = 0
updateData()
}
function setReminderFromPreset(preset, toggle) {
reminder.value = preset
updateData()
toggle()
}
function updateDataAndMaybeClose(toggle) {
updateData()
if (props.clearAfterUpdate) {
toggle()
}
}
function formatReminder(reminder: TaskReminderModel) {
const period = secondsToPeriod(reminder.relativePeriod)
if (period.amount === 0) {
switch (reminder.relativeTo) {
case REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE:
return t('task.reminder.onDueDate')
case REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE:
return t('task.reminder.onStartDate')
case REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE:
return t('task.reminder.onEndDate')
}
}
const amountAbs = Math.abs(period.amount)
let relativeTo = ''
switch (reminder.relativeTo) {
case REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE:
relativeTo = t('task.attributes.dueDate')
break
case REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE:
relativeTo = t('task.attributes.startDate')
break
case REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE:
relativeTo = t('task.attributes.endDate')
break
}
if (reminder.relativePeriod <= 0) {
return t('task.reminder.before', {
amount: amountAbs,
unit: translateUnit(amountAbs, period.unit),
type: relativeTo,
})
}
return t('task.reminder.after', {
amount: amountAbs,
unit: translateUnit(amountAbs, period.unit),
type: relativeTo,
})
}
function translateUnit(amount: number, unit: PeriodUnit): string {
switch (unit) {
case 'seconds':
return t('time.units.seconds', amount)
case 'minutes':
return t('time.units.minutes', amount)
case 'hours':
return t('time.units.hours', amount)
case 'days':
return t('time.units.days', amount)
case 'weeks':
return t('time.units.weeks', amount)
case 'months':
return t('time.units.months', amount)
case 'years':
return t('time.units.years', amount)
}
}
</script>
<style lang="scss" scoped>
.options {
display: flex;
flex-direction: column;
align-items: flex-start;
}
:deep(.popup) {
top: unset;
}
.reminder-options-popup {
width: 310px;
z-index: 99;
@media screen and (max-width: ($tablet)) {
width: calc(100vw - 5rem);
}
.option-button {
font-size: .85rem;
border-radius: 0;
padding: .5rem;
margin: 0;
&:hover {
background: var(--grey-100);
}
}
}
.reminder__close-button {
margin: .5rem;
width: calc(100% - 1rem);
}
.currently-active {
color: var(--primary);
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div
class="reminder-period control"
>
ce72 marked this conversation as resolved Outdated

What about inlining formatBeforeAfter to something like {{ reminder.relativePeriod <= 0 ? '&le;' : '&gt;' }}

What about inlining `formatBeforeAfter` to something like `{{ reminder.relativePeriod <= 0 ? '&le;' : '&gt;' }}`
Outdated
Review

ok

ok
<input
class="input"
v-model.number="period.duration"
type="number"
min="0"
@change="updateData"
/>
<div class="select">
<select v-model="period.durationUnit" @change="updateData">
<option value="minutes">{{ $t('time.units.minutes', period.duration) }}</option>
<option value="hours">{{ $t('time.units.hours', period.duration) }}</option>
<option value="days">{{ $t('time.units.days', period.duration) }}</option>
ce72 marked this conversation as resolved Outdated

Please add a translation string for the d. (It does not look like much, but still)

Please add a translation string for the `d`. (It does not look like much, but still)

Same for the placeholders.

Same for the placeholders.
Outdated
Review

ok

ok

To be honest I was confused by the d when I first saw the interface.
Why don't we write it out?

To be honest I was confused by the `d` when I first saw the interface. Why don't we write it out?
Outdated
Review

ok

ok
<option value="weeks">{{ $t('time.units.weeks', period.duration) }}</option>
</select>
</div>
<div class="select">
<select v-model.number="period.sign" @change="updateData">
<option value="-1">
{{ $t('task.reminder.beforeShort') }}
</option>
<option value="1">
{{ $t('task.reminder.afterShort') }}
</option>
</select>
</div>
<div class="select">
<select v-model="period.relativeTo" @change="updateData">
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE">
{{ $t('task.attributes.dueDate') }}
</option>
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE">
{{ $t('task.attributes.startDate') }}
</option>
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE">
{{ $t('task.attributes.endDate') }}
</option>
</select>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, watch, type PropType} from 'vue'
import {toRef} from '@vueuse/core'
import {periodToSeconds, PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
import TaskReminderModel from '@/models/taskReminder'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES, type IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo'
const props = defineProps({
modelValue: {
type: Object as PropType<ITaskReminder>,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const reminder = ref<ITaskReminder>(new TaskReminderModel())
interface PeriodInput {
duration: number,
durationUnit: PeriodUnit,
relativeTo: IReminderPeriodRelativeTo,
sign: -1 | 1,
}
const period = ref<PeriodInput>({
duration: 0,
durationUnit: 'hours',
relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE,
sign: -1,
})
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
(value) => {
const p = secondsToPeriod(value?.relativePeriod)
period.value.durationUnit = p.unit
period.value.duration = p.amount
period.value.relativeTo = value?.relativeTo || REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE
},
{immediate: true},

What does sign mean in this context?

What's the advantage of using an object here instead of individual refs?

What does `sign` mean in this context? What's the advantage of using an object here instead of individual `ref`s?
)
function updateData() {
reminder.value.relativePeriod = period.value.sign * periodToSeconds(period.value.duration, period.value.durationUnit)
reminder.value.relativeTo = period.value.relativeTo
reminder.value.reminder = null
emit('update:modelValue', reminder.value)
}
</script>
<style lang="scss" scoped>
.reminder-period {
display: flex;
flex-direction: column;
gap: .25rem;
padding: .5rem .5rem 0;
.input, .select select {
width: 100% !important;
height: auto;
}
}
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import reminders from './reminders.vue'
import {ref} from 'vue'
import ReminderDetail from '@/components/tasks/partials/reminder-detail.vue'
const reminderNow = ref({reminder: new Date(), relativePeriod: 0, relativeTo: null } )
const relativeReminder = ref({reminder: null, relativePeriod: 1, relativeTo: 'due_date' } )
const newReminder = ref(null)
</script>
<template>
<Story>
<Variant title="Default">
<reminders/>
</Variant>
<Variant title="Reminder Detail with fixed date">
<reminder-detail v-model="reminderNow"/>
</Variant>
<Variant title="Reminder Detail with relative date">
<reminder-detail v-model="relativeReminder"/>
</Variant>
<Variant title="New Reminder Detail">
<reminder-detail v-model="newReminder"/>
</Variant>
</Story>
</template>

View File

@ -3,104 +3,70 @@
<div
v-for="(r, index) in reminders"
:key="index"
:class="{ 'overdue': r < new Date()}"
:class="{ 'overdue': r.reminder < new Date() }"
class="reminder-input"
>
<Datepicker
v-model="reminders[index]"
<ReminderDetail
class="reminder-detail"
:disabled="disabled"
@close-on-change="() => addReminderDate(index)"
/>
<BaseButton @click="removeReminderByIndex(index)" v-if="!disabled" class="remove">
<icon icon="times"></icon>
v-model="reminders[index]"
@update:model-value="updateData"/>
<BaseButton
v-if="!disabled"
@click="removeReminderByIndex(index)"
class="remove"
>
<icon icon="times"/>
</BaseButton>
</div>
<div class="reminder-input" v-if="!disabled">
<Datepicker
v-model="newReminder"
@close-on-change="() => addReminderDate()"
:choose-date-label="$t('task.addReminder')"
/>
</div>
<ReminderDetail
:disabled="disabled"
@update:modelValue="addNewReminder"
:clear-after-update="true"
/>
</div>
</template>
<script setup lang="ts">
import {type PropType, ref, onMounted, watch} from 'vue'
import {ref, watch, type PropType} from 'vue'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import BaseButton from '@/components/base/BaseButton.vue'
konrad marked this conversation as resolved Outdated

Why remove the type?

Why remove the type?
Outdated
Review

The type has changed from Date | String to a complex type (Array as PropType<ITaskReminder[]>). I don't see why a validation is required. What do you suggest?

The type has changed from `Date | String` to a complex type (`Array as PropType<ITaskReminder[]>`). I don't see why a validation is required. What do you suggest?

@ce72: Sry I don't understand your answer here :)
What has the validation of the modelValue to do with the type of disabled?

@ce72: Sry I don't understand your answer here :) What has the validation of the modelValue to do with the type of disabled?
Outdated
Review

Can you please make a concrete suggestion what you expect here? I can find no other place in the code where you have a validation on a complex props type. And am not enough typescript native to understand what you want to have added here.

Can you please make a concrete suggestion what you expect here? I can find no other place in the code where you have a validation on a complex props type. And am not enough typescript native to understand what you want to have added here.

I think I misunderstood @konrads first question, because the lines changed. I assumed he is refering to the removed type from disabled and not the removed validation of the modelValue.

I think I misunderstood @konrads first question, because the lines changed. I assumed he is refering to the removed type from disabled and not the removed validation of the modelValue.
Outdated
Review

Then my question goes to @konrad

Then my question goes to @konrad

The type has changed from Date | String to a complex type (Array as PropType<ITaskReminder[]>). I don't see why a validation is required. What do you suggest?

Okay that actually makes sense.

> The type has changed from Date | String to a complex type (Array as PropType<ITaskReminder[]>). I don't see why a validation is required. What do you suggest? Okay that actually makes sense.
import Datepicker from '@/components/input/datepicker.vue'
type Reminder = Date | string
import ReminderDetail from '@/components/tasks/partials/reminder-detail.vue'
const props = defineProps({
modelValue: {
type: Array as PropType<Reminder[]>,
type: Array as PropType<ITaskReminder[]>,
default: () => [],
validator(prop) {
// This allows arrays of Dates and strings
if (!(prop instanceof Array)) {
return false
}
const isDate = (e: unknown) => e instanceof Date
const isString = (e: unknown) => typeof e === 'string'
for (const e of prop) {
if (!isDate(e) && !isString(e)) {
return false
}
}
return true
},
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const reminders = ref<Reminder[]>([])
onMounted(() => {
reminders.value = [...props.modelValue]
})
const reminders = ref<ITaskReminder[]>([])
watch(
() => props.modelValue,
props.modelValue,
(newVal) => {
for (const i in newVal) {
if (typeof newVal[i] === 'string') {
newVal[i] = new Date(newVal[i])
}
}
reminders.value = newVal
},
{immediate: true},
)
function updateData() {
emit('update:modelValue', reminders.value)
}
const newReminder = ref(null)
function addReminderDate(index : number | null = null) {
// New Date
if (index === null) {
if (newReminder.value === null) {
return
}
reminders.value.push(new Date(newReminder.value))
newReminder.value = null
} else if(reminders.value[index] === null) {
function addNewReminder(newReminder: ITaskReminder) {
if (newReminder === null) {
return
}
reminders.value.push(newReminder)
updateData()
}
@ -111,23 +77,27 @@ function removeReminderByIndex(index: number) {
</script>
<style lang="scss" scoped>
.reminders {
.reminder-input {
display: flex;
align-items: center;
.reminder-input {
display: flex;
align-items: center;
&.overdue :deep(.datepicker .show) {
color: var(--danger);
}
&.overdue :deep(.datepicker .show) {
color: var(--danger);
}
&:last-child {
margin-bottom: 0.75rem;
}
&::last-child {
margin-bottom: 0.75rem;
}
}
.remove {
color: var(--danger);
padding-left: .5rem;
}
}
.reminder-detail {
width: 100%;
}
.remove {
color: var(--danger);
vertical-align: top;
padding-left: .5rem;
line-height: 1;
}
</style>

View File

@ -0,0 +1,50 @@
import {
SECONDS_A_DAY,
SECONDS_A_HOUR,
SECONDS_A_MINUTE,
SECONDS_A_MONTH,
SECONDS_A_WEEK,
dpschen marked this conversation as resolved Outdated

Can you add a type hint for the return value?

Can you add a type hint for the return value?
Outdated
Review

ok

ok

Couldn't we instead define the return via as const?
Also we don' really need the declaration of the variables but could inline those definitions in the returned object directly

Couldn't we instead define the return via `as const`? Also we don' really need the declaration of the variables but could inline those definitions in the returned object directly
Outdated
Review

OK concerning the inlining. Can you please make a suggestion on how to replace the function using as const? My typescript might be not sufficient here.

OK concerning the inlining. Can you please make a suggestion on how to replace the function using `as const`? My typescript might be not sufficient here.
SECONDS_A_YEAR,
} from '@/constants/date'
export type PeriodUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years'
/**
* Convert time period given as seconds to days, hour, minutes, seconds
*/
export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: number } {
if (seconds % SECONDS_A_DAY === 0) {
if (seconds % SECONDS_A_WEEK === 0) {
return {unit: 'weeks', amount: seconds / SECONDS_A_WEEK}
} else if (seconds % SECONDS_A_MONTH === 0) {
return {unit: 'months', amount: seconds / SECONDS_A_MONTH}
} else if (seconds % SECONDS_A_YEAR === 0) {
return {unit: 'years', amount: seconds / SECONDS_A_YEAR}
} else {
return {unit: 'days', amount: seconds / SECONDS_A_DAY}
}
}
return {
unit: 'hours',
amount: seconds / SECONDS_A_HOUR,
}
}
/**
* Convert time period of days, hour, minutes, seconds to duration in seconds
*/
export function periodToSeconds(period: number, unit: PeriodUnit): number {
switch (unit) {
case 'minutes':
return period * SECONDS_A_MINUTE
case 'hours':
return period * SECONDS_A_HOUR
case 'days':
return period * SECONDS_A_DAY
case 'weeks':
return period * SECONDS_A_WEEK
}
return 0
}

View File

@ -720,6 +720,17 @@
"copiedto": "Copied To | Copied To"
}
},
"reminder": {
"before": "{amount} {unit} before {type}",
"after": "{amount} {unit} after {type}",
"beforeShort": "before",
"afterShort": "after",
"onDueDate": "On the due date",
"onStartDate": "On the start date",
"onEndDate": "On the end date",
"custom": "Custom",
"dateAndTime": "Date and time"
},
"repeat": {
"everyDay": "Every Day",
"everyWeek": "Every Week",
@ -984,5 +995,16 @@
"title": "About",
"frontendVersion": "Frontend Version: {version}",
"apiVersion": "API Version: {version}"
},
"time": {
"units": {
"seconds": "second|seconds",
"minutes": "minute|minutes",
"hours": "hour|hours",
"days": "day|days",
"weeks": "week|weeks",
"months": "month|months",
"years": "year|years"
}
}
}

View File

@ -13,6 +13,7 @@ import type {IRepeatAfter} from '@/types/IRepeatAfter'
import type {IRepeatMode} from '@/types/IRepeatMode'
import type {PartialWithId} from '@/types/PartialWithId'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
export interface ITask extends IAbstract {
id: number
@ -30,7 +31,7 @@ export interface ITask extends IAbstract {
repeatAfter: number | IRepeatAfter
repeatFromCurrentDate: boolean
repeatMode: IRepeatMode
reminderDates: Date[]
reminders: ITaskReminder[]
parentTaskId: ITask['id']
hexColor: string
percentDone: number

View File

@ -0,0 +1,8 @@
import type { IAbstract } from './IAbstract'
import type { IReminderPeriodRelativeTo } from '@/types/IReminderPeriodRelativeTo'
export interface ITaskReminder extends IAbstract {
reminder: Date | null
relativePeriod: number
relativeTo: IReminderPeriodRelativeTo | null
}

View File

@ -1,5 +1,4 @@
import {PRIORITIES, type Priority} from '@/constants/priorities'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_MONTH, SECONDS_A_WEEK, SECONDS_A_YEAR} from '@/constants/date'
import type {ITask} from '@/modelTypes/ITask'
import type {ILabel} from '@/modelTypes/ILabel'
@ -20,6 +19,9 @@ import LabelModel from './label'
import UserModel from './user'
import AttachmentModel from './attachment'
import SubscriptionModel from './subscription'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import TaskReminderModel from '@/models/taskReminder'
import {secondsToPeriod} from '@/helpers/time/period'
export const TASK_DEFAULT_COLOR = '#1973ff'
@ -35,21 +37,13 @@ export function getHexColor(hexColor: string): string {
* Parses `repeatAfterSeconds` into a usable js object.
*/
export function parseRepeatAfter(repeatAfterSeconds: number): IRepeatAfter {
let repeatAfter: IRepeatAfter = {type: 'hours', amount: repeatAfterSeconds / SECONDS_A_HOUR}
// if its dividable by 24, its something with days, otherwise hours
if (repeatAfterSeconds % SECONDS_A_DAY === 0) {
if (repeatAfterSeconds % SECONDS_A_WEEK === 0) {
repeatAfter = {type: 'weeks', amount: repeatAfterSeconds / SECONDS_A_WEEK}
} else if (repeatAfterSeconds % SECONDS_A_MONTH === 0) {
repeatAfter = {type:'months', amount: repeatAfterSeconds / SECONDS_A_MONTH}
} else if (repeatAfterSeconds % SECONDS_A_YEAR === 0) {
repeatAfter = {type: 'years', amount: repeatAfterSeconds / SECONDS_A_YEAR}
} else {
repeatAfter = {type: 'days', amount: repeatAfterSeconds / SECONDS_A_DAY}
}
const period = secondsToPeriod(repeatAfterSeconds)
return {
type: period.unit,
amount: period.amount,
}
return repeatAfter
}
export default class TaskModel extends AbstractModel<ITask> implements ITask {
@ -68,7 +62,13 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
repeatAfter: number | IRepeatAfter = 0
repeatFromCurrentDate = false
repeatMode: IRepeatMode = TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT
reminderDates: Date[] = []
/* Make sure to not return reminderDates to the server.
The server currently supports both reminderDates (old API) and reminder (new API) and assumes the old logic
if it still receives reminderDates.
This line and reminderDates attributes will be removed after https://kolaente.dev/vikunja/api/pulls/1448 was merged.
*/
reminderDates = null
reminders: ITaskReminder[] = []
parentTaskId: ITask['id'] = 0
hexColor = ''
percentDone = 0
@ -115,7 +115,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
// Parse the repeat after into something usable
this.repeatAfter = parseRepeatAfter(this.repeatAfter as number)
this.reminderDates = this.reminderDates.map(d => new Date(d))
this.reminders = this.reminders.map(r => new TaskReminderModel(r))
if (this.hexColor !== '' && this.hexColor.substring(0, 1) !== '#') {
this.hexColor = '#' + this.hexColor

View File

@ -0,0 +1,20 @@
import AbstractModel from './abstractModel'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import type {IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo'
export default class TaskReminderModel extends AbstractModel<ITaskReminder> implements ITaskReminder {
reminder: Date | null
relativePeriod = 0
relativeTo: IReminderPeriodRelativeTo | null = null
constructor(data: Partial<ITaskReminder> = {}) {
super()
this.assignData(data)
this.reminder = parseDateOrNull(data.reminder)
if (this.relativeTo === '') {
this.relativeTo = null
}
}
}

View File

@ -54,17 +54,17 @@ export default class TaskService extends AbstractService<ITask> {
model.created = new Date(model.created).toISOString()
model.updated = new Date(model.updated).toISOString()
model.reminderDates = null
// remove all nulls, these would create empty reminders
for (const index in model.reminderDates) {
if (model.reminderDates[index] === null) {
model.reminderDates.splice(index, 1)
for (const index in model.reminders) {
if (model.reminders[index] === null) {
model.reminders.splice(index, 1)
}
}
// Make normal timestamps from js dates
if (model.reminderDates.length > 0) {
model.reminderDates = model.reminderDates.map(r => {
return new Date(r).toISOString()
if (model.reminders.length > 0) {
model.reminders.forEach(r => {
r.reminder = new Date(r.reminder).toISOString()
})
}

View File

@ -0,0 +1,8 @@
export const REMINDER_PERIOD_RELATIVE_TO_TYPES = {
DUEDATE: 'due_date',
STARTDATE: 'start_date',
ENDDATE: 'end_date',
} as const
export type IReminderPeriodRelativeTo = typeof REMINDER_PERIOD_RELATIVE_TO_TYPES[keyof typeof REMINDER_PERIOD_RELATIVE_TO_TYPES]

View File

@ -1,4 +1,6 @@
export const REPEAT_TYPES = {
Seconds: 'seconds',
Minutes: 'minutes',
Hours: 'hours',
Days: 'days',
Weeks: 'weeks',

View File

@ -160,7 +160,7 @@
<reminders
:disabled="!canWrite"
:ref="e => setFieldRef('reminders', e)"
v-model="task.reminderDates"
v-model="task.reminders"
@update:model-value="saveTask"
/>
</div>
@ -639,7 +639,7 @@ function setActiveFields() {
activeFields.percentDone = task.percentDone > 0
activeFields.priority = task.priority !== PRIORITIES.UNSET
activeFields.relatedTasks = Object.keys(task.relatedTasks).length > 0
activeFields.reminders = task.reminderDates.length > 0
activeFields.reminders = task.reminders.length > 0
activeFields.repeatAfter = task.repeatAfter.amount > 0
activeFields.startDate = task.startDate !== null
}