diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 7ac572699..e0d091949 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -5,7 +5,7 @@ module.exports = {
'root': true,
'env': {
'browser': true,
- 'es2021': true,
+ 'es2022': true,
'node': true,
'vue/setup-compiler-macros': true,
},
diff --git a/cypress/e2e/list/list-view-gantt.spec.ts b/cypress/e2e/list/list-view-gantt.spec.ts
index e7d39b3e1..6763c359b 100644
--- a/cypress/e2e/list/list-view-gantt.spec.ts
+++ b/cypress/e2e/list/list-view-gantt.spec.ts
@@ -11,7 +11,7 @@ describe('List View Gantt', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
- cy.get('.gantt-chart .tasks')
+ cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
})
@@ -25,7 +25,7 @@ describe('List View Gantt', () => {
cy.visit('/lists/1/gantt')
- cy.get('.gantt-chart .months')
+ cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
@@ -38,14 +38,13 @@ describe('List View Gantt', () => {
})
cy.visit('/lists/1/gantt')
- cy.get('.gantt-chart .tasks')
+ cy.get('.g-gantt-rows-container')
.should('not.be.empty')
- cy.get('.gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
- TaskFactory.create(1, {
+ const tasks = TaskFactory.create(1, {
start_date: null,
end_date: null,
})
@@ -55,13 +54,15 @@ describe('List View Gantt', () => {
.contains('Show tasks which don\'t have dates set')
.click()
- cy.get('.gantt-chart .tasks')
+ cy.get('.g-gantt-rows-container')
.should('not.be.empty')
- cy.get('.gantt-chart .tasks .task.nodate')
- .should('exist')
+ .should('contain', tasks[0].title)
})
it('Drags a task around', () => {
+ cy.intercept('**/api/v1/tasks/*')
+ .as('taskUpdate')
+
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
@@ -69,10 +70,11 @@ describe('List View Gantt', () => {
})
cy.visit('/lists/1/gantt')
- cy.get('.gantt-chart .tasks .task')
+ cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
+ cy.wait('@taskUpdate')
})
})
\ No newline at end of file
diff --git a/package.json b/package.json
index 4fe8c66c4..da9061a27 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@github/hotkey": "2.0.1",
+ "@infectoone/vue-ganttastic": "2.1.2",
"@kyvg/vue3-notification": "2.4.1",
"@sentry/tracing": "7.17.0",
"@sentry/vue": "7.17.0",
@@ -37,8 +38,10 @@
"camel-case": "4.1.2",
"codemirror": "5.65.9",
"date-fns": "2.29.3",
+ "dayjs": "1.11.6",
"dompurify": "2.4.0",
"easymde": "2.18.0",
+ "fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.21",
"floating-vue": "2.0.0-beta.20",
@@ -55,7 +58,6 @@
"ufo": "0.8.6",
"vue": "3.2.41",
"vue-advanced-cropper": "2.8.6",
- "vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "10.0.0",
"vue-i18n": "9.2.2",
"vue-router": "4.1.6",
@@ -89,7 +91,7 @@
"eslint": "8.26.0",
"eslint-plugin-vue": "9.6.0",
"express": "4.18.2",
- "happy-dom": "7.6.0",
+ "happy-dom": "7.6.6",
"netlify-cli": "12.0.11",
"postcss": "8.4.18",
"postcss-preset-env": "7.8.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b8668149..e54ff5b14 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,6 +10,7 @@ specifiers:
'@fortawesome/free-solid-svg-icons': 6.2.0
'@fortawesome/vue-fontawesome': 3.0.1
'@github/hotkey': 2.0.1
+ '@infectoone/vue-ganttastic': 2.1.2
'@kyvg/vue3-notification': 2.4.1
'@rushstack/eslint-patch': 1.2.0
'@sentry/tracing': 7.17.0
@@ -42,16 +43,18 @@ specifiers:
codemirror: 5.65.9
cypress: 10.11.0
date-fns: 2.29.3
+ dayjs: 1.11.6
dompurify: 2.4.0
easymde: 2.18.0
esbuild: 0.15.12
eslint: 8.26.0
eslint-plugin-vue: 9.6.0
express: 4.18.2
+ fast-deep-equal: 3.1.3
flatpickr: 4.6.13
flexsearch: 0.7.21
floating-vue: 2.0.0-beta.20
- happy-dom: 7.6.0
+ happy-dom: 7.6.6
highlight.js: 11.6.0
is-touch-device: 1.0.1
lodash.clonedeep: 4.5.0
@@ -76,7 +79,6 @@ specifiers:
vitest: 0.24.3
vue: 3.2.41
vue-advanced-cropper: 2.8.6
- vue-drag-resize: 2.0.3
vue-flatpickr-component: 10.0.0
vue-i18n: 9.2.2
vue-router: 4.1.6
@@ -92,6 +94,7 @@ dependencies:
'@fortawesome/free-solid-svg-icons': 6.2.0
'@fortawesome/vue-fontawesome': 3.0.1_lteq7vqmz6gtgcgatkvrcm56su
'@github/hotkey': 2.0.1
+ '@infectoone/vue-ganttastic': 2.1.2_dayjs@1.11.6+vue@3.2.41
'@kyvg/vue3-notification': 2.4.1_vue@3.2.41
'@sentry/tracing': 7.17.0
'@sentry/vue': 7.17.0_vue@3.2.41
@@ -106,8 +109,10 @@ dependencies:
camel-case: 4.1.2
codemirror: 5.65.9
date-fns: 2.29.3
+ dayjs: 1.11.6
dompurify: 2.4.0
easymde: 2.18.0
+ fast-deep-equal: 3.1.3
flatpickr: 4.6.13
flexsearch: 0.7.21
floating-vue: 2.0.0-beta.20_vue@3.2.41
@@ -124,7 +129,6 @@ dependencies:
ufo: 0.8.6
vue: 3.2.41
vue-advanced-cropper: 2.8.6_vue@3.2.41
- vue-drag-resize: 2.0.3
vue-flatpickr-component: 10.0.0_vue@3.2.41
vue-i18n: 9.2.2_vue@3.2.41
vue-router: 4.1.6_vue@3.2.41
@@ -158,7 +162,7 @@ devDependencies:
eslint: 8.26.0
eslint-plugin-vue: 9.6.0_eslint@8.26.0
express: 4.18.2
- happy-dom: 7.6.0
+ happy-dom: 7.6.6
netlify-cli: 12.0.11_@types+node@18.11.7
postcss: 8.4.18
postcss-preset-env: 7.8.2_postcss@8.4.18
@@ -169,7 +173,7 @@ devDependencies:
vite: 3.2.0_sass@1.55.0+terser@5.10.0
vite-plugin-pwa: 0.13.1_26qty5l7rsv3yrimlrr6zrzqbu
vite-svg-loader: 3.6.0
- vitest: 0.24.3_gqoderdqe64ltl5b7jo6jid56m
+ vitest: 0.24.3_zk3z3yjjczezb4ds7r7zfrc4ay
vue-tsc: 1.0.9_typescript@4.8.4
wait-on: 6.0.1
workbox-cli: 6.5.4_acorn@8.8.0
@@ -1745,6 +1749,16 @@ packages:
resolution: {integrity: sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA==}
dev: true
+ /@infectoone/vue-ganttastic/2.1.2_dayjs@1.11.6+vue@3.2.41:
+ resolution: {integrity: sha512-xYjhA1bUUSVNjbFmM5eeN0mdKdDg7LWMO9RSh+9fFqfnqu24VwazTumHBYQZFGvGfdJW0DE7ZN3tbLhL9vkYlA==}
+ peerDependencies:
+ dayjs: ^1.11.5
+ vue: ^3.2.40
+ dependencies:
+ dayjs: 1.11.6
+ vue: 3.2.41
+ dev: false
+
/@intlify/core-base/9.2.2:
resolution: {integrity: sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==}
engines: {node: '>= 14'}
@@ -4023,7 +4037,7 @@ packages:
postcss: ^8.1.0
dependencies:
browserslist: 4.21.4
- caniuse-lite: 1.0.30001423
+ caniuse-lite: 1.0.30001426
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -4312,7 +4326,7 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
- caniuse-lite: 1.0.30001423
+ caniuse-lite: 1.0.30001426
electron-to-chromium: 1.4.256
node-releases: 2.0.6
update-browserslist-db: 1.0.9_browserslist@4.21.4
@@ -4517,6 +4531,10 @@ packages:
resolution: {integrity: sha512-09iwWGOlifvE1XuHokFMP7eR38a0JnajoyL3/i87c8ZjRWRrdKo1fqjNfugfBD0UDBIOz0U+jtNhJ0EPm1VleQ==}
dev: true
+ /caniuse-lite/1.0.30001426:
+ resolution: {integrity: sha512-n7cosrHLl8AWt0wwZw/PJZgUg3lV0gk9LMI7ikGJwhyhgsd2Nb65vKvmSexCqq/J7rbH3mFG6yZZiPR5dLPW5A==}
+ dev: true
+
/caseless/0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
dev: true
@@ -5230,7 +5248,7 @@ packages:
cli-table3: 0.6.1
commander: 5.1.0
common-tags: 1.8.2
- dayjs: 1.10.7
+ dayjs: 1.11.6
debug: 4.3.4_supports-color@8.1.1
enquirer: 2.3.6
eventemitter2: 6.4.7
@@ -5286,9 +5304,8 @@ packages:
time-zone: 1.0.0
dev: true
- /dayjs/1.10.7:
- resolution: {integrity: sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==}
- dev: true
+ /dayjs/1.11.6:
+ resolution: {integrity: sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ==}
/de-indent/1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
@@ -6629,7 +6646,6 @@ packages:
/fast-deep-equal/3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
- dev: true
/fast-diff/1.2.0:
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
@@ -7440,8 +7456,8 @@ packages:
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
dev: true
- /happy-dom/7.6.0:
- resolution: {integrity: sha512-QnNsiblZdyVDzW5ts6E7ub79JnabqHJeJgt+1WGNq9fSYqS/r/RzzTVXCZSDl6EVkipdwI48B4bgXAnMZPecIw==}
+ /happy-dom/7.6.6:
+ resolution: {integrity: sha512-28NxRiHXjzhr+BGciLNUoQW4OaBnQPRT/LPYLufh0Fj3Iwh1j9qJaozjBm/Uqdj5Ps4cukevQ7ERieA6Ddwf1g==}
dependencies:
css.escape: 1.5.1
he: 1.2.0
@@ -12922,7 +12938,7 @@ packages:
fsevents: 2.3.2
dev: true
- /vitest/0.24.3_gqoderdqe64ltl5b7jo6jid56m:
+ /vitest/0.24.3_zk3z3yjjczezb4ds7r7zfrc4ay:
resolution: {integrity: sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==}
engines: {node: '>=v14.16.0'}
hasBin: true
@@ -12949,7 +12965,7 @@ packages:
'@types/node': 18.11.7
chai: 4.3.6
debug: 4.3.4
- happy-dom: 7.6.0
+ happy-dom: 7.6.6
local-pkg: 0.4.2
strip-literal: 0.4.2
tinybench: 2.3.0
@@ -12992,10 +13008,6 @@ packages:
vue: 3.2.41
dev: false
- /vue-drag-resize/2.0.3:
- resolution: {integrity: sha512-5q03tZ/LyvQsg1iHRcqs+wI2OKNbNIWl9+7V8rVL6MxJhZLCIYSSgbAUaDE38LhD6dFd5aJhdgNmES61AxjXuw==}
- dev: false
-
/vue-eslint-parser/9.0.3_eslint@8.26.0:
resolution: {integrity: sha512-yL+ZDb+9T0ELG4VIFo/2anAOz8SvBdlqEnQnvJ3M7Scq56DvtjY0VY88bByRZB0D4J0u8olBcfrXTVONXsh4og==}
engines: {node: ^14.17.0 || >=16.0.0}
diff --git a/src/components/input/AsyncEditor.ts b/src/components/input/AsyncEditor.ts
index 30278c292..f80015229 100644
--- a/src/components/input/AsyncEditor.ts
+++ b/src/components/input/AsyncEditor.ts
@@ -1,12 +1,3 @@
-import { defineAsyncComponent } from 'vue'
-import ErrorComponent from '@/components/misc/error.vue'
-import LoadingComponent from '@/components/misc/loading.vue'
+import {createAsyncComponent} from '@/helpers/createAsyncComponent'
-const Editor = () => import('@/components/input/editor.vue')
-
-export default defineAsyncComponent({
- loader: Editor,
- loadingComponent: LoadingComponent,
- errorComponent: ErrorComponent,
- timeout: 60000,
-})
+export default createAsyncComponent(() => import('@/components/input/editor.vue'))
\ No newline at end of file
diff --git a/src/components/misc/error.vue b/src/components/misc/error.vue
index 04cd14483..7c797e264 100644
--- a/src/components/misc/error.vue
+++ b/src/components/misc/error.vue
@@ -7,6 +7,12 @@
+
+
+
+
diff --git a/src/components/misc/loading.vue b/src/components/misc/loading.vue
index 6cc001a0c..9607ca3cf 100644
--- a/src/components/misc/loading.vue
+++ b/src/components/misc/loading.vue
@@ -2,6 +2,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/tasks/TaskForm.vue b/src/components/tasks/TaskForm.vue
new file mode 100644
index 000000000..972fc1500
--- /dev/null
+++ b/src/components/tasks/TaskForm.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
diff --git a/src/components/tasks/gantt-component.vue b/src/components/tasks/gantt-component.vue
deleted file mode 100644
index 596dae839..000000000
--- a/src/components/tasks/gantt-component.vue
+++ /dev/null
@@ -1,642 +0,0 @@
-
-
-
-
-
-
-
- {{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
-
-
-
- {{ d.getDate() }}
-
-
- {{
- d.toLocaleString('en-us', {
- weekday: 'short',
- })
- }}
-
-
-
-
-
-
-
-
-
-
resizeTask(t, e)"
- @resizestop="(e) => resizeTask(t, e)"
- axis="x"
- class="task"
- >
-
- {{ t.title }}
-
-
-
-
-
-
-
-
-
-
-
- resizeTask(t, e)"
- @resizestop="(e) => resizeTask(t, e)"
- axis="x"
- class="task nodate"
- v-tooltip="$t('list.gantt.noDates')"
- >
- {{ t.title }}
-
-
-
-
-
-
- {isTaskEdit = false;taskToEdit = null}"
- :task="taskToEdit"
- />
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/composables/taskList.ts b/src/composables/taskList.ts
index e23f10087..85fdc23b7 100644
--- a/src/composables/taskList.ts
+++ b/src/composables/taskList.ts
@@ -2,7 +2,7 @@ import {ref, shallowReactive, watch, computed} from 'vue'
import {useRoute} from 'vue-router'
import TaskCollectionService from '@/services/taskCollection'
-import type { ITask } from '@/modelTypes/ITask'
+import type {ITask} from '@/modelTypes/ITask'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
diff --git a/src/composables/useRenewTokenOnFocus.ts b/src/composables/useRenewTokenOnFocus.ts
index c5ed0fa65..21070f3af 100644
--- a/src/composables/useRenewTokenOnFocus.ts
+++ b/src/composables/useRenewTokenOnFocus.ts
@@ -3,6 +3,9 @@ import {useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
+import {MILLISECONDS_A_HOUR, SECONDS_A_HOUR} from '@/constants/date'
+
+const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
export function useRenewTokenOnFocus() {
const router = useRouter()
@@ -21,7 +24,7 @@ export function useRenewTokenOnFocus() {
return
}
- const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
+ const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - new Date().valueOf() / MILLISECONDS_A_HOUR
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
@@ -32,7 +35,7 @@ export function useRenewTokenOnFocus() {
}
// Check if the token is valid for less than 60 hours and renew if thats the case
- if (expiresIn < 60 * 3600) {
+ if (expiresIn < SECONDS_TOKEN_VALID) {
authStore.renewToken()
console.debug('renewed token')
}
diff --git a/src/composables/useRouteFilters.ts b/src/composables/useRouteFilters.ts
new file mode 100644
index 000000000..0959ab59b
--- /dev/null
+++ b/src/composables/useRouteFilters.ts
@@ -0,0 +1,55 @@
+import {computed, ref, watch, type Ref} from 'vue'
+import {useRouter, type RouteLocationNormalized, type RouteLocationRaw} from 'vue-router'
+import cloneDeep from 'lodash.clonedeep'
+import equal from 'fast-deep-equal/es6'
+
+export type Filters = Record
+
+export function useRouteFilters(
+ route: Ref,
+ getDefaultFilters: (route: RouteLocationNormalized) => CurrentFilters,
+ routeToFilters: (route: RouteLocationNormalized) => CurrentFilters,
+ filtersToRoute: (filters: CurrentFilters) => RouteLocationRaw,
+ ) {
+ const router = useRouter()
+
+ const filters = ref(routeToFilters(route.value))
+
+ const routeFromFiltersFullPath = computed(() => router.resolve(filtersToRoute(filters.value)).fullPath)
+
+ watch(() => cloneDeep(route.value), (route, oldRoute) => {
+ if (
+ route.name !== oldRoute.name ||
+ routeFromFiltersFullPath.value === route.fullPath
+ ) {
+ return
+ }
+
+ filters.value = routeToFilters(route)
+ })
+
+ watch(
+ filters,
+ async () => {
+ if (routeFromFiltersFullPath.value !== route.value.fullPath) {
+ await router.push(routeFromFiltersFullPath.value)
+ }
+ },
+ // only apply new route after all filters have changed in component cycle
+ {flush: 'post'},
+ )
+
+ const hasDefaultFilters = computed(() => {
+ return equal(filters.value, getDefaultFilters(route.value))
+ })
+
+ function setDefaultFilters() {
+ filters.value = getDefaultFilters(route.value)
+ }
+
+ return {
+ filters,
+ hasDefaultFilters,
+ setDefaultFilters,
+ }
+}
\ No newline at end of file
diff --git a/src/constants/date.ts b/src/constants/date.ts
new file mode 100644
index 000000000..835f2c4da
--- /dev/null
+++ b/src/constants/date.ts
@@ -0,0 +1,14 @@
+export const DATEFNS_DATE_FORMAT_KEBAB = 'yyyy-LL-dd'
+
+export const SECONDS_A_MINUTE = 60
+export const SECONDS_A_HOUR = SECONDS_A_MINUTE * 60
+export const SECONDS_A_DAY = SECONDS_A_HOUR * 24
+export const SECONDS_A_WEEK = SECONDS_A_DAY * 7
+export const SECONDS_A_MONTH = SECONDS_A_DAY * 30
+export const SECONDS_A_YEAR = SECONDS_A_DAY * 365
+
+export const MILLISECONDS_A_SECOND = 1000
+export const MILLISECONDS_A_MINUTE = SECONDS_A_MINUTE * MILLISECONDS_A_SECOND
+export const MILLISECONDS_A_HOUR = SECONDS_A_HOUR * MILLISECONDS_A_SECOND
+export const MILLISECONDS_A_DAY = SECONDS_A_DAY * MILLISECONDS_A_SECOND
+export const MILLISECONDS_A_WEEK = SECONDS_A_WEEK * MILLISECONDS_A_SECOND
\ No newline at end of file
diff --git a/src/helpers/color/colorFromHex.ts b/src/helpers/color/colorFromHex.ts
index ac95be7e5..cc2fa0898 100644
--- a/src/helpers/color/colorFromHex.ts
+++ b/src/helpers/color/colorFromHex.ts
@@ -4,7 +4,7 @@
* @param color
* @returns {string}
*/
-export function colorFromHex(color) {
+export function colorFromHex(color: string) {
if (color.substring(0, 1) === '#') {
color = color.substring(1, 7)
}
diff --git a/src/helpers/createAsyncComponent.ts b/src/helpers/createAsyncComponent.ts
new file mode 100644
index 000000000..233396dd4
--- /dev/null
+++ b/src/helpers/createAsyncComponent.ts
@@ -0,0 +1,21 @@
+import { defineAsyncComponent, type AsyncComponentLoader, type AsyncComponentOptions, type Component, type ComponentPublicInstance } from 'vue'
+
+import ErrorComponent from '@/components/misc/error.vue'
+import LoadingComponent from '@/components/misc/loading.vue'
+
+const DEFAULT_TIMEOUT = 60000
+
+export function createAsyncComponent(source: AsyncComponentLoader | AsyncComponentOptions): T {
+ if (typeof source === 'function') {
+ source = { loader: source }
+ }
+
+ return defineAsyncComponent({
+ ...source,
+ loadingComponent: LoadingComponent,
+ errorComponent: ErrorComponent,
+ timeout: DEFAULT_TIMEOUT,
+ })
+}
diff --git a/src/helpers/time/formatDate.ts b/src/helpers/time/formatDate.ts
index 2a202b9d2..5eb7ccc37 100644
--- a/src/helpers/time/formatDate.ts
+++ b/src/helpers/time/formatDate.ts
@@ -1,5 +1,7 @@
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {format, formatDistanceToNow, formatISO as formatISOfns} from 'date-fns'
+
+// FIXME: support all locales and load dynamically
import {enGB, de, fr, ru} from 'date-fns/locale'
import {i18n} from '@/i18n'
diff --git a/src/helpers/time/getNextWeekDate.ts b/src/helpers/time/getNextWeekDate.ts
index d0bb303fb..bb7bcfe07 100644
--- a/src/helpers/time/getNextWeekDate.ts
+++ b/src/helpers/time/getNextWeekDate.ts
@@ -1,3 +1,5 @@
+import {MILLISECONDS_A_WEEK} from '@/constants/date'
+
export function getNextWeekDate(): Date {
- return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
+ return new Date((new Date()).getTime() + MILLISECONDS_A_WEEK)
}
diff --git a/src/helpers/time/isoToKebabDate.ts b/src/helpers/time/isoToKebabDate.ts
new file mode 100644
index 000000000..b9ec26c52
--- /dev/null
+++ b/src/helpers/time/isoToKebabDate.ts
@@ -0,0 +1,8 @@
+import {format} from 'date-fns'
+import {DATEFNS_DATE_FORMAT_KEBAB} from '@/constants/date'
+import type {DateISO} from '@/types/DateISO'
+import type {DateKebab} from '@/types/DateKebab'
+
+export function isoToKebabDate(isoDate: DateISO) {
+ return format(new Date(isoDate), DATEFNS_DATE_FORMAT_KEBAB) as DateKebab
+}
\ No newline at end of file
diff --git a/src/helpers/time/parseBooleanProp.ts b/src/helpers/time/parseBooleanProp.ts
new file mode 100644
index 000000000..b43ecac59
--- /dev/null
+++ b/src/helpers/time/parseBooleanProp.ts
@@ -0,0 +1,5 @@
+export function parseBooleanProp(booleanProp: string | undefined) {
+ return (booleanProp === 'false' || booleanProp === '0')
+ ? false
+ : Boolean(booleanProp)
+}
\ No newline at end of file
diff --git a/src/helpers/time/parseDate.ts b/src/helpers/time/parseDate.ts
index ffab0aa66..226cb8848 100644
--- a/src/helpers/time/parseDate.ts
+++ b/src/helpers/time/parseDate.ts
@@ -349,9 +349,7 @@ const getMonthFromText = (text: string, date: Date) => {
const getDateFromInterval = (interval: number): Date => {
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
- newDate.setHours(calculateNearestHours(newDate))
- newDate.setMinutes(0)
- newDate.setSeconds(0)
+ newDate.setHours(calculateNearestHours(newDate), 0, 0)
return newDate
}
diff --git a/src/helpers/time/parseDateProp.ts b/src/helpers/time/parseDateProp.ts
new file mode 100644
index 000000000..9e426df25
--- /dev/null
+++ b/src/helpers/time/parseDateProp.ts
@@ -0,0 +1,30 @@
+import type {DateISO} from '@/types/DateISO'
+import type {DateKebab} from '@/types/DateKebab'
+
+export function parseDateProp(kebabDate: DateKebab | undefined): string | undefined {
+ try {
+
+ if (!kebabDate) {
+ throw new Error('No value')
+ }
+ const dateValues = kebabDate.split('-')
+ const [, monthString, dateString] = dateValues
+ const [year, month, date] = dateValues.map(val => Number(val))
+ const dateValuesAreValid = (
+ !Number.isNaN(year) &&
+ monthString.length >= 1 && monthString.length <= 2 &&
+ !Number.isNaN(month) &&
+ month >= 1 && month <= 12 &&
+ dateString.length >= 1 && dateString.length <= 31 &&
+ !Number.isNaN(date) &&
+ date >= 1 && date <= 31
+ )
+ if (!dateValuesAreValid) {
+ throw new Error('Invalid date values')
+ }
+ return new Date(year, month, date).toISOString() as DateISO
+ } catch(e) {
+ // ignore nonsense route queries
+ return
+ }
+}
\ No newline at end of file
diff --git a/src/helpers/time/parseKebabDate.ts b/src/helpers/time/parseKebabDate.ts
new file mode 100644
index 000000000..f1643aa48
--- /dev/null
+++ b/src/helpers/time/parseKebabDate.ts
@@ -0,0 +1,7 @@
+import {parse} from 'date-fns'
+import {DATEFNS_DATE_FORMAT_KEBAB} from '@/constants/date'
+import type {DateKebab} from '@/types/DateKebab'
+
+export function parseKebabDate(date: DateKebab): Date {
+ return parse(date, DATEFNS_DATE_FORMAT_KEBAB, new Date())
+}
\ No newline at end of file
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 266fe99af..f44737786 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -1,19 +1,8 @@
import {createI18n} from 'vue-i18n'
import langEN from './lang/en.json'
-export const i18n = createI18n({
- locale: 'en', // set locale
- fallbackLocale: 'en',
- legacy: true,
- globalInjection: true,
- allowComposition: true,
- messages: {
- en: langEN,
- },
-})
-
-export const availableLanguages = {
- en: 'English',
+export const SUPPORTED_LOCALES = {
+ 'en': 'English',
'de-DE': 'Deutsch',
'de-swiss': 'Schwizertütsch',
'ru-RU': 'Русский',
@@ -24,62 +13,72 @@ export const availableLanguages = {
'pl-PL': 'Polski',
'nl-NL': 'Nederlands',
'pt-PT': 'Português',
-}
+ 'zh-CN': 'Chinese',
+} as Record
-const loadedLanguages = ['en'] // our default language that is preloaded
+export type SupportedLocale = keyof typeof SUPPORTED_LOCALES
-const setI18nLanguage = (lang: string) => {
+export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
+
+export type ISOLanguage = string
+
+export const i18n = createI18n({
+ locale: DEFAULT_LANGUAGE, // set locale
+ fallbackLocale: DEFAULT_LANGUAGE,
+ legacy: true,
+ globalInjection: true,
+ allowComposition: true,
+ inheritLocale: true,
+ messages: {
+ en: langEN,
+ } as Record,
+})
+
+function setI18nLanguage(lang: SupportedLocale): SupportedLocale {
i18n.global.locale = lang
- document.documentElement.lang =lang
+ document.documentElement.lang = lang
return lang
}
-export const loadLanguageAsync = lang => {
+export async function loadLanguageAsync(lang: SupportedLocale) {
if (!lang) {
+ throw new Error()
+ }
+
+ // do not change language to the current one
+ if (i18n.global.locale === lang) {
return
}
- if (
- // If the same language
- i18n.global.locale === lang ||
- // If the language was already loaded
- loadedLanguages.includes(lang)
- ) {
- return setI18nLanguage(lang)
+ // If the language hasn't been loaded yet
+ if (!i18n.global.availableLocales.includes(lang)) {
+ const messages = await import(`./lang/${lang}.json`)
+ i18n.global.setLocaleMessage(lang, messages.default)
}
- // If the language hasn't been loaded yet
- return import(`./lang/${lang}.json`).then(
- messages => {
- i18n.global.setLocaleMessage(lang, messages.default)
- loadedLanguages.push(lang)
- return setI18nLanguage(lang)
- },
- )
+ return setI18nLanguage(lang)
}
-export const getCurrentLanguage = () => {
+export function getCurrentLanguage(): SupportedLocale {
const savedLanguage = localStorage.getItem('language')
if (savedLanguage !== null) {
return savedLanguage
}
- const browserLanguage = navigator.language || navigator.userLanguage
+ const browserLanguage = navigator.language
- for (const k in availableLanguages) {
- if (browserLanguage[k] === browserLanguage || k.startsWith(browserLanguage + '-')) {
- return k
- }
- }
+ const language: SupportedLocale | undefined = Object.keys(SUPPORTED_LOCALES).find(langKey => {
+ return langKey === browserLanguage || langKey.startsWith(browserLanguage + '-')
+ })
- return 'en'
+ return language || DEFAULT_LANGUAGE
}
-export const saveLanguage = (lang: string) => {
+export function saveLanguage(lang: SupportedLocale) {
localStorage.setItem('language', lang)
setLanguage()
}
-export const setLanguage = () => {
- loadLanguageAsync(getCurrentLanguage())
-}
+export function setLanguage() {
+ return loadLanguageAsync(getCurrentLanguage())
+}
\ No newline at end of file
diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json
index 109c422c9..012d48f74 100644
--- a/src/i18n/lang/en.json
+++ b/src/i18n/lang/en.json
@@ -286,8 +286,8 @@
"default": "Default",
"month": "Month",
"day": "Day",
- "from": "From",
- "to": "To",
+ "hour": "Hour",
+ "range": "Date Range",
"noDates": "This task has no dates set."
},
"table": {
diff --git a/src/i18n/useDayjsLanguageSync.ts b/src/i18n/useDayjsLanguageSync.ts
new file mode 100644
index 000000000..377737de7
--- /dev/null
+++ b/src/i18n/useDayjsLanguageSync.ts
@@ -0,0 +1,60 @@
+import {computed, ref, watch} from 'vue'
+import type dayjs from 'dayjs'
+import type ILocale from 'dayjs/locale/*'
+
+import {i18n, type SupportedLocale, type ISOLanguage} from '@/i18n'
+
+export const DAYJS_LOCALE_MAPPING = {
+ 'de-de': 'de',
+ 'de-swiss': 'de-at',
+ 'ru-ru': 'ru',
+ 'fr-fr': 'fr',
+ 'vi-vn': 'vi',
+ 'it-it': 'it',
+ 'cs-cz': 'cs',
+ 'pl-pl': 'pl',
+ 'nl-nl': 'nl',
+ 'pt-pt': 'pt',
+ 'zh-cn': 'zh-cn',
+} as Record
+
+export const DAYJS_LANGUAGE_IMPORTS = {
+ 'de-de': () => import('dayjs/locale/de'),
+ 'de-swiss': () => import('dayjs/locale/de-at'),
+ 'ru-ru': () => import('dayjs/locale/ru'),
+ 'fr-fr': () => import('dayjs/locale/fr'),
+ 'vi-vn': () => import('dayjs/locale/vi'),
+ 'it-it': () => import('dayjs/locale/it'),
+ 'cs-cz': () => import('dayjs/locale/cs'),
+ 'pl-pl': () => import('dayjs/locale/pl'),
+ 'nl-nl': () => import('dayjs/locale/nl'),
+ 'pt-pt': () => import('dayjs/locale/pt'),
+ 'zh-cn': () => import('dayjs/locale/zh-cn'),
+} as Record Promise>
+
+export function useDayjsLanguageSync(dayjsGlobal: typeof dayjs) {
+
+ const dayjsLanguageLoaded = ref(false)
+ watch(
+ () => i18n.global.locale,
+ async (currentLanguage: string) => {
+ if (!dayjsGlobal) {
+ return
+ }
+ const dayjsLanguageCode = DAYJS_LOCALE_MAPPING[currentLanguage.toLowerCase()] || currentLanguage.toLowerCase()
+ dayjsLanguageLoaded.value = dayjsGlobal.locale() === dayjsLanguageCode
+ if (dayjsLanguageLoaded.value) {
+ return
+ }
+ await DAYJS_LANGUAGE_IMPORTS[currentLanguage.toLowerCase()]()
+ dayjsGlobal.locale(dayjsLanguageCode)
+ dayjsLanguageLoaded.value = true
+ },
+ {immediate: true},
+ )
+
+ // we export the loading state since that's easier to work with
+ const isLoading = computed(() => !dayjsLanguageLoaded.value)
+
+ return isLoading
+}
\ No newline at end of file
diff --git a/src/message/index.ts b/src/message/index.ts
index c960bf84b..70902d50b 100644
--- a/src/message/index.ts
+++ b/src/message/index.ts
@@ -1,5 +1,5 @@
import {i18n} from '@/i18n'
-import { notify } from '@kyvg/vue3-notification'
+import {notify} from '@kyvg/vue3-notification'
export const getErrorText = (r) => {
diff --git a/src/modelSchema/common/repeats.ts b/src/modelSchema/common/repeats.ts
index 175b0c727..da89e1f95 100644
--- a/src/modelSchema/common/repeats.ts
+++ b/src/modelSchema/common/repeats.ts
@@ -1,6 +1,28 @@
+import {SECONDS_A_HOUR} from '@/constants/date'
import { REPEAT_TYPES, type IRepeatAfter } from '@/types/IRepeatAfter'
import { nativeEnum, number, object, preprocess } from 'zod'
+/**
+ * Parses `repeatAfterSeconds` into a usable js object.
+ */
+ export function parseRepeatAfter(repeatAfterSeconds: number): IRepeatAfter {
+ let repeatAfter: IRepeatAfter = {type: 'hours', amount: repeatAfterSeconds / SECONDS_A_HOUR}
+
+ // if its dividable by 24, its something with days, otherwise hours
+ if (repeatAfterSeconds % SECONDS_A_DAY === 0) {
+ if (repeatAfterSeconds % SECONDS_A_WEEK === 0) {
+ repeatAfter = {type: 'weeks', amount: repeatAfterSeconds / SECONDS_A_WEEK}
+ } else if (repeatAfterSeconds % SECONDS_A_MONTH === 0) {
+ repeatAfter = {type:'months', amount: repeatAfterSeconds / SECONDS_A_MONTH}
+ } else if (repeatAfterSeconds % SECONDS_A_YEAR === 0) {
+ repeatAfter = {type: 'years', amount: repeatAfterSeconds / SECONDS_A_YEAR}
+ } else {
+ repeatAfter = {type: 'days', amount: repeatAfterSeconds / SECONDS_A_DAY}
+ }
+ }
+ return repeatAfter
+}
+
export const RepeatsSchema = preprocess(
(repeats: unknown) => {
// Parses the "repeat after x seconds" from the task into a usable js object inside the task.
@@ -9,32 +31,7 @@ export const RepeatsSchema = preprocess(
return repeats
}
- const repeatAfterHours = (repeats / 60) / 60
-
- const repeatAfter : IRepeatAfter = {
- type: 'hours',
- amount: repeatAfterHours,
- }
-
- // if its dividable by 24, its something with days, otherwise hours
- if (repeatAfterHours % 24 === 0) {
- const repeatAfterDays = repeatAfterHours / 24
- if (repeatAfterDays % 7 === 0) {
- repeatAfter.type = 'weeks'
- repeatAfter.amount = repeatAfterDays / 7
- } else if (repeatAfterDays % 30 === 0) {
- repeatAfter.type = 'months'
- repeatAfter.amount = repeatAfterDays / 30
- } else if (repeatAfterDays % 365 === 0) {
- repeatAfter.type = 'years'
- repeatAfter.amount = repeatAfterDays / 365
- } else {
- repeatAfter.type = 'days'
- repeatAfter.amount = repeatAfterDays
- }
- }
-
- return repeatAfter
+ return parseRepeatAfter(repeats)
},
object({
type: nativeEnum(REPEAT_TYPES),
diff --git a/src/modelTypes/ITask.ts b/src/modelTypes/ITask.ts
index 4bfb23fa4..bcbcf3925 100644
--- a/src/modelTypes/ITask.ts
+++ b/src/modelTypes/ITask.ts
@@ -12,6 +12,8 @@ import type {IRelationKind} from '@/types/IRelationKind'
import type {IRepeatAfter} from '@/types/IRepeatAfter'
import type {IRepeatMode} from '@/types/IRepeatMode'
+import type {PartialWithId} from '@/types/PartialWithId'
+
export interface ITask extends IAbstract {
id: number
title: string
@@ -49,4 +51,6 @@ export interface ITask extends IAbstract {
listId: IList['id'] // Meta, only used when creating a new task
bucketId: IBucket['id']
-}
\ No newline at end of file
+}
+
+export type ITaskPartialWithId = PartialWithId
\ No newline at end of file
diff --git a/src/models/task.ts b/src/models/task.ts
index 5f4e01d70..a133c5e65 100644
--- a/src/models/task.ts
+++ b/src/models/task.ts
@@ -1,5 +1,5 @@
-
-import { PRIORITIES, type Priority } from '@/constants/priorities'
+import {PRIORITIES, type Priority} from '@/constants/priorities'
+import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_MONTH, SECONDS_A_WEEK, SECONDS_A_YEAR} from '@/constants/date'
import type {ITask} from '@/modelTypes/ITask'
import type {ILabel} from '@/modelTypes/ILabel'
@@ -10,10 +10,10 @@ import type {ISubscription} from '@/modelTypes/ISubscription'
import type {IBucket} from '@/modelTypes/IBucket'
import type {IRepeatAfter} from '@/types/IRepeatAfter'
+import type {IRelationKind} from '@/types/IRelationKind'
import {TASK_REPEAT_MODES, type IRepeatMode} from '@/types/IRepeatMode'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
-import type { IRelationKind } from '@/types/IRelationKind'
import AbstractModel from './abstractModel'
import LabelModel from './label'
@@ -36,6 +36,27 @@ export function getHexColor(hexColor: string): string {
return hexColor
}
+/**
+ * Parses `repeatAfterSeconds` into a usable js object.
+ */
+export function parseRepeatAfter(repeatAfterSeconds: number): IRepeatAfter {
+ let repeatAfter: IRepeatAfter = {type: 'hours', amount: repeatAfterSeconds / SECONDS_A_HOUR}
+
+ // if its dividable by 24, its something with days, otherwise hours
+ if (repeatAfterSeconds % SECONDS_A_DAY === 0) {
+ if (repeatAfterSeconds % SECONDS_A_WEEK === 0) {
+ repeatAfter = {type: 'weeks', amount: repeatAfterSeconds / SECONDS_A_WEEK}
+ } else if (repeatAfterSeconds % SECONDS_A_MONTH === 0) {
+ repeatAfter = {type:'months', amount: repeatAfterSeconds / SECONDS_A_MONTH}
+ } else if (repeatAfterSeconds % SECONDS_A_YEAR === 0) {
+ repeatAfter = {type: 'years', amount: repeatAfterSeconds / SECONDS_A_YEAR}
+ } else {
+ repeatAfter = {type: 'days', amount: repeatAfterSeconds / SECONDS_A_DAY}
+ }
+ }
+ return repeatAfter
+}
+
export default class TaskModel extends AbstractModel implements ITask {
id = 0
title = ''
@@ -95,7 +116,7 @@ export default class TaskModel extends AbstractModel implements ITask {
this.endDate = parseDateOrNull(this.endDate)
// Parse the repeat after into something usable
- this.parseRepeatAfter()
+ this.repeatAfter = parseRepeatAfter(this.repeatAfter as number)
this.reminderDates = this.reminderDates.map(d => new Date(d))
@@ -151,33 +172,6 @@ export default class TaskModel extends AbstractModel implements ITask {
// Helper functions
///////////////
- /**
- * Parses the "repeat after x seconds" from the task into a usable js object inside the task.
- * This function should only be called from the constructor.
- */
- parseRepeatAfter() {
- const repeatAfterHours = (this.repeatAfter as number / 60) / 60
- this.repeatAfter = {type: 'hours', amount: repeatAfterHours}
-
- // if its dividable by 24, its something with days, otherwise hours
- if (repeatAfterHours % 24 === 0) {
- const repeatAfterDays = repeatAfterHours / 24
- if (repeatAfterDays % 7 === 0) {
- this.repeatAfter.type = 'weeks'
- this.repeatAfter.amount = repeatAfterDays / 7
- } else if (repeatAfterDays % 30 === 0) {
- this.repeatAfter.type = 'months'
- this.repeatAfter.amount = repeatAfterDays / 30
- } else if (repeatAfterDays % 365 === 0) {
- this.repeatAfter.type = 'years'
- this.repeatAfter.amount = repeatAfterDays / 365
- } else {
- this.repeatAfter.type = 'days'
- this.repeatAfter.amount = repeatAfterDays
- }
- }
- }
-
async cancelScheduledNotifications() {
if (!SUPPORTS_TRIGGERED_NOTIFICATION) {
return
diff --git a/src/modules/parseTaskText.test.ts b/src/modules/parseTaskText.test.ts
index 83b291a70..728d1c8e3 100644
--- a/src/modules/parseTaskText.test.ts
+++ b/src/modules/parseTaskText.test.ts
@@ -1,9 +1,10 @@
import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest'
import {parseTaskText, PrefixMode} from './parseTaskText'
-import {getDateFromText, getDateFromTextIn, parseDate} from '../helpers/time/parseDate'
+import {getDateFromText, parseDate} from '../helpers/time/parseDate'
import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
import {PRIORITIES} from '@/constants/priorities'
+import { MILLISECONDS_A_DAY } from '@/constants/date'
describe('Parse Task Text', () => {
beforeEach(() => {
@@ -296,7 +297,7 @@ describe('Parse Task Text', () => {
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
})
it('should recognize dates of the month in the future', () => {
- const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
+ const nextDay = new Date(+new Date() + MILLISECONDS_A_DAY)
const result = parseTaskText(`Lorem Ipsum ${nextDay.getDate()}nd`)
expect(result.text).toBe('Lorem Ipsum')
diff --git a/src/router/index.ts b/src/router/index.ts
index 06120f6f0..9a00995e9 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -35,7 +35,7 @@ import MigrationComponent from '../views/migrator/Migrate.vue'
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
// List Views
import ListList from '../views/list/ListList.vue'
-import ListGantt from '../views/list/ListGantt.vue'
+const ListGantt = () => import('../views/list/ListGantt.vue')
import ListTable from '../views/list/ListTable.vue'
import ListKanban from '../views/list/ListKanban.vue'
const ListInfo = () => import('../views/list/ListInfo.vue')
@@ -379,7 +379,8 @@ const router = createRouter({
name: 'list.gantt',
component: ListGantt,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
- props: route => ({ listId: Number(route.params.listId as string) }),
+ // FIXME: test if `useRoute` would be the same. If it would use it instead.
+ props: route => ({route}),
},
{
path: '/lists/:listId/table',
diff --git a/src/services/abstractService.ts b/src/services/abstractService.ts
index b21f155ed..c056912ff 100644
--- a/src/services/abstractService.ts
+++ b/src/services/abstractService.ts
@@ -2,8 +2,9 @@ import {AuthenticatedHTTPFactory} from '@/http-common'
import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
-import AbstractModel, { type IAbstract } from '@/models/abstractModel'
-import type { Right } from '@/constants/rights'
+import AbstractModel from '@/models/abstractModel'
+import type {IAbstract} from '@/modelTypes/IAbstract'
+import type {Right} from '@/constants/rights'
import type {IFile} from '@/modelTypes/IFile'
interface Paths {
diff --git a/src/services/task.ts b/src/services/task.ts
index bb41b6853..0eee3227b 100644
--- a/src/services/task.ts
+++ b/src/services/task.ts
@@ -6,6 +6,7 @@ import LabelService from './label'
import {formatISO} from 'date-fns'
import {colorFromHex} from '@/helpers/color/colorFromHex'
+import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK, SECONDS_A_MONTH, SECONDS_A_YEAR} from '@/constants/date'
const parseDate = date => {
if (date) {
@@ -73,19 +74,19 @@ export default class TaskService extends AbstractService {
if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) {
switch (model.repeatAfter.type) {
case 'hours':
- repeatAfterSeconds = model.repeatAfter.amount * 60 * 60
+ repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_HOUR
break
case 'days':
- repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24
+ repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_DAY
break
case 'weeks':
- repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 7
+ repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_WEEK
break
case 'months':
- repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 30
+ repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_MONTH
break
case 'years':
- repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 365
+ repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_YEAR
break
}
}
diff --git a/src/services/taskCollection.ts b/src/services/taskCollection.ts
index 51830862b..8533bd2a9 100644
--- a/src/services/taskCollection.ts
+++ b/src/services/taskCollection.ts
@@ -1,8 +1,22 @@
-import AbstractService from './abstractService'
-import TaskModel from '../models/task'
import {formatISO} from 'date-fns'
-export default class TaskCollectionService extends AbstractService {
+import AbstractService from '@/services/abstractService'
+import TaskModel from '@/models/task'
+
+import type {ITask} from '@/modelTypes/ITask'
+
+// FIXME: unite with other filter params types
+export interface GetAllTasksParams {
+ sort_by: ('start_date' | 'done' | 'id')[],
+ order_by: ('asc' | 'asc' | 'desc')[],
+ filter_by: 'start_date'[],
+ filter_comparator: ('greater_equals' | 'less_equals')[],
+ filter_value: [string, string] // [dateFrom, dateTo],
+ filter_concat: 'and',
+ filter_include_nulls: boolean,
+}
+
+export default class TaskCollectionService extends AbstractService {
constructor() {
super({
getAll: '/lists/{listId}/tasks',
diff --git a/src/stores/auth.ts b/src/stores/auth.ts
index 13d354a49..90cca6836 100644
--- a/src/stores/auth.ts
+++ b/src/stores/auth.ts
@@ -14,6 +14,7 @@ import type {IUserSettings} from '@/modelTypes/IUserSettings'
import router from '@/router'
import {useConfigStore} from '@/stores/config'
import UserSettingsModel from '@/models/userSettings'
+import {MILLISECONDS_A_SECOND} from '@/constants/date'
export interface AuthState {
authenticated: boolean,
@@ -133,8 +134,10 @@ export const useAuthStore = defineStore('auth', {
}
},
- // Registers a new user and logs them in.
- // Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
+ /**
+ * Registers a new user and logs them in.
+ * Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
+ */
async register(credentials) {
const HTTP = HTTPFactory()
this.setIsLoading(true)
@@ -184,14 +187,17 @@ export const useAuthStore = defineStore('auth', {
return response.data
},
- // Populates user information from jwt token saved in local storage in store
+ /**
+ * Populates user information from jwt token saved in local storage in store
+ */
async checkAuth() {
-
+ const now = new Date()
+ const inOneMinute = new Date(new Date().setMinutes(now.getMinutes() + 1))
// This function can be called from multiple places at the same time and shortly after one another.
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
if (
this.lastUserInfoRefresh !== null &&
- this.lastUserInfoRefresh > (new Date()).setMinutes((new Date()).getMinutes() + 1)
+ this.lastUserInfoRefresh > inOneMinute
) {
return
}
@@ -204,7 +210,7 @@ export const useAuthStore = defineStore('auth', {
.replace('-', '+')
.replace('_', '/')
const info = new UserModel(JSON.parse(atob(base64)))
- const ts = Math.round((new Date()).getTime() / 1000)
+ const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
authenticated = info.exp >= ts
this.setUser(info)
@@ -282,9 +288,8 @@ export const useAuthStore = defineStore('auth', {
/**
* Try to verify the email
- * @returns {Promise} if the email was successfully confirmed
*/
- async verifyEmail() {
+ async verifyEmail(): Promise {
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
if (emailVerifyToken) {
const stopLoading = setModuleLoading(this)
@@ -325,7 +330,9 @@ export const useAuthStore = defineStore('auth', {
}
},
- // Renews the api token and saves it to local storage
+ /**
+ * Renews the api token and saves it to local storage
+ */
renewToken() {
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
diff --git a/src/styles/components/list.scss b/src/styles/components/list.scss
index ea340f3cd..a9bfda0c9 100644
--- a/src/styles/components/list.scss
+++ b/src/styles/components/list.scss
@@ -1,6 +1,5 @@
// FIXME: should be a component
// used in
-// - gantt-component.vue
// - Kanban.vue
// - List.vue
// - Table.vue
diff --git a/src/styles/components/tasks.scss b/src/styles/components/tasks.scss
index 910f0440f..061728658 100644
--- a/src/styles/components/tasks.scss
+++ b/src/styles/components/tasks.scss
@@ -46,7 +46,6 @@
}
// FIXME: is only used where is used aswell:
-// - gantt-component.vue
// - List.vue
// -> Move the wrapper including this class definition inside
.is-max-width-desktop .tasks .task {
diff --git a/src/types/DateISO.ts b/src/types/DateISO.ts
new file mode 100644
index 000000000..0db782318
--- /dev/null
+++ b/src/types/DateISO.ts
@@ -0,0 +1,7 @@
+/**
+ * Returns a date as a string value in ISO format.
+ * same format as `new Date().toISOString()`
+ */
+export type DateISO = T
+
+new Date().toISOString()
\ No newline at end of file
diff --git a/src/types/DateKebab.ts b/src/types/DateKebab.ts
new file mode 100644
index 000000000..bdc1808df
--- /dev/null
+++ b/src/types/DateKebab.ts
@@ -0,0 +1,4 @@
+/**
+* Date in Format 2022-12-10
+*/
+export type DateKebab = `${string}-${string}-${string}`
diff --git a/src/types/PartialWithId.ts b/src/types/PartialWithId.ts
new file mode 100644
index 000000000..3d72b8f40
--- /dev/null
+++ b/src/types/PartialWithId.ts
@@ -0,0 +1 @@
+export type PartialWithId = Pick & Omit, 'id'>
\ No newline at end of file
diff --git a/src/views/list/ListGantt.vue b/src/views/list/ListGantt.vue
index 12ab6e952..b032f74bb 100644
--- a/src/views/list/ListGantt.vue
+++ b/src/views/list/ListGantt.vue
@@ -1,47 +1,30 @@
-
+
-
-
- {{ $t('list.gantt.showTasksWithoutDates') }}
-
-
+
+
-
{{ $t('list.gantt.size') }}
+
{{ $t('list.gantt.range') }}
-
-
- {{ $t('list.gantt.default') }}
- {{ $t('list.gantt.month') }}
- {{ $t('list.gantt.day') }}
-
-
-
-
-
-
{{ $t('list.gantt.from') }}
-
-
-
-
{{ $t('list.gantt.to') }}
+
+
+ {{ $t('list.gantt.showTasksWithoutDates') }}
+
@@ -49,15 +32,15 @@
-
-
+
@@ -65,46 +48,92 @@
-
\ No newline at end of file
diff --git a/src/views/list/ListWrapper.vue b/src/views/list/ListWrapper.vue
index 274a7f664..b4306b8c6 100644
--- a/src/views/list/ListWrapper.vue
+++ b/src/views/list/ListWrapper.vue
@@ -61,7 +61,6 @@ import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
-import {useKanbanStore} from '@/stores/kanban'
const props = defineProps({
listId: {
@@ -77,7 +76,6 @@ const props = defineProps({
const route = useRoute()
const baseStore = useBaseStore()
-const kanbanStore = useKanbanStore()
const listStore = useListStore()
const listService = ref(new ListService())
const loadedListId = ref(0)
@@ -90,6 +88,7 @@ const currentList = computed(() => {
maxRight: null,
} : baseStore.currentList
})
+useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the list multiple times, even when navigating away from it.
@@ -98,62 +97,47 @@ const currentList = computed(() => {
// of it, most likely due to the rights not being properly populated.
watch(
() => props.listId,
- listId => loadList(listId),
+ // loadList
+ async (listIdToLoad: number) => {
+ const listData = {id: listIdToLoad}
+ saveListToHistory(listData)
+
+ // Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
+ // the currently loaded list has the right set.
+ if (
+ (
+ listIdToLoad === loadedListId.value ||
+ typeof listIdToLoad === 'undefined' ||
+ listIdToLoad === currentList.value.id
+ )
+ && typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
+ ) {
+ loadedListId.value = props.listId
+ return
+ }
+
+ console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
+
+ // Set the current list to the one we're about to load so that the title is already shown at the top
+ loadedListId.value = 0
+ const listFromStore = listStore.getListById(listData.id)
+ if (listFromStore !== null) {
+ baseStore.setBackground(null)
+ baseStore.setBlurHash(null)
+ baseStore.handleSetCurrentList({list: listFromStore})
+ }
+
+ // We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
+ const list = new ListModel(listData)
+ try {
+ const loadedList = await listService.value.get(list)
+ await baseStore.handleSetCurrentList({list: loadedList})
+ } finally {
+ loadedListId.value = props.listId
+ }
+ },
{immediate: true},
)
-
-useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
-
-async function loadList(listIdToLoad: number) {
- const listData = {id: listIdToLoad}
- saveListToHistory(listData)
-
- // This invalidates the loaded list at the kanban board which lets it reload its content when
- // switched to it. This ensures updates done to tasks in the gantt or list views are consistently
- // shown in all views while preventing reloads when closing a task popup.
- // We don't do this for the table view because that does not change tasks.
- // FIXME: remove this
- if (
- props.viewName === 'list.list' ||
- props.viewName === 'list.gantt'
- ) {
- kanbanStore.setListId(0)
- }
-
- // Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
- // the currently loaded list has the right set.
- if (
- (
- listIdToLoad === loadedListId.value ||
- typeof listIdToLoad === 'undefined' ||
- listIdToLoad === currentList.value.id
- )
- && typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
- ) {
- loadedListId.value = props.listId
- return
- }
-
- console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
-
- // Set the current list to the one we're about to load so that the title is already shown at the top
- loadedListId.value = 0
- const listFromStore = listStore.getListById(listData.id)
- if (listFromStore !== null) {
- baseStore.setBackground(null)
- baseStore.setBlurHash(null)
- baseStore.handleSetCurrentList({list: listFromStore})
- }
-
- // We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
- const list = new ListModel(listData)
- try {
- const loadedList = await listService.value.get(list)
- await baseStore.handleSetCurrentList({list: loadedList})
- } finally {
- loadedListId.value = props.listId
- }
-}