feat: add date math for filters #1342

Merged
konrad merged 88 commits from feature/date-math into main 2022-03-28 17:30:43 +00:00
18 changed files with 784 additions and 366 deletions

View File

@ -72,7 +72,7 @@ describe('Lists', () => {
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
cy.get('.card-content')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})

View File

@ -0,0 +1,21 @@
export const DATE_RANGES = {
konrad marked this conversation as resolved Outdated

Use UPPER_SNAKE_CASE for constants

Use UPPER_SNAKE_CASE for constants

Done.

Done.
// Format:
// Key is the title, as a translation string, the first entry of the value array
// is the "from" date, the second one is the "to" date.
'today': ['now/d', 'now/d+1d'],
'lastWeek': ['now/w-1w', 'now/w-2w'],
'thisWeek': ['now/w', 'now/w+1w'],
'restOfThisWeek': ['now', 'now/w+1w'],
'nextWeek': ['now/w+1w', 'now/w+2w'],
'next7Days': ['now', 'now+7d'],
'lastMonth': ['now/M-1M', 'now/M-2M'],
'thisMonth': ['now/M', 'now/M+1M'],
'restOfThisMonth': ['now', 'now/M+1M'],
'nextMonth': ['now/M+1M', 'now/M+2M'],
'next30Days': ['now', 'now+30d'],
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}

View File

@ -0,0 +1,131 @@
<template>
<card
class="has-no-shadow how-it-works-modal"
:title="$t('input.datemathHelp.title')">
<p>
{{ $t('input.datemathHelp.intro') }}
konrad marked this conversation as resolved Outdated

The naming of this feels wrong now that it's not part of the component anymore.
If this help is specific to datepickerRange maybe we should also show that via the component name.

The naming of this feels wrong now that it's not part of the component anymore. If this help is specific to datepickerRange maybe we should also show that via the component name.

Changed!

Changed!
</p>
<p>
<i18n-t keypath="input.datemathHelp.expression">
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datemathHelp.similar">
<BaseButton
konrad marked this conversation as resolved Outdated

use BaseButton

use BaseButton

Done.

Done.
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
target="_blank">
konrad marked this conversation as resolved Outdated

Remove rel attribute here and below.
Show we also add target="_blank" as default for the BaseButton is a link case?

Remove rel attribute here and below. Show we also add `target="_blank"` as default for the BaseButton is a link case?

Show we also add target="_blank" as default for the BaseButton is a link case?

Mh I think there will still be cases where we want to control this so right now I think we should not add one.

> Show we also add target="_blank" as default for the BaseButton is a link case? Mh I think there will still be cases where we want to control this so right now I think we should not add one.
Grafana
</BaseButton>
<BaseButton
konrad marked this conversation as resolved Outdated

use BaseButton

use BaseButton
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
target="_blank">
Elasticsearch
</BaseButton>
</i18n-t>
</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
</ul>
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>s</code></td>
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
</tr>
<tr>
<td><code>m</code></td>
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
</tr>
<tr>
<td><code>h</code></td>
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
</tr>
<tr>
<td><code>H</code></td>
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
</tr>
<tr>
<td><code>d</code></td>
<td>{{ $t('input.datemathHelp.units.days') }}</td>
</tr>
<tr>
<td><code>w</code></td>
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
</tr>
<tr>
<td><code>M</code></td>
<td>{{ $t('input.datemathHelp.units.months') }}</td>
</tr>
<tr>
<td><code>y</code></td>
<td>{{ $t('input.datemathHelp.units.years') }}</td>
</tr>
</tbody>
</table>
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>now</code></td>
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
</tr>
<tr>
<td><code>now+24h</code></td>
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
</tr>
<tr>
<td><code>now/d</code></td>
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
</tr>
<tr>
<td><code>now/w</code></td>
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now/w+1w</code></td>
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now+30d</code></td>
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
</tr>
<tr>
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth">
<code>{{ exampleDate }}</code>
</i18n-t>
</td>
</tr>
</tbody>
</table>
</card>
</template>
<script lang="ts" setup>
import {format} from 'date-fns'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = format(new Date(), 'yyyy-MM-dd')
</script>
<style scoped>
.how-it-works-modal {
font-size: 1rem;
}
p {
display: inline-block !important;
}
.base-button {
display: inline;
}
</style>

View File

@ -0,0 +1,254 @@
<template>
<div class="datepicker-with-range-container">
<popup>
<template #trigger="{toggle}">
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
konrad marked this conversation as resolved Outdated

Provide buttonText as slot prop aswell

Provide `buttonText` as slot prop aswell

Done.

Done.
</template>
konrad marked this conversation as resolved Outdated

The styles (=props) selected for the button are hightly specific and create an indirect dependency to its indented use case. The latter might change in the future and then we have to remember this dependency. This is why I think it makes sense to remove at least the specific use case of this button. Since we wouldn't use it then at all anymore (since you need to overwrite the default slot) it might make it simpler to only define the slot.

The styles (=props) selected for the button are hightly specific and create an indirect dependency to its indented use case. The latter might change in the future and then we have to remember this dependency. This is why I think it makes sense to remove at least the specific use case of this button. Since we wouldn't use it then at all anymore (since you need to overwrite the default slot) it might make it simpler to only define the slot.

Moved everything to a slot.

Moved everything to a slot.
<template #content="{isOpen}">
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
<div class="selections">
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_RANGES"
konrad marked this conversation as resolved Outdated

