WIP: feat: make today really today #1380

Closed
kergma-lw wants to merge 27 commits from fix/upcoming into fix/upcoming
4 changed files with 397 additions and 139 deletions

View File

@ -86,7 +86,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,276 @@
<template>
<div class="datepicker-with-range-container">
<popup>
<template #trigger="{toggle}">
<x-button @click.prevent.stop="toggle()" type="secondary" :shadow="false" class="mb-2">
{{ $t('task.show.select') }}
</x-button>
</template>
<template #content="{isOpen}">
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
<div class="selections">
<button @click="setDateRange(datesToday)" :class="{'is-active': dateRange === datesToday}">
{{ $t('task.show.today') }}
</button>
<button @click="setDateRange(datesThisWeek)"
:class="{'is-active': dateRange === datesThisWeek}">
{{ $t('task.show.thisWeek') }}
</button>
<button @click="setDateRange(datesNextWeek)"
:class="{'is-active': dateRange === datesNextWeek}">
{{ $t('task.show.nextWeek') }}
</button>
<button @click="setDateRange(datesNext7Days)"
:class="{'is-active': dateRange === datesNext7Days}">
{{ $t('task.show.next7Days') }}
</button>
<button @click="setDateRange(datesThisMonth)"
:class="{'is-active': dateRange === datesThisMonth}">
{{ $t('task.show.thisMonth') }}
</button>
<button @click="setDateRange(datesNextMonth)"
:class="{'is-active': dateRange === datesNextMonth}">
{{ $t('task.show.nextMonth') }}
</button>
<button @click="setDateRange(datesNext30Days)"
:class="{'is-active': dateRange === datesNext30Days}">
{{ $t('task.show.next30Days') }}
</button>
<button @click="setDateRange('')" :class="{'is-active': customRangeActive}">
{{ $t('misc.custom') }}
</button>
</div>
<div class="flatpickr-container">
<flat-pickr
:config="flatPickerConfig"
v-model="dateRange"
/>
</div>
</div>
</template>
</popup>
</div>
</template>
<script lang="ts" setup>
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {store} from '@/store'
import {format} from 'date-fns'
import Popup from '@/components/misc/popup'
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,
inline: true,
mode: 'range',
locale: {
firstDayOf7Days: weekStart.value,
},
}))
const dateRange = ref<string>('')
watch(
() => dateRange.value,
(newVal: string | null) => {
if (newVal === null) {
return
}
const [fromDate, toDate] = newVal.split(' to ')
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
return
}
emit('dateChanged', {
dateFrom: new Date(fromDate),
dateTo: new Date(toDate),
})
},
)
function formatDate(date: Date): string {
return format(date, 'yyyy-MM-dd HH:mm')
}
function startOfDay(date: Date): Date {
date.setHours(0)
date.setMinutes(0)
return date
}
function endOfDay(date: Date): Date {
date.setHours(23)
date.setMinutes(59)
return date
}
const datesToday = computed<string>(() => {
const startDate = startOfDay(new Date())
const endDate = endOfDay(new Date())
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
function thisWeek() {
const startDate = startOfDay(new Date())
const first = startDate.getDate() - startDate.getDay() + weekStart.value
startDate.setDate(first)
const endDate = endOfDay(new Date((new Date(startDate).setDate(first + 6))))
return {
startDate,
endDate,
}
}
const datesThisWeek = computed<string>(() => {
const {startDate, endDate} = thisWeek()
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
const datesNextWeek = computed<string>(() => {
const {startDate, endDate} = thisWeek()
startDate.setDate(startDate.getDate() + 7)
endDate.setDate(endDate.getDate() + 7)
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
const datesNext7Days = computed<string>(() => {
const startDate = startOfDay(new Date())
const endDate = endOfDay(new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000))
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
function thisMonth() {
const startDate = startOfDay(new Date())
startDate.setDate(1)
const endDate = endOfDay(new Date((new Date()).getFullYear(), (new Date()).getMonth() + 1, 0))
return {
startDate,
endDate,
}
}
const datesThisMonth = computed<string>(() => {
const {startDate, endDate} = thisMonth()
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
const datesNextMonth = computed<string>(() => {
const {startDate, endDate} = thisMonth()
startDate.setMonth(startDate.getMonth() + 1)
endDate.setMonth(endDate.getMonth() + 1)
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
const datesNext30Days = computed<string>(() => {
const startDate = startOfDay(new Date())
const endDate = endOfDay(new Date((new Date()).setMonth((new Date()).getMonth() + 1)))
return `${formatDate(startDate)} to ${formatDate(endDate)}`
})
function setDateRange(range: string) {
dateRange.value = range
}
const customRangeActive = computed<Boolean>(() => {
return dateRange.value !== datesToday.value &&
dateRange.value !== datesThisWeek.value &&
dateRange.value !== datesNextWeek.value &&
dateRange.value !== datesNext7Days.value &&
dateRange.value !== datesThisMonth.value &&
dateRange.value !== datesNextMonth.value &&
dateRange.value !== datesNext30Days.value
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
&.is-open {
width: 500px;
height: 320px;
}
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
}
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
:deep(input.input) {
display: none;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
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

@ -476,7 +476,8 @@
"showMenu": "Show the menu",
"hideMenu": "Hide the menu",
"forExample": "For example:",
"welcomeBack": "Welcome Back!"
"welcomeBack": "Welcome Back!",
"custom": "Custom"
},
"input": {
"resetColor": "Reset Color",
@ -532,12 +533,16 @@
"titleCurrent": "Current Tasks",
"titleDates": "Tasks from {from} until {to}",
"noDates": "Show tasks without dates",
"current": "Current tasks",
"from": "Tasks from",
"until": "until",
"overdue": "Show overdue tasks",
"fromuntil": "Tasks from {from} until {until}",
"select": "Select a date range",
"today": "Today",
"thisWeek": "This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"thisMonth": "This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"noTasks": "Nothing to do — Have a nice day!"
},
"detail": {

View File

@ -1,53 +1,35 @@
<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">
{{ 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"/>
<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="mt-5"/>
</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">
<div v-if="!hasTasks" :class="{ 'is-loading': loading}" class="spinner"></div>
<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"/>
@ -56,33 +38,26 @@
</div>
</template>
<script>
import SingleTaskInList from '../../components/tasks/partials/singleTaskInList'
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'
import Fancycheckbox from '@/components/input/fancycheckbox'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import LlamaCool from '@/assets/llama-cool.svg?component'
import DatepickerWithRange from '@/components/date/datepickerWithRange'
export default {
name: 'ShowTasks',
components: {
DatepickerWithRange,
Fancycheckbox,
SingleTaskInList,
flatPickr,
LlamaCool,
},
data() {
return {
tasks: [],
showNulls: true,
showOverdue: false,
cStartDate: null,
cEndDate: null,
showNothingToDo: false,
}
},
@ -92,8 +67,6 @@ export default {
showAll: Boolean,
},
created() {
this.cStartDate = this.startDate
this.cEndDate = this.endDate
this.loadPendingTasks()
},
mounted() {
@ -104,25 +77,54 @@ export default {
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,
},
}
dateFrom() {
const d = new Date(Number(this.$route.query.from))
return !isNaN(d)
? d
: this.startDate
},
dateTo() {
const d = new Date(Number(this.$route.query.to))
return !isNaN(d)
? d
: this.endDate
},
showNulls() {
return this.$route.query.showNulls === 'true'
},
showOverdue() {
return this.$route.query.showOverdue === 'true'
},
pageTitle() {
const title = this.showAll
? this.$t('task.show.titleCurrent')
: this.$t('task.show.fromuntil', {
from: this.format(this.dateFrom, 'PPP'),
until: this.format(this.dateTo, 'PPP'),
})
this.setTitle(title)
return title
},
tasksSorted() {
// 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.
return [...this.tasks].sort((a, b) => {
const sortByDueDate = b.dueDate - a.dueDate
return sortByDueDate === 0
? b.id - a.id
: sortByDueDate
})
},
hasTasks() {
return this.tasks && this.tasks.length > 0
},
...mapState({
userAuthenticated: state => state.auth.authenticated,
@ -130,17 +132,35 @@ export default {
}),
},
methods: {
setDate() {
setDate({dateFrom, dateTo}) {
this.$router.push({
name: this.$route.name,
query: {
from: +new Date(this.cStartDate),
to: +new Date(this.cEndDate),
from: +new Date(dateFrom ?? this.dateFrom),
to: +new Date(dateTo ?? this.dateTo),
showOverdue: this.showOverdue,
showNulls: this.showNulls,
},
})
},
setShowOverdue(show) {
this.$router.push({
name: this.$route.name,
query: {
...this.$route.query,
showOverdue: show,
},
})
},
setShowNulls(show) {
this.$router.push({
name: this.$route.name,
query: {
...this.$route.query,
showNulls: show,
},
})
},
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
@ -149,26 +169,6 @@ export default {
return
}
// 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
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 params = {
sort_by: ['due_date', 'id'],
order_by: ['desc', 'desc'],
@ -178,41 +178,23 @@ export default {
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')
params.filter_by.push('end_date')
params.filter_value.push(this.cEndDate)
params.filter_comparator.push('less')
}
params.filter_by.push('due_date')
params.filter_value.push(this.cEndDate)
params.filter_value.push(this.dateTo)
params.filter_comparator.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 (!this.showOverdue) {
params.filter_by.push('due_date')
params.filter_value.push(this.cStartDate)
params.filter_value.push(this.dateFrom)
params.filter_comparator.push('greater')
}
}
const tasks = await this.$store.dispatch('tasks/loadTasks', params)
// 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
})
this.tasks = await this.$store.dispatch('tasks/loadTasks', params)
},
// FIXME: this modification should happen in the store
@ -247,7 +229,8 @@ export default {
showTodaysTasks() {
const d = new Date()
this.cStartDate = new Date()
this.cEndDate = new Date(d.setDate(d.getDate() + 1))
this.cEndDate = new Date(d.setDate(d.getDate()))
this.cEndDate.setHours(23,59,0,0)
this.showOverdue = true
this.setDate()
},
@ -256,18 +239,12 @@ export default {
</script>
<style lang="scss" scoped>
h3 {
text-align: left;
.show-tasks-options {
display: flex;
flex-direction: column;
&.nothing {
text-align: center;
margin-top: 3rem;
}
:deep(.input) {
width: 190px;
vertical-align: middle;
margin: .5rem 0;
> :deep(a) {
margin-right: .5rem;
}
}
@ -278,4 +255,4 @@ h3 {
.llama-cool {
margin-top: 2rem;
}
</style>
</style>