From a66e26678ece858cc13515e160acbd5261371dbc Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 11 Mar 2024 16:13:42 +0100 Subject: [PATCH] feat(filters): pass timezone down when filtering with relative date math Resolves https://community.vikunja.io/t/my-vikunja-instance-creates-tasks-with-due-date-time-of-9am-for-tasks-with-the-word-today-word-in-it/2105/8 --- frontend/src/composables/useTaskList.ts | 10 ++++-- frontend/src/services/taskCollection.ts | 2 ++ frontend/src/stores/kanban.ts | 11 +++++-- frontend/src/stores/tasks.ts | 8 +++-- .../views/project/helpers/useGanttTaskList.ts | 7 ++++ frontend/src/views/tasks/ShowTasks.vue | 2 ++ pkg/models/kanban.go | 3 +- pkg/models/task_collection.go | 7 +++- pkg/models/task_collection_filter.go | 33 +++++++++++++------ pkg/models/tasks.go | 1 + 10 files changed, 65 insertions(+), 19 deletions(-) diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index 51e874c4d..7b05375e8 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -6,6 +6,7 @@ import TaskCollectionService, {getDefaultTaskFilterParams} from '@/services/task import type {ITask} from '@/modelTypes/ITask' import {error} from '@/message' import type {IProject} from '@/modelTypes/IProject' +import {useAuthStore} from '@/stores/auth' export type Order = 'asc' | 'desc' | 'none' @@ -81,11 +82,16 @@ export function useTaskList(projectIdGetter: ComputedGetter, sor page.value = 1 }, ) - + + const authStore = useAuthStore() + const getAllTasksParams = computed(() => { return [ {projectId: projectId.value}, - allParams.value, + { + ...allParams.value, + filter_timezone: authStore.settings.timezone, + }, page.value, ] }) diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts index 0c8a13f06..965ad05b3 100644 --- a/frontend/src/services/taskCollection.ts +++ b/frontend/src/services/taskCollection.ts @@ -8,6 +8,7 @@ export interface TaskFilterParams { order_by: ('asc' | 'desc')[], filter: string, filter_include_nulls: boolean, + filter_timezone: string, s: string, } @@ -17,6 +18,7 @@ export function getDefaultTaskFilterParams(): TaskFilterParams { order_by: ['asc', 'desc'], filter: '', filter_include_nulls: false, + filter_timezone: '', s: '', } } diff --git a/frontend/src/stores/kanban.ts b/frontend/src/stores/kanban.ts index 94c626748..6c0e7b044 100644 --- a/frontend/src/stores/kanban.ts +++ b/frontend/src/stores/kanban.ts @@ -7,13 +7,14 @@ import {i18n} from '@/i18n' import {success} from '@/message' import BucketService from '@/services/bucket' -import TaskCollectionService from '@/services/taskCollection' +import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection' import {setModuleLoading} from '@/stores/helper' import type {ITask} from '@/modelTypes/ITask' import type {IProject} from '@/modelTypes/IProject' import type {IBucket} from '@/modelTypes/IBucket' +import {useAuthStore} from '@/stores/auth' const TASKS_PER_BUCKET = 25 @@ -44,6 +45,8 @@ const addTaskToBucketAndSort = (buckets: IBucket[], task: ITask) => { * It should hold only the current buckets. */ export const useKanbanStore = defineStore('kanban', () => { + const authStore = useAuthStore() + const buckets = ref([]) const projectId = ref(0) const bucketLoading = ref<{[id: IBucket['id']]: boolean}>({}) @@ -247,7 +250,7 @@ export const useKanbanStore = defineStore('kanban', () => { async function loadNextTasksForBucket( projectId: IProject['id'], - ps, + ps: TaskFilterParams, bucketId: IBucket['id'], ) { const isLoading = bucketLoading.value[bucketId] ?? false @@ -265,7 +268,7 @@ export const useKanbanStore = defineStore('kanban', () => { const cancel = setModuleLoading(setIsLoading) setBucketLoading({bucketId: bucketId, loading: true}) - const params = JSON.parse(JSON.stringify(ps)) + const params: TaskFilterParams = JSON.parse(JSON.stringify(ps)) params.sort_by = 'kanban_position' params.order_by = 'asc' @@ -286,6 +289,8 @@ export const useKanbanStore = defineStore('kanban', () => { params.filter_value = [...(params.filter_value ?? []), bucketId] params.filter_comparator = [...(params.filter_comparator ?? []), 'equals'] } + + params.filter_timezone = authStore.settings.timezone params.per_page = TASKS_PER_BUCKET diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index b57213766..9cf3ab3e5 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -28,7 +28,7 @@ import {useKanbanStore} from '@/stores/kanban' import {useBaseStore} from '@/stores/base' import ProjectUserService from '@/services/projectUsers' import {useAuthStore} from '@/stores/auth' -import TaskCollectionService from '@/services/taskCollection' +import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection' import {getRandomColorHex} from '@/helpers/color/randomColor' interface MatchedAssignee extends IUser { @@ -124,7 +124,11 @@ export const useTaskStore = defineStore('task', () => { }) } - async function loadTasks(params, projectId: IProject['id'] | null = null) { + async function loadTasks(params: TaskFilterParams, projectId: IProject['id'] | null = null) { + + if (params.filter_timezone === '') { + params.filter_timezone = authStore.settings.timezone + } const cancel = setModuleLoading(setIsLoading) try { diff --git a/frontend/src/views/project/helpers/useGanttTaskList.ts b/frontend/src/views/project/helpers/useGanttTaskList.ts index 7f094252c..f2a76c8d6 100644 --- a/frontend/src/views/project/helpers/useGanttTaskList.ts +++ b/frontend/src/views/project/helpers/useGanttTaskList.ts @@ -9,6 +9,7 @@ import TaskService from '@/services/task' import TaskModel from '@/models/task' import {error, success} from '@/message' +import {useAuthStore} from '@/stores/auth' // FIXME: unify with general `useTaskList` export function useGanttTaskList( @@ -21,12 +22,18 @@ export function useGanttTaskList( }) { const taskCollectionService = shallowReactive(new TaskCollectionService()) const taskService = shallowReactive(new TaskService()) + const authStore = useAuthStore() const isLoading = computed(() => taskCollectionService.loading) const tasks = ref>(new Map()) async function fetchTasks(params: TaskFilterParams, page = 1): Promise { + + if(params.filter_timezone === '') { + params.filter_timezone = authStore.settings.timezone + } + const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[] if (options.loadAll && page < taskCollectionService.totalPages) { const nextTasks = await fetchTasks(params, page + 1) diff --git a/frontend/src/views/tasks/ShowTasks.vue b/frontend/src/views/tasks/ShowTasks.vue index 0d2d747ea..899edd04c 100644 --- a/frontend/src/views/tasks/ShowTasks.vue +++ b/frontend/src/views/tasks/ShowTasks.vue @@ -85,6 +85,7 @@ import type {ITask} from '@/modelTypes/ITask' import {useAuthStore} from '@/stores/auth' import {useTaskStore} from '@/stores/tasks' import {useProjectStore} from '@/stores/projects' +import type {TaskFilterParams} from '@/services/taskCollection' // Linting disabled because we explicitely enabled destructuring in vite's config, this will work. // eslint-disable-next-line vue/no-setup-props-destructure @@ -184,6 +185,7 @@ async function loadPendingTasks(from: string, to: string) { const params = { sortBy: ['due_date', 'id'], orderBy: ['asc', 'desc'], + filterTimezone: authStore.settings.timezone, filterBy: ['done'], filterValue: ['false'], filterComparator: ['equals'], diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 24a307c44..597420a97 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -109,6 +109,7 @@ func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err // @Param per_page query int false "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page." // @Param s query string false "Search tasks by task text." // @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature." +// @Param filter_timezone query string false "The time zone which should be used for date match (statements like "now" resolve to different actual times)" // @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`." // @Success 200 {array} models.Bucket "The buckets with their tasks" // @Failure 500 {object} models.Message "Internal server error" @@ -197,7 +198,7 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int } else { filterString = "(" + originalFilter + ") && bucket_id = " + strconv.FormatInt(id, 10) } - opts.parsedFilters, err = getTaskFiltersFromFilterString(filterString) + opts.parsedFilters, err = getTaskFiltersFromFilterString(filterString, opts.filterTimezone) if err != nil { return } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index bb844086e..4c8160b72 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -33,7 +33,10 @@ type TaskCollection struct { OrderBy []string `query:"order_by" json:"order_by"` OrderByArr []string `query:"order_by[]" json:"-"` + // The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature. Filter string `query:"filter" json:"filter"` + // The time zone which should be used for date match (statements like "now" resolve to different actual times) + FilterTimezone string `query:"filter_timezone" json:"filter_timezone"` // If set to true, the result will also include null values FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"` @@ -103,9 +106,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption sortby: sort, filterIncludeNulls: tf.FilterIncludeNulls, filter: tf.Filter, + filterTimezone: tf.FilterTimezone, } - opts.parsedFilters, err = getTaskFiltersFromFilterString(tf.Filter) + opts.parsedFilters, err = getTaskFiltersFromFilterString(tf.Filter, tf.FilterTimezone) return opts, err } @@ -122,6 +126,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption // @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`." // @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`." // @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature." +// @Param filter_timezone query string false "The time zone which should be used for date match (statements like "now" resolve to different actual times)" // @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`." // @Security JWTKeyAuth // @Success 200 {array} models.Task "The tasks" diff --git a/pkg/models/task_collection_filter.go b/pkg/models/task_collection_filter.go index 560094bf9..d0885ff4d 100644 --- a/pkg/models/task_collection_filter.go +++ b/pkg/models/task_collection_filter.go @@ -92,7 +92,7 @@ func parseTimeFromUserInput(timeString string) (value time.Time, err error) { return value.In(config.GetTimeZone()), err } -func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error) { +func parseFilterFromExpression(f fexpr.ExprGroup, loc *time.Location) (filter *taskFilter, err error) { filter = &taskFilter{ join: filterConcatAnd, } @@ -112,7 +112,7 @@ func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error case []fexpr.ExprGroup: values := make([]*taskFilter, 0, len(v)) for _, expression := range v { - subfilter, err := parseFilterFromExpression(expression) + subfilter, err := parseFilterFromExpression(expression, loc) if err != nil { return nil, err } @@ -132,7 +132,7 @@ func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error if filter.field == "project" { filter.field = "project_id" } - reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, value) + reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, value, loc) if err != nil { return nil, ErrInvalidTaskFilterValue{ Value: filter.field, @@ -146,7 +146,7 @@ func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error return filter, nil } -func getTaskFiltersFromFilterString(filter string) (filters []*taskFilter, err error) { +func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) { if filter == "" { return @@ -174,9 +174,17 @@ func getTaskFiltersFromFilterString(filter string) (filters []*taskFilter, err e } } + var loc *time.Location + if filterTimezone != "" { + loc, err = time.LoadLocation(filterTimezone) + if err != nil { + return + } + } + filters = make([]*taskFilter, 0, len(parsedFilter)) for _, f := range parsedFilter { - parsedFilter, err := parseFilterFromExpression(f) + parsedFilter, err := parseFilterFromExpression(f, loc) if err != nil { return nil, err } @@ -230,7 +238,12 @@ func getFilterComparatorFromOp(op fexpr.SignOp) (taskFilterComparator, error) { } } -func getValueForField(field reflect.StructField, rawValue string) (value interface{}, err error) { +func getValueForField(field reflect.StructField, rawValue string, loc *time.Location) (value interface{}, err error) { + + if loc == nil { + loc = config.GetTimeZone() + } + switch field.Type.Kind() { case reflect.Int64: value, err = strconv.ParseInt(rawValue, 10, 64) @@ -245,7 +258,7 @@ func getValueForField(field reflect.StructField, rawValue string) (value interfa var t datemath.Expression t, err = datemath.Parse(rawValue) if err == nil { - value = t.Time(datemath.WithLocation(config.GetTimeZone())) + value = t.Time(datemath.WithLocation(config.GetTimeZone())).In(loc) } else { value, err = parseTimeFromUserInput(rawValue) } @@ -273,7 +286,7 @@ func getValueForField(field reflect.StructField, rawValue string) (value interfa return } -func getNativeValueForTaskField(fieldName string, comparator taskFilterComparator, value string) (reflectField *reflect.StructField, nativeValue interface{}, err error) { +func getNativeValueForTaskField(fieldName string, comparator taskFilterComparator, value string, loc *time.Location) (reflectField *reflect.StructField, nativeValue interface{}, err error) { realFieldName := strings.ReplaceAll(strcase.ToCamel(fieldName), "Id", "ID") @@ -299,7 +312,7 @@ func getNativeValueForTaskField(fieldName string, comparator taskFilterComparato vals := strings.Split(value, ",") valueSlice := []interface{}{} for _, val := range vals { - v, err := getValueForField(field, val) + v, err := getValueForField(field, val, loc) if err != nil { return nil, nil, err } @@ -308,6 +321,6 @@ func getNativeValueForTaskField(fieldName string, comparator taskFilterComparato return nil, valueSlice, nil } - val, err := getValueForField(field, value) + val, err := getValueForField(field, value, loc) return &field, val, err } diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index c00ce0d00..2f331d482 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -174,6 +174,7 @@ type taskSearchOptions struct { parsedFilters []*taskFilter filterIncludeNulls bool filter string + filterTimezone string projectIDs []int64 }