Use BaseButton

Use BaseButton

Done.

Done.
:key="text"
@click="setDateRange(value)"
:class="{'is-active': from === value[0] && to === value[1]}">
konrad marked this conversation as resolved Outdated

Use BaseButton

Use BaseButton

Done.

Done.
{{ $t(`input.datepickerRange.ranges.${text}`) }}
</BaseButton>
</div>
<div class="flatpickr-container input-group">
<label class="label">
{{ $t('input.datepickerRange.from') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="from"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
konrad marked this conversation as resolved Outdated

Instead of calling inputChanged from here better watch the from and below the to value.
This prevents future mistakes

Instead of calling `inputChanged` from here better watch the `from` and below the `to` value. This prevents future mistakes

Changed. I kept inputChanged though because I need to watchers.

Changed. I kept `inputChanged` though because I need to watchers.
</div>
</label>
<label class="label">
{{ $t('input.datepickerRange.to') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="to"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<flat-pickr
:config="flatPickerConfig"
v-model="flatpickrRange"
/>
<p>
{{ $t('input.datemathHelp.canuse') }}
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p>
konrad marked this conversation as resolved
Review

Use BaseButton

Use BaseButton
Review

Done.

Done.
<modal
@close="() => showHowItWorks = false"
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
>
<DatemathHelp/>
</modal>
</div>
konrad marked this conversation as resolved Outdated

The whole explanation card should be its own component.

The whole explanation card should be its own component.

Done!

Done!
</div>
</template>
</popup>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
dpschen marked this conversation as resolved Outdated

I think it's really cool, that we support this, but it would be cool, if we could break this down and explain it in our / simpler terms.

I think it's really cool, that we support this, but it would be cool, if we could break this down and explain it in our / simpler terms.

I think I'm already doing that but wanted to highlight for people who know the syntax from Elasticsearch or Grafana they can use it here as well.

Was there anything in the explanation you noticed as not quite understandable?

I think I'm already doing that but wanted to highlight for people who know the syntax from Elasticsearch or Grafana they can use it here as well. Was there anything in the explanation you noticed as not quite understandable?

I didn't even check to be honest.
Was just something I though of while reading this.

I didn't even check to be honest. Was just something I though of while reading this.
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
const store = useStore()
const {t} = useI18n()
const emit = defineEmits(['dateChanged'])
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: false,
wrap: true,
mode: 'range',
locale: {
firstDayOf7Days: weekStart.value,
},
}))
const showHowItWorks = ref(false)
const flatpickrRange = ref('')
const from = ref('')
const to = ref('')
function emitChanged() {
emit('dateChanged', {
dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value,
})
}
watch(
() => flatpickrRange.value,
konrad marked this conversation as resolved Outdated

Why do we need to reset this every time?

Why do we need to reset this every time?

I don't think we have to. I've checked and it looks like this doesn't really break anything so I've removed it. Lets us get rid of inputChanged.

I don't think we have to. I've checked and it looks like this doesn't really break anything so I've removed it. Lets us get rid of `inputChanged`.
(newVal: string | null) => {

Called here!

Called here!
if (newVal === null) {
return
}
const [fromDate, toDate] = newVal.split(' to ')
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
return
}
from.value = fromDate
to.value = toDate
emitChanged()
},
)
watch(() => from.value, emitChanged)
watch(() => to.value, emitChanged)

This is triggered three times!

Once for each of these lines:

from.value = fromDate
to.value = toDate

in the called function inputChanged of the watcher.
And then here again.

This is triggered three times! Once for each of these lines: ```js from.value = fromDate to.value = toDate ``` in the called function inputChanged of the watcher. And then here again.

mhh how can we fix this? Using a debounce to prevent it getting called three times if it changed all values at once? Or removing the watchers again?

It looks like the tasks are only loaded once, not three times. Maybe that's already enough?

mhh how can we fix this? Using a debounce to prevent it getting called three times if it changed all values at once? Or removing the watchers again? It looks like the tasks are only loaded once, not three times. Maybe that's already enough?
function setDateRange(range: string[] | null) {
if (range === null) {
from.value = ''
to.value = ''
return
}
from.value = range[0]
to.value = range[1]
}
const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1])
})
const buttonText = computed<string>(() => {
if (from.value !== '' && to.value !== '') {
return t('input.datepickerRange.fromto', {
konrad marked this conversation as resolved Outdated

picky: el is a bit misleading here. We don't have elements

picky: `el` is a bit misleading here. We don't have elements

right, I've changed it.

right, I've changed it.
from: from.value,
to: to.value,
})
}
return t('task.show.select')
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
}
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
konrad marked this conversation as resolved Outdated

Picky:

I like to group imports from external packages and from internal stuff.
Usally I follow this order (but not strict):

  • external packages beginning with vue libs

  • external packages other libs and css

  • internal instances like store, message, etc

  • services and models

  • component imports

  • helpers

As you can see this is more or less random, so happy to discuss this.
Or we can simple keep it random :D

Picky: I like to group imports from external packages and from internal stuff. Usally I follow this order (but not strict): - external packages beginning with vue libs - external packages other libs and css - internal instances like store, message, etc - services and models - component imports - helpers As you can see this is more or less random, so happy to discuss this. Or we can simple keep it random :D

Oh I think it absolutely makes sense to have something like that, even if not strictly enforced. Do you think we could put this in a linter?

Oh I think it absolutely makes sense to have something like that, even if not strictly enforced. Do you think we could put this in a linter?

I think I encountered at least something like external stuff first in a linter already.

I think I encountered at least something like external stuff first in a linter already.
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
konrad marked this conversation as resolved Outdated

After using it a while I prefer now to use the useStore method to get the current store instance.
The reason is that it makes it easier to refactor in composables later because useStore will always give you the store.

After using it a while I prefer now to use the `useStore` method to get the current store instance. The reason is that it makes it easier to refactor in composables later because `useStore` will always give you the store.

Makes sense! Changed it.

Makes sense! Changed it.
&.is-open {
width: 500px;
height: 320px;
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
konrad marked this conversation as resolved Outdated

We could remove this prop if we would only expose the slot for the trigger.

We could remove this prop if we would only expose the slot for the trigger.

Done.

Done.
}
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}

