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
This commit is contained in:
kolaente 2024-03-11 16:13:42 +01:00
parent 6fc3d1e98f
commit a66e26678e
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
10 changed files with 65 additions and 19 deletions

View File

@ -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<IProject['id']>, 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,
]
})

View File

@ -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: '',
}
}

View File

@ -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<IBucket[]>([])
const projectId = ref<IProject['id']>(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

View File

@ -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 {

View File

@ -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<F extends Filters>(
@ -21,12 +22,18 @@ export function useGanttTaskList<F extends Filters>(
}) {
const taskCollectionService = shallowReactive(new TaskCollectionService())
const taskService = shallowReactive(new TaskService())
const authStore = useAuthStore()
const isLoading = computed(() => taskCollectionService.loading)
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
async function fetchTasks(params: TaskFilterParams, page = 1): Promise<ITask[]> {
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)

View File

@ -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'],

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -174,6 +174,7 @@ type taskSearchOptions struct {
parsedFilters []*taskFilter
filterIncludeNulls bool
filter string
filterTimezone string
projectIDs []int64
}