Continuing from #1261 (comment)

With default value you mean the value of store.state.auth.settings.weekStart?

Continuing from https://kolaente.dev/vikunja/frontend/pulls/1261#issuecomment-22675 With default value you mean the value of store.state.auth.settings.weekStart?

As said in #1261:

It's not reflected in the flatpicker config (below). That always contains 0 as week start, no matter what is set in store.

As said in #1261: > It's not reflected in the flatpicker config (below). That always contains `0` as week start, no matter what is set in store.
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
padding: 1rem;
font-size: .9rem;
// Flatpickr has no option to use it without an input field so we're hiding it instead
:deep(input.form-control.input) {
height: 0;
padding: 0;
border: 0;
}
.field .control :deep(.button) {
border: 1px solid var(--input-border-color);
height: 2.25rem;
&:hover {

I think this is a good usecase for a reactive.

I think this is a good usecase for a reactive.

What would be the advantage over a ref?

What would be the advantage over a ref?

Both values can be changed at the same time
=> watchers aren't triggered twice when you change stuff shortly after each other.

Both values can be changed at the same time => watchers aren't triggered twice when you change stuff shortly after each other.

How do I type it properly? If I only pass a string into a reactive it complains I should pass in an object instead.

Edit: using String (capital S) seems to work.

How do I type it properly? If I only pass a string into a reactive it complains I should pass in an object instead. Edit: using `String` (capital S) seems to work.

Okay even with the typing solved it still complains when trying to set it: Because we do const from = ... I won't be able to do from = fromDate. Any idea how to solve this?

Okay even with the typing solved it still complains when trying to set it: Because we do `const from = ...` I won't be able to do `from = fromDate`. Any idea how to solve this?
border: 1px solid var(--input-hover-border-color);
}
}

Use v-model for values.
Might be useful: https://vueuse.org/core/usevmodel/

Use v-model for values. Might be useful: https://vueuse.org/core/usevmodel/

What I don't like with using a v-model here would be the need to introduce another watcher in ShowTasks because we need to push the changes to the route. I think the current way with an event nicely circumvents this.

Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?).

What I don't like with using a v-model here would be the need to introduce another watcher in ShowTasks because we need to push the changes to the route. I think the current way with an event nicely circumvents this. Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?).

Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?).

correct

> Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?). correct
.label, .input, :deep(.button) {
font-size: .9rem;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
overflow-y: scroll;
button {
display: block;
width: 100%;
text-align: left;
padding: .5rem 1rem;
transition: $transition;
font-size: .9rem;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
&.is-active {
color: var(--primary);
}
&:hover, &.is-active {
background-color: var(--grey-100);
}
}
}
</style>

View File

@ -6,7 +6,7 @@
>
{{ $t('filters.clear') }}
</x-button>
<popup>
<popup :has-overflow="true">
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"

View File

@ -67,49 +67,49 @@
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setDueDateFilter"
class="input"
:placeholder="$t('filters.attributes.dueDateRange')"
v-model="filters.dueDate"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('due_date', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setStartDateFilter"
class="input"
:placeholder="$t('filters.attributes.startDateRange')"
v-model="filters.startDate"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('start_date', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setEndDateFilter"
class="input"
:placeholder="$t('filters.attributes.endDateRange')"
v-model="filters.endDate"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('end_date', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setReminderFilter"
class="input"
:placeholder="$t('filters.attributes.reminderRange')"
v-model="filters.reminders"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('reminders', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
@ -175,15 +175,14 @@
</template>
<script>
import DatepickerWithRange from '@/components/date/datepickerWithRange'
import Fancycheckbox from '../../input/fancycheckbox'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {includesById} from '@/helpers/utils'
import {formatISO} from 'date-fns'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import Multiselect from '@/components/input/multiselect.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import UserService from '@/services/user'
import ListService from '@/services/list'
@ -222,15 +221,15 @@ const DEFAULT_FILTERS = {
namespace: '',
}
export const ALPHABETICAL_SORT = 'title'
export const ALPHABETICAL_SORT = 'title'
export default {
name: 'filters',
components: {
DatepickerWithRange,
EditLabels,
PrioritySelect,
Fancycheckbox,
flatPickr,
PercentDoneSelect,
Multiselect,
},
@ -281,7 +280,7 @@ export default {
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
this.params.sort_by = sortAlphabetically
this.params.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
@ -291,19 +290,6 @@ export default {
foundLabels() {
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
},
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
change() {
@ -343,19 +329,12 @@ export default {
}
}
},
setDateFilter(filterName, variableName = null) {
if (variableName === null) {
variableName = filterName
}
// Only filter if we have a start and end due date
if (this.filters[variableName] !== '') {
setDateFilter(filterName, {dateFrom, dateTo}) {
dateFrom = parseDateOrString(dateFrom, null)
dateTo = parseDateOrString(dateTo, null)
const parts = this.filters[variableName].split(' to ')
if (parts.length < 2) {
return
}
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
@ -363,23 +342,23 @@ export default {
this.params.filter_by.forEach((f, i) => {
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true
this.params.filter_value[i] = formatISO(new Date(parts[0]))
this.params.filter_value[i] = dateFrom
}
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true
this.params.filter_value[i] = formatISO(new Date(parts[1]))
this.params.filter_value[i] = dateTo
}
})
if (!foundStart) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('greater_equals')
this.params.filter_value.push(formatISO(new Date(parts[0])))
this.params.filter_value.push(dateFrom)
}
if (!foundEnd) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(formatISO(new Date(parts[1])))
this.params.filter_value.push(dateTo)
}
this.change()
return
@ -513,24 +492,12 @@ export default {
this.params.filter_concat = 'or'
}
},
setDueDateFilter() {
this.setDateFilter('due_date', 'dueDate')
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')
},
setStartDateFilter() {
this.setDateFilter('start_date', 'startDate')
},
setEndDateFilter() {
this.setDateFilter('end_date', 'endDate')
},
setPercentDoneFilter() {
this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
},
setReminderFilter() {
this.setDateFilter('reminders')
},
clear(kind) {
konrad marked this conversation as resolved Outdated

These abstractions are unnecessary and just increase complexity, call setDateFilter directly, like:
@dateChanged="(values) => setDateFilter('due_date', values)"

These abstractions are unnecessary and just increase complexity, call `setDateFilter` directly, like: `@dateChanged="(values) => setDateFilter('due_date', values)"`

Done.

Done.
this[`found${kind}`] = []
},
@ -609,7 +576,7 @@ export default {
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.single-value-control {
display: flex;
align-items: center;
@ -618,4 +585,8 @@ export default {
margin-left: .5rem;
}
}
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
<div class="popup" :class="{'is-open': open}" ref="popup">
<div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow && open}" ref="popup">
<slot name="content" :isOpen="open"/>
</div>
</template>
@ -16,6 +16,13 @@ const toggle = () => {
open.value = !open.value
}
const props = defineProps({
hasOverflow: {
type: Boolean,
default: false,
},
})
function hidePopup(e) {
if (!open.value) {
return

View File

@ -11,7 +11,6 @@
>
<div
class="modal-container"
:class="{'has-overflow': overflow}"
@click.self.prevent.stop="$emit('close')"
v-shortcut="'Escape'"
>
@ -199,7 +198,6 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}

View File

@ -0,0 +1,3 @@
export function getNextWeekDate(): Date {
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
}

View File

@ -0,0 +1,12 @@
export function parseDateOrString(rawValue: string | undefined, fallback: any): string | Date {
konrad marked this conversation as resolved Outdated

Maybe we should just move this to the emits section of the date range component? To avoid callees requiring to import it every time?

Maybe we should just move this to the emits section of the date range component? To avoid callees requiring to import it every time?

Yes that makes sense!

Yes that makes sense!

Since we're now using route props where we need this function I think we'll have to leave it as is.

Since we're now using route props where we need this function I think we'll have to leave it as is.
if (typeof rawValue === 'undefined') {
return fallback
}
const d = new Date(rawValue)
// @ts-ignore if rawValue is an invalid date, isNan will return false.
return !isNaN(d)
? d
: rawValue
}

View File

@ -485,7 +485,8 @@
"showMenu": "Show the menu",
"hideMenu": "Hide the menu",
"forExample": "For example:",
"welcomeBack": "Welcome Back!"
"welcomeBack": "Welcome Back!",
"custom": "Custom"
},
"input": {
"resetColor": "Reset Color",
@ -524,6 +525,60 @@
"multiselect": {
"createPlaceholder": "Create new",
"selectPlaceholder": "Click or press enter to select"
},
"datepickerRange": {
"to": "To",
"from": "From",
"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"
}
},
"datemathHelp": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"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",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
}
},
"task": {
@ -541,12 +596,9 @@
"titleCurrent": "Current Tasks",
"titleDates": "Tasks from {from} until {to}",
"noDates": "Show tasks without dates",
"current": "Current tasks",
"from": "Tasks from",
"until": "until",
"today": "Today",
"nextWeek": "Next Week",
"nextMonth": "Next Month",
"overdue": "Show overdue tasks",
"fromuntil": "Tasks from {from} until {until}",
"select": "Select a date range",
"noTasks": "Nothing to do — Have a nice day!"
},
"detail": {

View File

@ -3,6 +3,8 @@ import {saveLastVisited} from '@/helpers/saveLastVisited'
import {store} from '@/store'
import {saveListView, getListView} from '@/helpers/saveListView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import HomeComponent from '../views/Home.vue'
import NotFoundComponent from '../views/404.vue'
@ -13,7 +15,7 @@ import RegisterComponent from '../views/user/Register.vue'
import OpenIdAuth from '../views/user/OpenIdAuth.vue'
import DataExportDownload from '../views/user/DataExportDownload.vue'
// Tasks
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange.vue'
import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
@ -248,7 +250,13 @@ const router = createRouter({
{
path: '/tasks/by/upcoming',
name: 'tasks.range',
component: ShowTasksInRangeComponent,
component: UpcomingTasksComponent,
props: route => ({
dateFrom: parseDateOrString(route.query.from as string, new Date()),
dateTo: parseDateOrString(route.query.to as string, getNextWeekDate()),
showNulls: route.query.showNulls === 'true',
showOverdue: route.query.showOverdue === 'true',
}),
},
{
path: '/lists/new/:namespaceId/',

View File

@ -2,6 +2,31 @@ import axios from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import {getToken} from '@/helpers/auth'
function convertObject(o) {
if (o instanceof Date) {
return o.toISOString()
}
return o
}
function prepareParams(params) {
if (typeof params !== 'object') {
return params
}
for (const p in params) {
if (Array.isArray(params[p])) {
params[p] = params[p].map(convertObject)
continue
}
params[p] = convertObject(params[p])
}
return objectToSnakeCase(params)
}
export default class AbstractService {
/////////////////////////////
@ -292,7 +317,7 @@ export default class AbstractService {
const finalUrl = this.getReplacedRoute(url, model)
try {
const response = await this.http.get(finalUrl, {params})
const response = await this.http.get(finalUrl, {params: prepareParams(params)})
const result = this.modelGetFactory(response.data)
result.maxRight = Number(response.headers['x-max-right'])
return result
@ -331,7 +356,7 @@ export default class AbstractService {
const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
try {
const response = await this.http.get(finalUrl, {params: params})
const response = await this.http.get(finalUrl, {params: prepareParams(params)})
this.resultCount = Number(response.headers['x-pagination-result-count'])
this.totalPages = Number(response.headers['x-pagination-total-pages'])

View File

@ -70,7 +70,7 @@ h6 {
}
.has-overflow {
overflow: visible;
overflow: visible !important;
}
.has-horizontal-overflow {

View File

@ -50,7 +50,11 @@
/>
</div>
</div>
<ShowTasks class="mt-4" :show-all="true" v-if="hasLists" :key="showTasksKey"/>
<ShowTasks
v-if="hasLists"
class="mt-4"
:key="showTasksKey"
/>
</div>
</template>
@ -83,13 +87,14 @@ const userInfo = computed(() => store.state.auth.info)
const hasTasks = computed(() => store.state.hasTasks)
const defaultListId = computed(() => store.state.auth.defaultListId)
const defaultNamespaceId = computed(() => store.state.namespaces.namespaces?.[0]?.id || 0)
const hasLists = computed (() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
const hasLists = computed(() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
const deletionScheduledAt = computed(() => parseDateOrNull(store.state.auth.info?.deletionScheduledAt))
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use vuex (somehow?)
const showTasksKey = ref(0)
function updateTaskList() {
showTasksKey.value++
}

View File

@ -1,287 +1,236 @@
<template>
<div class="is-max-width-desktop show-tasks">
<fancycheckbox
@change="setDate"
class="is-pulled-right"
v-if="!showAll"
v-model="showNulls"
>
{{ $t('task.show.noDates') }}
</fancycheckbox>
<h3 v-if="showAll && tasks.length > 0">
{{ $t('task.show.current') }}
<div class="is-max-width-desktop has-text-left ">
<h3 class="mb-2 title">
{{ pageTitle }}
</h3>
<h3 v-else-if="!showAll" class="mb-2">
{{ $t('task.show.from') }}
<flat-pickr
:class="{ 'disabled': loading}"
:config="flatPickerConfig"
:disabled="loading"
@on-close="setDate"
class="input"
v-model="cStartDate"
/>
{{ $t('task.show.until') }}
<flat-pickr
:class="{ 'disabled': loading}"
:config="flatPickerConfig"
:disabled="loading"
@on-close="setDate"
class="input"
v-model="cEndDate"
/>
</h3>
<div v-if="!showAll" class="mb-4">
<x-button variant="secondary" @click="showTodaysTasks()" class="mr-2">{{ $t('task.show.today') }}</x-button>
<x-button variant="secondary" @click="setDatesToNextWeek()" class="mr-2">{{ $t('task.show.nextWeek') }}</x-button>
<x-button variant="secondary" @click="setDatesToNextMonth()">{{ $t('task.show.nextMonth') }}</x-button>
</div>
<p v-if="!showAll" class="show-tasks-options">
<datepicker-with-range @dateChanged="setDate">
<template #trigger="{toggle}">
<x-button @click.prevent.stop="toggle()" variant="primary" :shadow="false" class="mb-2">
{{ $t('task.show.select') }}
</x-button>
</template>
</datepicker-with-range>
<fancycheckbox @change="setShowNulls" class="mr-2">
{{ $t('task.show.noDates') }}
</fancycheckbox>
<fancycheckbox @change="setShowOverdue">
{{ $t('task.show.overdue') }}
</fancycheckbox>
</p>
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
<h3 class="nothing">{{ $t('task.show.noTasks') }}</h3>
<LlamaCool class="llama-cool" />
<h3 class="has-text-centered mt-6">{{ $t('task.show.noTasks') }}</h3>
<LlamaCool class="llama-cool"/>
</template>
<div :class="{ 'is-loading': loading}" class="spinner"></div>
<card :padding="false" class="has-overflow" :has-content="false" v-if="tasks && tasks.length > 0">
<div class="tasks">
<card
v-if="hasTasks"
:padding="false"
class="has-overflow"
:has-content="false"
:loading="loading"
>
<div class="p-2">
<single-task-in-list
v-for="t in tasksSorted"
:key="t.id"
class="task"
v-for="t in tasks"
:show-list="true"
:the-task="t"
@taskUpdated="updateTasks"/>
</div>
</card>
<div v-else :class="{ 'is-loading': loading}" class="spinner"></div>
</div>
</template>
<script>
import SingleTaskInList from '../../components/tasks/partials/singleTaskInList'
import {mapState} from 'vuex'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import Fancycheckbox from '../../components/input/fancycheckbox'
import {LOADING, LOADING_MODULE} from '../../store/mutation-types'
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import TaskModel from '@/models/task'
import {formatDate} from '@/helpers/time/formatDate'
import {setTitle} from '@/helpers/setTitle'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList.vue'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import LlamaCool from '@/assets/llama-cool.svg?component'
export default {
name: 'ShowTasks',
components: {
Fancycheckbox,
SingleTaskInList,
flatPickr,
LlamaCool,
},
data() {
return {
tasks: [],
showNulls: true,
showOverdue: false,
const store = useStore()
const route = useRoute()
const router = useRouter()
const {t} = useI18n()
cStartDate: null,
cEndDate: null,
const tasks = ref<TaskModel[]>([])
const showNothingToDo = ref<boolean>(false)
showNothingToDo: false,
}
},
props: {
startDate: Date,
konrad marked this conversation as resolved Outdated

Readd route props

Readd route props

Changed it to route props.

However, we still have a dependency on the router: every time when seleting a date in ShowTasks it will push the change to the route. I don't know how to change that without massively overengeneering everything so I'd say we leave it at that.

Changed it to route props. However, we still have a dependency on the router: every time when seleting a date in ShowTasks it will push the change to the route. I don't know how to change that without massively overengeneering everything so I'd say we leave it at that.

I think it's fine for now. I thought a while about this but also don't have a better soltion for this at the moment (except the v-model version)

I think it's fine for now. I thought a while about this but also don't have a better soltion for this at the moment (except the v-model version)
endDate: Date,
showAll: Boolean,
},
created() {
this.cStartDate = this.startDate
this.cEndDate = this.endDate
this.loadPendingTasks()
},
mounted() {
setTimeout(() => this.showNothingToDo = true, 100)
},
watch: {
'$route': {
handler: 'loadPendingTasks',
deep: true,
},
startDate(newVal) {
this.cStartDate = newVal
},
endDate(newVal) {
this.cEndDate = newVal
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
...mapState({
userAuthenticated: state => state.auth.authenticated,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
}),
},
methods: {
setDate() {
this.$router.push({
name: this.$route.name,
query: {
from: +new Date(this.cStartDate),
to: +new Date(this.cEndDate),
showOverdue: this.showOverdue,
showNulls: this.showNulls,
},
})
},
async loadPendingTasks() {
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
// Since this component is mounted as the home page before unauthenticated users get redirected
// to the login page, they will almost always see the error message.
if (!this.userAuthenticated) {
return
}
setTimeout(() => showNothingToDo.value = true, 100)
// Make sure all dates are date objects
if (typeof this.$route.query.from !== 'undefined' && typeof this.$route.query.to !== 'undefined') {
this.cStartDate = new Date(Number(this.$route.query.from))
this.cEndDate = new Date(Number(this.$route.query.to))
} else {
this.cStartDate = new Date(this.cStartDate)
this.cEndDate = new Date(this.cEndDate)
}
this.showOverdue = this.$route.query.showOverdue
this.showNulls = this.$route.query.showNulls
// Linting disabled because we explicitely enabled destructuring in vite's config, this will work.
// eslint-disable-next-line vue/no-setup-props-destructure
konrad marked this conversation as resolved Outdated

Wouldn't it be better to make showAll a computed that gets autoset when dateFrom and dateTo doesn't contain a value?

Wouldn't it be better to make showAll a computed that gets autoset when dateFrom and dateTo doesn't contain a value?

Excellent idea. Changed it!

Excellent idea. Changed it!
const {
dateFrom,
dateTo,
showNulls = false,
showOverdue = false,
} = defineProps<{
dateFrom?: Date | string,
dateTo?: Date | string,
showNulls?: Boolean,
showOverdue?: Boolean,
}>()
konrad marked this conversation as resolved Outdated

get value from props to remove dependency on route. Removing dependency from router makes the components easier reusable nested inside another view (which is what we do).

get value from props to remove dependency on route. Removing dependency from router makes the components easier reusable nested inside another view (which is what we do).
if (this.showAll) {
this.setTitle(this.$t('task.show.titleCurrent'))
} else {
this.setTitle(this.$t('task.show.titleDates', {
from: this.cStartDate.toLocaleDateString(),
to: this.cEndDate.toLocaleDateString(),
}))
}
const showAll = computed(() => typeof dateFrom === 'undefined' || typeof dateTo === 'undefined')
const params = {
sort_by: ['due_date', 'id'],
order_by: ['desc', 'desc'],
filter_by: ['done'],
filter_value: [false],
filter_comparator: ['equals'],
filter_concat: 'and',
filter_include_nulls: this.showNulls,
}
if (!this.showAll) {
if (this.showNulls) {
params.filter_by.push('start_date')
params.filter_value.push(this.cStartDate)
params.filter_comparator.push('greater')
const pageTitle = computed(() => {
// We need to define "key" because it is the first parameter in the array and we need the second
const predefinedRange = Object.entries(DATE_RANGES)
// eslint-disable-next-line no-unused-vars
.find(([key, value]) => dateFrom === value[0] && dateTo === value[1])
?.[0]
if (typeof predefinedRange !== 'undefined') {
return t(`input.datepickerRange.ranges.${predefinedRange}`)
konrad marked this conversation as resolved Outdated

Unsure: I think eslint doesn't complain here if you use underscore (_) to spread the unused variable.

Unsure: I think eslint doesn't complain here if you use underscore (`_`) to spread the unused variable.

Add ?.[0] so we return the key, which is what we actually want to use and adjust variable name.

Add `?.[0]` so we return the key, which is what we actually want to use and adjust variable name.

Unsure: I think eslint doesn't complain here if you use underscore (_) to spread the unused variable.

Nope, does not seem to have an effect.

> Unsure: I think eslint doesn't complain here if you use underscore (_) to spread the unused variable. Nope, does not seem to have an effect.

Add ?.[0] so we return the key, which is what we actually want to use and adjust variable name.

Done.

> Add ?.[0] so we return the key, which is what we actually want to use and adjust variable name. Done.

Nope, does not seem to have an effect.

Interesting!

> Nope, does not seem to have an effect. Interesting!
}
konrad marked this conversation as resolved Outdated

Remove title var and return directly:

if (typeof predefinedRange !== 'undefined') {
    return t(`input.datepickerRange.ranges.${predefinedRangeKey}`)
} else {
    return showAll
			? t('task.show.titleCurrent')
			: t('task.show.fromuntil', {
				from: formatDate(dateFrom, 'PPP'),
				until: formatDate(dateTo, 'PPP'),
			})
}
Remove title var and return directly: ```js if (typeof predefinedRange !== 'undefined') { return t(`input.datepickerRange.ranges.${predefinedRangeKey}`) } else { return showAll ? t('task.show.titleCurrent') : t('task.show.fromuntil', { from: formatDate(dateFrom, 'PPP'), until: formatDate(dateTo, 'PPP'), }) }

Done.

Done.
params.filter_by.push('end_date')
params.filter_value.push(this.cEndDate)
params.filter_comparator.push('less')
}
return showAll.value
? t('task.show.titleCurrent')
: t('task.show.fromuntil', {
from: formatDate(dateFrom, 'PPP'),
until: formatDate(dateTo, 'PPP'),
})
})
const tasksSorted = computed(() => {
// Sort all tasks to put those with a due date before the ones without a due date, the
// soonest before the later ones.
// We can't use the api sorting here because that sorts tasks with a due date after
// ones without a due date.
params.filter_by.push('due_date')
params.filter_value.push(this.cEndDate)
params.filter_comparator.push('less')
const tasksWithDueDate = [...tasks.value]
.filter(t => t.dueDate !== null)
.sort((a, b) => {
konrad marked this conversation as resolved Outdated

This is a sideeffect inside a computed. Remove this.
Better watch the computed and react to that:

watchEffect(() => setTitle(pageTitle.value))
This is a sideeffect inside a computed. Remove this. Better watch the computed and react to that: ```js watchEffect(() => setTitle(pageTitle.value)) ```

Done.

Done.
const sortByDueDate = a.dueDate - b.dueDate
return sortByDueDate === 0
? b.id - a.id
: sortByDueDate
})
const tasksWithoutDueDate = [...tasks.value]
.filter(t => t.dueDate === null)
if (!this.showOverdue) {
params.filter_by.push('due_date')
params.filter_value.push(this.cStartDate)
params.filter_comparator.push('greater')
}
}
return [
...tasksWithDueDate,
...tasksWithoutDueDate,
]
})
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
const userAuthenticated = computed(() => store.state.auth.authenticated)
const loading = computed(() => store.state[LOADING] && store.state[LOADING_MODULE] === 'tasks')
const tasks = await this.$store.dispatch('tasks/loadTasks', params)
if (!tasks) {
// When no tasks where returned, we won't be able to sort them.
return
}
// FIXME: sort tasks in computed
// Sort all tasks to put those with a due date before the ones without a due date, the
// soonest before the later ones.
// We can't use the api sorting here because that sorts tasks with a due date after
// ones without a due date.
this.tasks = tasks.sort((a, b) => {
const sortByDueDate = b.dueDate - a.dueDate
return sortByDueDate === 0
? b.id - a.id
: sortByDueDate
})
},
// FIXME: this modification should happen in the store
updateTasks(updatedTask) {
for (const t in this.tasks) {
if (this.tasks[t].id === updatedTask.id) {
this.tasks[t] = updatedTask
// Move the task to the end of the done tasks if it is now done
if (updatedTask.done) {
this.tasks.splice(t, 1)
this.tasks.push(updatedTask)
}
break
}
}
},
setDatesToNextWeek() {
const now = new Date()
this.cStartDate = now
this.cEndDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
this.showOverdue = false
this.setDate()
},
setDatesToNextMonth() {
const now = new Date()
this.cStartDate = now
this.cEndDate = new Date((new Date()).setMonth(now.getMonth() + 1))
this.showOverdue = false
this.setDate()
},
showTodaysTasks() {
const now = new Date()
this.cStartDate = now
this.cEndDate = new Date((new Date()).setDate(now.getDate() + 1))
this.showOverdue = true
this.setDate()
},
},
interface dateStrings {
dateFrom: string,
dateTo: string,
}
function setDate(dates: dateStrings) {
router.push({
name: route.name as string,
query: {
from: dates.dateFrom ?? dateFrom,
to: dates.dateTo ?? dateTo,
showOverdue: showOverdue ? 'true' : 'false',
showNulls: showNulls ? 'true' : 'false',
},
})
}
dpschen marked this conversation as resolved Outdated

Mhh that is a bit annoying that we have to convert to strings here :/
But I also don't have a better idea for now.
Right now this might be still fine, but I remember similar usecases where the conversion from state object to the url representation was quite complex.

We might need to abstract this in the future, so let's keep that in the back of our head =)

Mhh that is a bit annoying that we have to convert to strings here :/ But I also don't have a better idea for now. Right now this might be still fine, but I remember similar usecases where the conversion from state object to the url representation was quite complex. We might need to abstract this in the future, so let's keep that in the back of our head =)
function setShowOverdue(show: boolean) {
router.push({
name: route.name as string,
query: {
...route.query,
showOverdue: show ? 'true' : 'false',
},
})
}
function setShowNulls(show: boolean) {
router.push({
name: route.name as string,
query: {
...route.query,
showNulls: show ? 'true' : 'false',
},
})
}
async function loadPendingTasks(from: string, to: string) {
// FIXME: HACK! This should never happen.
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
// Since this component is mounted as the home page before unauthenticated users get redirected
// to the login page, they will almost always see the error message.
konrad marked this conversation as resolved Outdated

This should be marked as a Hack because it's something that shouldn't happen.
When I tried to change the auth these days I tried to fix this, but got tangled.
We should try to solve this in the future because else we will just add more and more hacks =)

This should be marked as a Hack because it's something that shouldn't happen. When I tried to change the auth these days I tried to fix this, but got tangled. We should try to solve this in the future because else we will just add more and more hacks =)

Done.

Done.
if (!userAuthenticated.value) {
return
}
const params = {
sortBy: ['due_date', 'id'],
orderBy: ['desc', 'desc'],
filterBy: ['done'],
filterValue: ['false'],
filterComparator: ['equals'],
filterConcat: 'and',
filterIncludeNulls: showNulls,
}
if (!showAll.value) {
params.filterBy.push('due_date')
params.filterValue.push(to)
params.filterComparator.push('less')
// NOTE: Ideally we could also show tasks with a start or end date in the specified range, but the api
// is not capable (yet) of combining multiple filters with 'and' and 'or'.
if (!showOverdue) {
params.filterBy.push('due_date')
params.filterValue.push(from)
params.filterComparator.push('greater')
}
}
tasks.value = await store.dispatch('tasks/loadTasks', params)
}
// FIXME: this modification should happen in the store
function updateTasks(updatedTask: TaskModel) {
for (const t in tasks.value) {
if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask
// Move the task to the end of the done tasks if it is now done
konrad marked this conversation as resolved Outdated

Instead of creating the params already in snake_case:
Wait until the last moment (ideally abstracted away from a apiClient) until you convert params from camelCase to snake_case, needed by the api. This way we use consequently use camelCase in most areas of the frontend – which is what you usually do in JS.

Instead of creating the params already in snake_case: Wait until the last moment (ideally abstracted away from a apiClient) until you convert params from camelCase to snake_case, needed by the api. This way we use consequently use camelCase in most areas of the frontend – which is what you usually do in JS.

I agree. However, this whole filter thing is a mess right now, not only here. I'd like to fix this everywhere at once at some point, simplifying the filter handling in the process as well.

That being said, I've changed there here for now.

I agree. However, this whole filter thing is a mess right now, not only here. I'd like to fix this everywhere at once at some point, simplifying the filter handling in the process as well. That being said, I've changed there here for now.
if (updatedTask.done) {
tasks.value.splice(t, 1)
tasks.value.push(updatedTask)
}
break
}
}
}
watchEffect(() => loadPendingTasks(dateFrom as string, dateTo as string))
watchEffect(() => setTitle(pageTitle.value))
</script>
<style lang="scss" scoped>
h3 {
text-align: left;
&.nothing {
text-align: center;
margin-top: 3rem;
}
:deep(.input) {
width: 190px;
vertical-align: middle;
margin: .5rem 0;
}
}
.tasks {
padding: .5rem;
.show-tasks-options {
display: flex;
flex-direction: column;
}
.llama-cool {
margin-top: 2rem;
margin: 3rem auto 0;
display: block;
}
</style>

View File

@ -1,20 +0,0 @@
<template>
<div class="content has-text-centered">
<ShowTasks
:end-date="endDate"
:start-date="startDate"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import ShowTasks from './ShowTasks.vue'
function getNextWeekDate() {
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
}
const startDate = ref(new Date())
const endDate = ref(getNextWeekDate())
</script>

View File

@ -1,7 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import legacyFn from '@vitejs/plugin-legacy'
const {VitePWA} = require('vite-plugin-pwa')
const path = require('path')
const {visualizer} = require('rollup-plugin-visualizer')
@ -49,6 +50,7 @@ export default defineConfig({
},
},
},
reactivityTransform: true,
}),
legacy,
svgLoader({