chore: move frontend files
This commit is contained in:
39
frontend/src/helpers/attachments.ts
Normal file
39
frontend/src/helpers/attachments.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import AttachmentModel from '@/models/attachment'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
|
||||
import AttachmentService from '@/services/attachment'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
export function uploadFile(taskId: number, file: File, onSuccess?: (url: string) => void) {
|
||||
const attachmentService = new AttachmentService()
|
||||
const files = [file]
|
||||
|
||||
return uploadFiles(attachmentService, taskId, files, onSuccess)
|
||||
}
|
||||
|
||||
export async function uploadFiles(
|
||||
attachmentService: AttachmentService,
|
||||
taskId: number,
|
||||
files: File[] | FileList,
|
||||
onSuccess?: (attachmentUrl: string) => void,
|
||||
) {
|
||||
const attachmentModel = new AttachmentModel({taskId})
|
||||
const response = await attachmentService.create(attachmentModel, files)
|
||||
console.debug(`Uploaded attachments for task ${taskId}, response was`, response)
|
||||
|
||||
response.success?.map((attachment: IAttachment) => {
|
||||
useTaskStore().addTaskAttachment({
|
||||
taskId,
|
||||
attachment,
|
||||
})
|
||||
onSuccess?.(generateAttachmentUrl(taskId, attachment.id))
|
||||
})
|
||||
|
||||
if (response.errors !== null) {
|
||||
throw Error(response.errors)
|
||||
}
|
||||
}
|
||||
|
||||
export function generateAttachmentUrl(taskId: number, attachmentId: number) {
|
||||
return `${window.API_URL}/tasks/${taskId}/attachments/${attachmentId}`
|
||||
}
|
51
frontend/src/helpers/auth.ts
Normal file
51
frontend/src/helpers/auth.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
||||
import type {AxiosResponse} from 'axios'
|
||||
|
||||
let savedToken: string | null = null
|
||||
|
||||
/**
|
||||
* Saves a token while optionally saving it to lacal storage. This is used when viewing a link share:
|
||||
* It enables viewing multiple link shares indipendently from each in multiple tabs other without overriding any other open ones.
|
||||
*/
|
||||
export const saveToken = (token: string, persist: boolean) => {
|
||||
savedToken = token
|
||||
if (persist) {
|
||||
localStorage.setItem('token', token)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a saved token. If there is one saved in memory it will use that before anything else.
|
||||
*/
|
||||
export const getToken = (): string | null => {
|
||||
if (savedToken !== null) {
|
||||
return savedToken
|
||||
}
|
||||
|
||||
savedToken = localStorage.getItem('token')
|
||||
return savedToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all tokens everywhere.
|
||||
*/
|
||||
export const removeToken = () => {
|
||||
savedToken = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
||||
*/
|
||||
export async function refreshToken(persist: boolean): Promise<AxiosResponse> {
|
||||
const HTTP = AuthenticatedHTTPFactory()
|
||||
try {
|
||||
const response = await HTTP.post('user/token')
|
||||
saveToken(response.data.token, persist)
|
||||
return response
|
||||
|
||||
} catch(e) {
|
||||
throw new Error('Error renewing token: ', { cause: e })
|
||||
}
|
||||
}
|
||||
|
18
frontend/src/helpers/calculateItemPosition.ts
Normal file
18
frontend/src/helpers/calculateItemPosition.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export const calculateItemPosition = (positionBefore: number | null, positionAfter: number | null): number => {
|
||||
if (positionBefore === null) {
|
||||
if (positionAfter === null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If there is no task after it, we just add 2^16 to the last position to have enough room in the future
|
||||
return positionAfter / 2
|
||||
}
|
||||
|
||||
// If there is no task after it, we just add 2^16 to the last position to have enough room in the future
|
||||
if (positionAfter === null) {
|
||||
return positionBefore + Math.pow(2, 16)
|
||||
}
|
||||
|
||||
// If we have both a task before and after it, we acually calculate the position
|
||||
return positionBefore + (positionAfter - positionBefore) / 2
|
||||
}
|
20
frontend/src/helpers/calculateTaskPosition.test.ts
Normal file
20
frontend/src/helpers/calculateTaskPosition.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import {it, expect} from 'vitest'
|
||||
|
||||
import {calculateItemPosition} from './calculateItemPosition'
|
||||
|
||||
it('should calculate the task position', () => {
|
||||
const result = calculateItemPosition(10, 100)
|
||||
expect(result).toBe(55)
|
||||
})
|
||||
it('should return 0 if no position was provided', () => {
|
||||
const result = calculateItemPosition(null, null)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
it('should calculate the task position for the first task', () => {
|
||||
const result = calculateItemPosition(null, 100)
|
||||
expect(result).toBe(50)
|
||||
})
|
||||
it('should calculate the task position for the last task', () => {
|
||||
const result = calculateItemPosition(10, null)
|
||||
expect(result).toBe(65546)
|
||||
})
|
7
frontend/src/helpers/canNestProjectDeeper.ts
Normal file
7
frontend/src/helpers/canNestProjectDeeper.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function canNestProjectDeeper(level: number) {
|
||||
if (level < 2) {
|
||||
return true
|
||||
}
|
||||
|
||||
return level >= 2 && window.PROJECT_INFINITE_NESTING_ENABLED
|
||||
}
|
82
frontend/src/helpers/case.ts
Normal file
82
frontend/src/helpers/case.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {camelCase} from 'camel-case'
|
||||
import {snakeCase} from 'snake-case'
|
||||
|
||||
/**
|
||||
* Transforms field names to camel case.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function objectToCamelCase(object: Record<string, any>) {
|
||||
|
||||
// When calling recursively, this can be called without being and object or array in which case we just return the value
|
||||
if (typeof object !== 'object') {
|
||||
return object
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const parsedObject: Record<string, any> = {}
|
||||
for (const m in object) {
|
||||
parsedObject[camelCase(m)] = object[m]
|
||||
|
||||
// Recursive processing
|
||||
// Prevent processing for some cases
|
||||
if (object[m] === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Call it again for arrays
|
||||
if (Array.isArray(object[m])) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parsedObject[camelCase(m)] = object[m].map((o: Record<string, any>) => objectToCamelCase(o))
|
||||
// Because typeof [] === 'object' is true for arrays, we leave the loop here to prevent converting arrays to objects.
|
||||
continue
|
||||
}
|
||||
|
||||
// Call it again for nested objects
|
||||
if (typeof object[m] === 'object') {
|
||||
parsedObject[camelCase(m)] = objectToCamelCase(object[m])
|
||||
}
|
||||
}
|
||||
return parsedObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms field names to snake case - used before making an api request.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function objectToSnakeCase(object: Record<string, any>) {
|
||||
|
||||
// When calling recursively, this can be called without being and object or array in which case we just return the value
|
||||
if (typeof object !== 'object') {
|
||||
return object
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const parsedObject: Record<string, any> = {}
|
||||
for (const m in object) {
|
||||
parsedObject[snakeCase(m)] = object[m]
|
||||
|
||||
// Recursive processing
|
||||
// Prevent processing for some cases
|
||||
if (
|
||||
object[m] === null ||
|
||||
(object[m] instanceof Date)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Call it again for arrays
|
||||
if (Array.isArray(object[m])) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parsedObject[snakeCase(m)] = object[m].map((o: Record<string, any>) => objectToSnakeCase(o))
|
||||
// Because typeof [] === 'object' is true for arrays, we leave the loop here to prevent converting arrays to objects.
|
||||
continue
|
||||
}
|
||||
|
||||
// Call it again for nested objects
|
||||
if (typeof object[m] === 'object') {
|
||||
parsedObject[snakeCase(m)] = objectToSnakeCase(object[m])
|
||||
}
|
||||
}
|
||||
|
||||
return parsedObject
|
||||
}
|
116
frontend/src/helpers/checkAndSetApiUrl.ts
Normal file
116
frontend/src/helpers/checkAndSetApiUrl.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const API_DEFAULT_PORT = '3456'
|
||||
|
||||
export const ERROR_NO_API_URL = 'noApiUrlProvided'
|
||||
|
||||
export class NoApiUrlProvidedError extends Error {
|
||||
constructor() {
|
||||
super()
|
||||
this.message = 'No API URL provided'
|
||||
this.name = 'NoApiUrlProvidedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidApiUrlProvidedError extends Error {
|
||||
constructor() {
|
||||
super()
|
||||
this.message = 'The provided API URL is invalid.'
|
||||
this.name = 'InvalidApiUrlProvidedError'
|
||||
}
|
||||
}
|
||||
|
||||
export const checkAndSetApiUrl = (url: string | undefined | null): Promise<string> => {
|
||||
if (url === '' || url === null || typeof url === 'undefined') {
|
||||
throw new NoApiUrlProvidedError()
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
url = window.location.host + url
|
||||
}
|
||||
|
||||
// Check if the url has a http prefix
|
||||
if (
|
||||
!url.startsWith('http://') &&
|
||||
!url.startsWith('https://')
|
||||
) {
|
||||
url = `${window.location.protocol}//${url}`
|
||||
}
|
||||
|
||||
let urlToCheck: URL
|
||||
try {
|
||||
urlToCheck = new URL(url)
|
||||
} catch (e) {
|
||||
throw new InvalidApiUrlProvidedError()
|
||||
}
|
||||
|
||||
const origUrlToCheck = urlToCheck
|
||||
|
||||
const oldUrl = window.API_URL
|
||||
window.API_URL = urlToCheck.toString()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const updateConfig = () => configStore.update()
|
||||
|
||||
// Check if the api is reachable at the provided url
|
||||
return updateConfig()
|
||||
.catch(e => {
|
||||
// Check if it is reachable at /api/v1 and http
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return updateConfig()
|
||||
}
|
||||
throw e
|
||||
})
|
||||
.catch(e => {
|
||||
// Check if it is reachable at /api/v1 and https
|
||||
urlToCheck.pathname = origUrlToCheck.pathname
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return updateConfig()
|
||||
}
|
||||
throw e
|
||||
})
|
||||
.catch(e => {
|
||||
// Check if it is reachable at port API_DEFAULT_PORT and https
|
||||
if (urlToCheck.port !== API_DEFAULT_PORT) {
|
||||
urlToCheck.port = API_DEFAULT_PORT
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return updateConfig()
|
||||
}
|
||||
throw e
|
||||
})
|
||||
.catch(e => {
|
||||
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1
|
||||
urlToCheck.pathname = origUrlToCheck.pathname
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return updateConfig()
|
||||
}
|
||||
throw e
|
||||
})
|
||||
.catch(e => {
|
||||
window.API_URL = oldUrl
|
||||
throw e
|
||||
})
|
||||
.then(success => {
|
||||
if (success) {
|
||||
localStorage.setItem('API_URL', window.API_URL)
|
||||
return window.API_URL
|
||||
}
|
||||
|
||||
throw new InvalidApiUrlProvidedError()
|
||||
})
|
||||
}
|
130
frontend/src/helpers/checklistFromText.test.ts
Normal file
130
frontend/src/helpers/checklistFromText.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {findCheckboxesInText, getChecklistStatistics} from './checklistFromText'
|
||||
|
||||
describe('Find checklists in text', () => {
|
||||
it('should find no checkbox', () => {
|
||||
const text: string = 'Lorem Ipsum'
|
||||
const checkboxes = findCheckboxesInText(text)
|
||||
|
||||
expect(checkboxes).toHaveLength(0)
|
||||
})
|
||||
it('should find multiple checkboxes', () => {
|
||||
const text: string = `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Task</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Another task</p>
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>subtask</p></div>
|
||||
</li>
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"
|
||||
checked="checked"><span></span></label>
|
||||
<div><p>done</p></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>`
|
||||
const checkboxes = findCheckboxesInText(text)
|
||||
|
||||
expect(checkboxes).toHaveLength(4)
|
||||
expect(checkboxes[0]).toBe(32)
|
||||
expect(checkboxes[1]).toBe(163)
|
||||
expect(checkboxes[2]).toBe(321)
|
||||
expect(checkboxes[3]).toBe(464)
|
||||
})
|
||||
it('should find one unchecked checkbox', () => {
|
||||
const text: string = `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Task</p></div>
|
||||
</li>
|
||||
</ul>`
|
||||
const checkboxes = findCheckboxesInText(text)
|
||||
|
||||
expect(checkboxes).toHaveLength(1)
|
||||
expect(checkboxes[0]).toBe(32)
|
||||
})
|
||||
it('should find one checked checkbox', () => {
|
||||
const text: string = `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Task</p></div>
|
||||
</li>
|
||||
</ul>`
|
||||
const checkboxes = findCheckboxesInText(text)
|
||||
|
||||
expect(checkboxes).toHaveLength(1)
|
||||
expect(checkboxes[0]).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Get Checklist Statistics in a Text', () => {
|
||||
it('should find no checkbox', () => {
|
||||
const text: string = 'Lorem Ipsum'
|
||||
const stats = getChecklistStatistics(text)
|
||||
|
||||
expect(stats.total).toBe(0)
|
||||
})
|
||||
it('should find one checkbox', () => {
|
||||
const text: string = `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Task</p></div>
|
||||
</li>
|
||||
</ul>`
|
||||
const stats = getChecklistStatistics(text)
|
||||
|
||||
expect(stats.total).toBe(1)
|
||||
expect(stats.checked).toBe(0)
|
||||
})
|
||||
it('should find one checked checkbox', () => {
|
||||
const text: string = `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Task</p></div>
|
||||
</li>
|
||||
</ul>`
|
||||
const stats = getChecklistStatistics(text)
|
||||
|
||||
expect(stats.total).toBe(1)
|
||||
expect(stats.checked).toBe(1)
|
||||
})
|
||||
it('should find multiple mixed and matched', () => {
|
||||
const text: string = `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Task</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Another task</p>
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>subtask</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>subtask 2</p></div>
|
||||
</li>
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"
|
||||
checked="checked"><span></span></label>
|
||||
<div><p>done</p></div>
|
||||
</li>
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"
|
||||
checked="checked"><span></span></label>
|
||||
<div><p>also done</p></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>`
|
||||
|
||||
const stats = getChecklistStatistics(text)
|
||||
|
||||
expect(stats.total).toBe(6)
|
||||
expect(stats.checked).toBe(2)
|
||||
})
|
||||
})
|
51
frontend/src/helpers/checklistFromText.ts
Normal file
51
frontend/src/helpers/checklistFromText.ts
Normal file
@ -0,0 +1,51 @@
|
||||
interface CheckboxStatistics {
|
||||
total: number
|
||||
checked: number
|
||||
}
|
||||
|
||||
interface MatchedCheckboxes {
|
||||
checked: number[]
|
||||
unchecked: number[]
|
||||
}
|
||||
|
||||
const getCheckboxesInText = (text: string): MatchedCheckboxes => {
|
||||
const regex = /data-checked="(true|false)"/g
|
||||
let match
|
||||
const checkboxes: MatchedCheckboxes = {
|
||||
checked: [],
|
||||
unchecked: [],
|
||||
}
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match[1] === 'true') {
|
||||
checkboxes.checked.push(match.index)
|
||||
} else {
|
||||
checkboxes.unchecked.push(match.index)
|
||||
}
|
||||
}
|
||||
|
||||
return checkboxes
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the indices where checkboxes start and end in the given text.
|
||||
*
|
||||
* @param text
|
||||
*/
|
||||
export const findCheckboxesInText = (text: string): number[] => {
|
||||
const checkboxes = getCheckboxesInText(text)
|
||||
|
||||
return [
|
||||
...checkboxes.checked,
|
||||
...checkboxes.unchecked,
|
||||
].sort((a, b) => a < b ? -1 : 1)
|
||||
}
|
||||
|
||||
export const getChecklistStatistics = (text: string): CheckboxStatistics => {
|
||||
const checkboxes = getCheckboxesInText(text)
|
||||
|
||||
return {
|
||||
total: checkboxes.checked.length + checkboxes.unchecked.length,
|
||||
checked: checkboxes.checked.length,
|
||||
}
|
||||
}
|
27
frontend/src/helpers/closeWhenClickedOutside.ts
Normal file
27
frontend/src/helpers/closeWhenClickedOutside.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Calls the close callback when a click happened outside of the rootElement.
|
||||
*
|
||||
* @param event The "click" event object.
|
||||
* @param rootElement
|
||||
* @param closeCallback A closure function to call when the click event happened outside of the rootElement.
|
||||
*/
|
||||
export const closeWhenClickedOutside = (event: MouseEvent, rootElement: HTMLElement, closeCallback: () => void) => {
|
||||
// We walk up the tree to see if any parent of the clicked element is the root element.
|
||||
// If it is not, we call the close callback. We're doing all this hassle to only call the
|
||||
// closing callback when a click happens outside of the rootElement.
|
||||
let parent = (event.target as HTMLElement)?.parentElement
|
||||
while (parent !== rootElement) {
|
||||
if (parent === null || parent.parentElement === null) {
|
||||
parent = null
|
||||
break
|
||||
}
|
||||
|
||||
parent = parent.parentElement
|
||||
}
|
||||
|
||||
if (parent === rootElement) {
|
||||
return
|
||||
}
|
||||
|
||||
closeCallback()
|
||||
}
|
13
frontend/src/helpers/color/colorFromHex.test.ts
Normal file
13
frontend/src/helpers/color/colorFromHex.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import {test, expect} from 'vitest'
|
||||
|
||||
import {colorFromHex} from './colorFromHex'
|
||||
|
||||
test('hex', () => {
|
||||
const color = '#ffffff'
|
||||
expect(colorFromHex(color)).toBe('ffffff')
|
||||
})
|
||||
|
||||
test('no hex', () => {
|
||||
const color = 'ffffff'
|
||||
expect(colorFromHex(color)).toBe('ffffff')
|
||||
})
|
13
frontend/src/helpers/color/colorFromHex.ts
Normal file
13
frontend/src/helpers/color/colorFromHex.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Returns the hex color code without the '#' if it has one.
|
||||
*
|
||||
* @param color
|
||||
* @returns {string}
|
||||
*/
|
||||
export function colorFromHex(color: string): string {
|
||||
if (color !== '' && color.substring(0, 1) === '#') {
|
||||
color = color.substring(1, 7)
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
18
frontend/src/helpers/color/colorIsDark.test.ts
Normal file
18
frontend/src/helpers/color/colorIsDark.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import {test, expect} from 'vitest'
|
||||
|
||||
import {colorIsDark} from './colorIsDark'
|
||||
|
||||
test('dark color', () => {
|
||||
const color = '#111111'
|
||||
expect(colorIsDark(color)).toBe(false)
|
||||
})
|
||||
|
||||
test('light color', () => {
|
||||
const color = '#DDDDDD'
|
||||
expect(colorIsDark(color)).toBe(true)
|
||||
})
|
||||
|
||||
test('default dark', () => {
|
||||
const color = ''
|
||||
expect(colorIsDark(color)).toBe(true)
|
||||
})
|
26
frontend/src/helpers/color/colorIsDark.ts
Normal file
26
frontend/src/helpers/color/colorIsDark.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export function colorIsDark(color: string | undefined) {
|
||||
if (typeof color === 'undefined') {
|
||||
return true // Defaults to dark
|
||||
}
|
||||
|
||||
if (color === '#' || color === '') {
|
||||
return true // Defaults to dark
|
||||
}
|
||||
|
||||
if (color.substring(0, 1) !== '#') {
|
||||
color = '#' + color
|
||||
}
|
||||
|
||||
const rgb = parseInt(color.substring(1, 7), 16) // convert rrggbb to decimal
|
||||
const r = (rgb >> 16) & 0xff // extract red
|
||||
const g = (rgb >> 8) & 0xff // extract green
|
||||
const b = (rgb >> 0) & 0xff // extract blue
|
||||
|
||||
// this is a quick and dirty implementation of the WCAG 3.0 APCA color contrast formula
|
||||
// see: https://gist.github.com/Myndex/e1025706436736166561d339fd667493#andys-shortcut-to-luminance--lightness
|
||||
const Ys = Math.pow(r/255.0,2.2) * 0.2126 +
|
||||
Math.pow(g/255.0,2.2) * 0.7152 +
|
||||
Math.pow(b/255.0,2.2) * 0.0722
|
||||
|
||||
return Math.pow(Ys,0.678) >= 0.5
|
||||
}
|
20
frontend/src/helpers/color/randomColor.ts
Normal file
20
frontend/src/helpers/color/randomColor.ts
Normal file
@ -0,0 +1,20 @@
|
||||
|
||||
const COLORS = [
|
||||
'#ffbe0b',
|
||||
'#fd8a09',
|
||||
'#fb5607',
|
||||
'#ff006e',
|
||||
'#efbdeb',
|
||||
'#8338ec',
|
||||
'#5f5ff6',
|
||||
'#3a86ff',
|
||||
'#4c91ff',
|
||||
'#0ead69',
|
||||
'#25be8b',
|
||||
'#073b4c',
|
||||
'#373f47',
|
||||
]
|
||||
|
||||
export function getRandomColorHex(): string {
|
||||
return COLORS[Math.floor(Math.random() * COLORS.length)]
|
||||
}
|
21
frontend/src/helpers/createAsyncComponent.ts
Normal file
21
frontend/src/helpers/createAsyncComponent.ts
Normal file
@ -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<T extends Component = {
|
||||
new (): ComponentPublicInstance;
|
||||
}>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
|
||||
if (typeof source === 'function') {
|
||||
source = { loader: source }
|
||||
}
|
||||
|
||||
return defineAsyncComponent({
|
||||
...source,
|
||||
loadingComponent: LoadingComponent,
|
||||
errorComponent: ErrorComponent,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
})
|
||||
}
|
7
frontend/src/helpers/downloadBlob.ts
Normal file
7
frontend/src/helpers/downloadBlob.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const downloadBlob = (url: string, filename: string) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', filename)
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
3
frontend/src/helpers/editorContentEmpty.ts
Normal file
3
frontend/src/helpers/editorContentEmpty.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isEditorContentEmpty(content: string): boolean {
|
||||
return content === '' || content === '<p></p>'
|
||||
}
|
36
frontend/src/helpers/fetcher.ts
Normal file
36
frontend/src/helpers/fetcher.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import axios from 'axios'
|
||||
import {getToken} from '@/helpers/auth'
|
||||
|
||||
export function HTTPFactory() {
|
||||
const instance = axios.create({baseURL: window.API_URL})
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
// by setting the baseURL fresh for every request
|
||||
// we make sure that it is never outdated in case it is updated
|
||||
config.baseURL = window.API_URL
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
export function AuthenticatedHTTPFactory() {
|
||||
const instance = HTTPFactory()
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Set the default auth header if we have a token
|
||||
const token = getToken()
|
||||
if (token !== null) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
15
frontend/src/helpers/flatpickrLanguage.ts
Normal file
15
frontend/src/helpers/flatpickrLanguage.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import FlatpickrLanguages from 'flatpickr/dist/l10n'
|
||||
import type { CustomLocale, key } from 'flatpickr/dist/types/locale'
|
||||
|
||||
export function getFlatpickrLanguage(): CustomLocale {
|
||||
const authStore = useAuthStore()
|
||||
const lang = authStore.settings.language
|
||||
const langPair = lang.split('-')
|
||||
let language = FlatpickrLanguages[lang === 'vi-vn' ? 'vn' : 'en']
|
||||
if (langPair.length > 0 && FlatpickrLanguages[langPair[0] as key] !== undefined) {
|
||||
language = FlatpickrLanguages[langPair[0] as key]
|
||||
}
|
||||
language.firstDayOfWeek = authStore.settings.weekStart ?? language.firstDayOfWeek
|
||||
return language
|
||||
}
|
31
frontend/src/helpers/getBlobFromBlurHash.ts
Normal file
31
frontend/src/helpers/getBlobFromBlurHash.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {decode} from 'blurhash'
|
||||
|
||||
export async function getBlobFromBlurHash(blurHash: string): Promise<Blob | null> {
|
||||
if (blurHash === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pixels = decode(blurHash, 32, 32)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 32
|
||||
canvas.height = 32
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const imageData = ctx.createImageData(32, 32)
|
||||
imageData.data.set(pixels)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(b => {
|
||||
if (b === null) {
|
||||
reject(b)
|
||||
return
|
||||
}
|
||||
|
||||
resolve(b)
|
||||
})
|
||||
})
|
||||
}
|
14
frontend/src/helpers/getFullBaseUrl.ts
Normal file
14
frontend/src/helpers/getFullBaseUrl.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Get full BASE_URL
|
||||
* - including path
|
||||
* - will always end with a trailing slash
|
||||
*/
|
||||
export function getFullBaseUrl() {
|
||||
// (1) The injected BASE_URL is declared from the `resolvedBase` that might miss a trailing slash...
|
||||
// see: https://github.com/vitejs/vite/blob/b35fe883fdc699ac1450882562872095abe9959b/packages/vite/src/node/config.ts#LL614C25-L614C25
|
||||
const rawBase = import.meta.env.BASE_URL
|
||||
// (2) so we readd a slash like done here
|
||||
// https://github.com/vitejs/vite/blob/b35fe883fdc699ac1450882562872095abe9959b/packages/vite/src/node/config.ts#L643
|
||||
// See this comment: https://github.com/vitejs/vite/pull/10723#issuecomment-1303627478
|
||||
return rawBase.endsWith('/') ? rawBase : rawBase + '/'
|
||||
}
|
18
frontend/src/helpers/getHumanSize.ts
Normal file
18
frontend/src/helpers/getHumanSize.ts
Normal file
@ -0,0 +1,18 @@
|
||||
const SIZES = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB',
|
||||
'TB',
|
||||
] as const
|
||||
|
||||
export function getHumanSize(inputSize: number) {
|
||||
let iterator = 0
|
||||
let size = inputSize
|
||||
while (size > 1024) {
|
||||
size /= 1024
|
||||
iterator++
|
||||
}
|
||||
|
||||
return Number(Math.round(Number(size + 'e2')) + 'e-2') + ' ' + SIZES[iterator]
|
||||
}
|
24
frontend/src/helpers/getInheritedBackgroundColor.ts
Normal file
24
frontend/src/helpers/getInheritedBackgroundColor.ts
Normal file
@ -0,0 +1,24 @@
|
||||
function getDefaultBackground() {
|
||||
const div = document.createElement('div')
|
||||
document.head.appendChild(div)
|
||||
const bg = window.getComputedStyle(div).backgroundColor
|
||||
document.head.removeChild(div)
|
||||
return bg
|
||||
}
|
||||
|
||||
// get default style for current browser
|
||||
const defaultStyle = getDefaultBackground() // typically "rgba(0, 0, 0, 0)"
|
||||
|
||||
// based on https://stackoverflow.com/a/62630563/15522256
|
||||
export function getInheritedBackgroundColor(el: HTMLElement): string {
|
||||
const backgroundColor = window.getComputedStyle(el).backgroundColor
|
||||
|
||||
if (backgroundColor !== defaultStyle) return backgroundColor
|
||||
|
||||
if (!el.parentElement) {
|
||||
// we reached the top parent el without getting an explicit color
|
||||
return defaultStyle
|
||||
}
|
||||
|
||||
return getInheritedBackgroundColor(el.parentElement)
|
||||
}
|
14
frontend/src/helpers/getProjectTitle.ts
Normal file
14
frontend/src/helpers/getProjectTitle.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import {i18n} from '@/i18n'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export function getProjectTitle(project: IProject) {
|
||||
if (project.id === -1) {
|
||||
return i18n.global.t('project.pseudo.favorites.title')
|
||||
}
|
||||
|
||||
if (project.title === 'Inbox') {
|
||||
return i18n.global.t('project.inboxTitle')
|
||||
}
|
||||
|
||||
return project.title
|
||||
}
|
31
frontend/src/helpers/hourToDaytime.test.ts
Normal file
31
frontend/src/helpers/hourToDaytime.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {hourToDaytime} from "./hourToDaytime"
|
||||
|
||||
function dateWithHour(hours: number): Date {
|
||||
const newDate = new Date()
|
||||
newDate.setHours(hours, 0, 0,0 )
|
||||
return newDate
|
||||
}
|
||||
|
||||
describe('Salutation', () => {
|
||||
it('shows the right salutation in the night', () => {
|
||||
const salutation = hourToDaytime(dateWithHour(4))
|
||||
expect(salutation).toBe('night')
|
||||
})
|
||||
it('shows the right salutation in the morning', () => {
|
||||
const salutation = hourToDaytime(dateWithHour(8))
|
||||
expect(salutation).toBe('morning')
|
||||
})
|
||||
it('shows the right salutation in the day', () => {
|
||||
const salutation = hourToDaytime(dateWithHour(13))
|
||||
expect(salutation).toBe('day')
|
||||
})
|
||||
it('shows the right salutation in the night', () => {
|
||||
const salutation = hourToDaytime(dateWithHour(20))
|
||||
expect(salutation).toBe('evening')
|
||||
})
|
||||
it('shows the right salutation in the night again', () => {
|
||||
const salutation = hourToDaytime(dateWithHour(23))
|
||||
expect(salutation).toBe('night')
|
||||
})
|
||||
})
|
14
frontend/src/helpers/hourToDaytime.ts
Normal file
14
frontend/src/helpers/hourToDaytime.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { Daytime } from '@/composables/useDaytimeSalutation'
|
||||
|
||||
export function hourToDaytime(now: Date): Daytime {
|
||||
const hours = now.getHours()
|
||||
|
||||
const daytimeMap = {
|
||||
night: hours < 5 || hours > 23,
|
||||
morning: hours < 11,
|
||||
day: hours < 18,
|
||||
evening: hours < 23,
|
||||
} as Record<Daytime, boolean>
|
||||
|
||||
return (Object.keys(daytimeMap) as Daytime[]).find((daytime) => daytimeMap[daytime]) || 'night'
|
||||
}
|
39
frontend/src/helpers/inputPrompt.ts
Normal file
39
frontend/src/helpers/inputPrompt.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import tippy from 'tippy.js'
|
||||
import {nextTick} from 'vue'
|
||||
import {eventToHotkeyString} from '@github/hotkey'
|
||||
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const id = 'link-input-' + createRandomID()
|
||||
|
||||
const linkPopup = tippy('body', {
|
||||
getReferenceClientRect: () => pos,
|
||||
appendTo: () => document.body,
|
||||
content: `<div><input class="input" placeholder="URL" id="${id}" value="${oldValue}"/></div>`,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'top-start',
|
||||
allowHTML: true,
|
||||
})
|
||||
|
||||
linkPopup[0].show()
|
||||
|
||||
nextTick(() => document.getElementById(id)?.focus())
|
||||
|
||||
document.getElementById(id)?.addEventListener('keydown', event => {
|
||||
const hotkeyString = eventToHotkeyString(event)
|
||||
if (hotkeyString !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
||||
const url = event.target.value
|
||||
|
||||
resolve(url)
|
||||
|
||||
linkPopup[0].hide()
|
||||
})
|
||||
|
||||
})
|
||||
}
|
10
frontend/src/helpers/isAppleDevice.ts
Normal file
10
frontend/src/helpers/isAppleDevice.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const isAppleDevice = (): boolean => {
|
||||
return navigator.userAgent.includes('Mac') || [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
'iPod Simulator',
|
||||
'iPad',
|
||||
'iPhone',
|
||||
'iPod',
|
||||
].includes(navigator.platform)
|
||||
}
|
6
frontend/src/helpers/isEmail.ts
Normal file
6
frontend/src/helpers/isEmail.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export function isEmail(email: string): boolean {
|
||||
const format = /^.+@.+$/
|
||||
const match = email.match(format)
|
||||
|
||||
return match === null ? false : match.length > 0
|
||||
}
|
11
frontend/src/helpers/isValidHttpUrl.ts
Normal file
11
frontend/src/helpers/isValidHttpUrl.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function isValidHttpUrl(urlToCheck: string): boolean {
|
||||
let url
|
||||
|
||||
try {
|
||||
url = new URL(urlToCheck)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
14
frontend/src/helpers/parseDateOrNull.ts
Normal file
14
frontend/src/helpers/parseDateOrNull.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Make date objects from timestamps
|
||||
*/
|
||||
export function parseDateOrNull(date: string | Date) {
|
||||
if (date instanceof Date) {
|
||||
return date
|
||||
}
|
||||
|
||||
if ((typeof date === 'string') && !date.startsWith('0001')) {
|
||||
return new Date(date)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
169
frontend/src/helpers/parseSubtasksViaIndention.test.ts
Normal file
169
frontend/src/helpers/parseSubtasksViaIndention.test.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
|
||||
import {PrefixMode} from '@/modules/parseTaskText'
|
||||
|
||||
describe('Parse Subtasks via Relation', () => {
|
||||
it('Should not return a parent for a single task', () => {
|
||||
const tasks = parseSubtasksViaIndention('single task', PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(1)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
})
|
||||
it('Should not return a parent for multiple tasks without indention', () => {
|
||||
const tasks = parseSubtasksViaIndention(`task one
|
||||
task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(2)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[1].parent).toBeNull()
|
||||
})
|
||||
it('Should return a parent for two tasks with indention', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(2)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
})
|
||||
it('Should return a parent for multiple subtasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task one
|
||||
sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub task two')
|
||||
expect(tasks[2].parent).to.eq('parent task')
|
||||
})
|
||||
it('Should work with multiple indention levels', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task
|
||||
sub sub task`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub sub task')
|
||||
expect(tasks[2].parent).to.eq('sub task')
|
||||
})
|
||||
it('Should work with multiple indention levels and multiple tasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task
|
||||
sub sub task one
|
||||
sub sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(4)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub sub task one')
|
||||
expect(tasks[2].parent).to.eq('sub task')
|
||||
expect(tasks[3].title).to.eq('sub sub task two')
|
||||
expect(tasks[3].parent).to.eq('sub task')
|
||||
})
|
||||
it('Should work with multiple indention levels and multiple tasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(`parent task
|
||||
sub task
|
||||
sub sub task one
|
||||
sub sub sub task
|
||||
sub sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(5)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub sub task one')
|
||||
expect(tasks[2].parent).to.eq('sub task')
|
||||
expect(tasks[3].title).to.eq('sub sub sub task')
|
||||
expect(tasks[3].parent).to.eq('sub sub task one')
|
||||
expect(tasks[4].title).to.eq('sub sub task two')
|
||||
expect(tasks[4].parent).to.eq('sub task')
|
||||
})
|
||||
it('Should return a parent for multiple subtasks with special stuff', () => {
|
||||
const tasks = parseSubtasksViaIndention(`* parent task
|
||||
* sub task one
|
||||
sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).to.eq('parent task')
|
||||
expect(tasks[2].title).to.eq('sub task two')
|
||||
expect(tasks[2].parent).to.eq('parent task')
|
||||
})
|
||||
it('Should not break when the first line is indented', () => {
|
||||
const tasks = parseSubtasksViaIndention(' single task', PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(1)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
})
|
||||
it('Should add the list of the parent task as list for all sub tasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(
|
||||
`parent task +list
|
||||
sub task 1
|
||||
sub task 2`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].project).to.eq('list')
|
||||
expect(tasks[1].project).to.eq('list')
|
||||
expect(tasks[2].project).to.eq('list')
|
||||
})
|
||||
it('Should clean the indention if there is indention on the first line', () => {
|
||||
const tasks = parseSubtasksViaIndention(
|
||||
` parent task
|
||||
sub task one
|
||||
sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(3)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).toBeNull()
|
||||
expect(tasks[2].title).to.eq('sub task two')
|
||||
expect(tasks[2].parent).to.eq('sub task one')
|
||||
})
|
||||
it('Should clean the indention if there is indention on the first line but not for subsequent tasks', () => {
|
||||
const tasks = parseSubtasksViaIndention(
|
||||
` parent task
|
||||
sub task one
|
||||
first level task one
|
||||
sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(4)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).toBeNull()
|
||||
expect(tasks[2].title).to.eq('first level task one')
|
||||
expect(tasks[2].parent).toBeNull()
|
||||
expect(tasks[3].title).to.eq('sub task two')
|
||||
expect(tasks[3].parent).to.eq('first level task one')
|
||||
})
|
||||
it('Should clean the indention if there is indention on the first line for subsequent tasks with less indention', () => {
|
||||
const tasks = parseSubtasksViaIndention(
|
||||
` parent task
|
||||
sub task one
|
||||
first level task one
|
||||
sub task two`, PrefixMode.Default)
|
||||
|
||||
expect(tasks).to.have.length(4)
|
||||
expect(tasks[0].parent).toBeNull()
|
||||
expect(tasks[0].title).to.eq('parent task')
|
||||
expect(tasks[1].title).to.eq('sub task one')
|
||||
expect(tasks[1].parent).toBeNull()
|
||||
expect(tasks[2].title).to.eq('first level task one')
|
||||
expect(tasks[2].parent).toBeNull()
|
||||
expect(tasks[3].title).to.eq('sub task two')
|
||||
expect(tasks[3].parent).to.eq('first level task one')
|
||||
})
|
||||
})
|
82
frontend/src/helpers/parseSubtasksViaIndention.ts
Normal file
82
frontend/src/helpers/parseSubtasksViaIndention.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {getProjectFromPrefix, PrefixMode} from '@/modules/parseTaskText'
|
||||
|
||||
export interface TaskWithParent {
|
||||
title: string,
|
||||
parent: string | null,
|
||||
project: string | null,
|
||||
}
|
||||
|
||||
function cleanupTitle(title: string) {
|
||||
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
|
||||
}
|
||||
|
||||
const spaceRegex = /^ */
|
||||
|
||||
/**
|
||||
* @param taskTitles should be multiple lines of task tiles with indention to declare their parent/subtask
|
||||
* relation between each other.
|
||||
*/
|
||||
export function parseSubtasksViaIndention(taskTitles: string, prefixMode: PrefixMode): TaskWithParent[] {
|
||||
let titles = taskTitles
|
||||
.split(/[\r\n]+/)
|
||||
.filter(t => t.replace(/\s/g, '').length > 0) // Remove titles which are empty or only contain spaces / tabs
|
||||
|
||||
if (titles.length == 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const spaceOnFirstLine = /^(\t| )+/
|
||||
const spaces = spaceOnFirstLine.exec(titles[0])
|
||||
if (spaces !== null) {
|
||||
let spacesToCut = spaces[0].length
|
||||
titles = titles.map(title => {
|
||||
const spacesOnThisLine = spaceOnFirstLine.exec(title)
|
||||
if (spacesOnThisLine === null) {
|
||||
// This means the current task title does not start with indention, but the very first one did
|
||||
// To prevent cutting actual task data we now need to update the number of spaces to cut
|
||||
spacesToCut = 0
|
||||
}
|
||||
if (spacesOnThisLine !== null && spacesOnThisLine[0].length < spacesToCut) {
|
||||
spacesToCut = spacesOnThisLine[0].length
|
||||
}
|
||||
return title.substring(spacesToCut)
|
||||
})
|
||||
}
|
||||
|
||||
return titles.map((title, index) => {
|
||||
const task: TaskWithParent = {
|
||||
title: cleanupTitle(title),
|
||||
parent: null,
|
||||
project: null,
|
||||
}
|
||||
|
||||
task.project = getProjectFromPrefix(task.title, prefixMode)
|
||||
|
||||
if (index === 0) {
|
||||
return task
|
||||
}
|
||||
|
||||
const matched = spaceRegex.exec(task.title)
|
||||
const matchedSpaces = matched ? matched[0].length : 0
|
||||
|
||||
if (matchedSpaces > 0) {
|
||||
// Go up the tree to find the first task with less indention than the current one
|
||||
let pi = 1
|
||||
let parentSpaces = 0
|
||||
do {
|
||||
task.parent = cleanupTitle(titles[index - pi])
|
||||
pi++
|
||||
const parentMatched = spaceRegex.exec(task.parent)
|
||||
parentSpaces = parentMatched ? parentMatched[0].length : 0
|
||||
} while (parentSpaces >= matchedSpaces)
|
||||
task.title = cleanupTitle(task.title.replace(spaceRegex, ''))
|
||||
task.parent = task.parent.replace(spaceRegex, '')
|
||||
if (task.project === null) {
|
||||
// This allows to specify a project once for the parent task and inherit it to all subtasks
|
||||
task.project = getProjectFromPrefix(task.parent, prefixMode)
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
})
|
||||
}
|
12
frontend/src/helpers/playPop.ts
Normal file
12
frontend/src/helpers/playPop.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import popSoundFile from '@/assets/audio/pop.mp3'
|
||||
|
||||
export const playSoundWhenDoneKey = 'playSoundWhenTaskDone'
|
||||
|
||||
export function playPopSound() {
|
||||
try {
|
||||
const popSound = new Audio(popSoundFile)
|
||||
popSound.play()
|
||||
} catch (e) {
|
||||
console.error('Could not play pop sound:', e)
|
||||
}
|
||||
}
|
100
frontend/src/helpers/projectView.ts
Normal file
100
frontend/src/helpers/projectView.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import type { RouteRecordName } from 'vue-router'
|
||||
import router from '@/router'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
export type ProjectRouteName = Extract<RouteRecordName, string>
|
||||
export type ProjectViewSettings = Record<
|
||||
IProject['id'],
|
||||
Extract<RouteRecordName, ProjectRouteName>
|
||||
>
|
||||
|
||||
const SETTINGS_KEY_PROJECT_VIEW = 'projectView'
|
||||
|
||||
// TODO: remove migration when releasing 1.0
|
||||
type ListViewSettings = ProjectViewSettings
|
||||
const SETTINGS_KEY_DEPRECATED_LIST_VIEW = 'listView'
|
||||
function migrateStoredProjectRouteSettings() {
|
||||
try {
|
||||
const listViewSettingsString = localStorage.getItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
|
||||
if (listViewSettingsString === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// A) the first version stored one setting for all lists in a string
|
||||
if (listViewSettingsString.startsWith('list.')) {
|
||||
const projectView = listViewSettingsString.replace('list.', 'project.')
|
||||
|
||||
if (!router.hasRoute(projectView)) {
|
||||
return
|
||||
}
|
||||
return projectView as RouteRecordName
|
||||
}
|
||||
|
||||
// B) the last version used a 'list.' prefix
|
||||
const listViewSettings: ListViewSettings = JSON.parse(listViewSettingsString)
|
||||
|
||||
const projectViewSettingEntries = Object.entries(listViewSettings).map(([id, value]) => {
|
||||
return [id, value.replace('list.', 'project.')]
|
||||
})
|
||||
const projectViewSettings = Object.fromEntries(projectViewSettingEntries)
|
||||
|
||||
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
|
||||
} catch(e) {
|
||||
//
|
||||
} finally {
|
||||
localStorage.removeItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current project view to local storage
|
||||
*/
|
||||
export function saveProjectView(projectId: IProject['id'], routeName: string) {
|
||||
if (routeName.includes('settings.')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
// We use local storage and not the store here to make it persistent across reloads.
|
||||
const savedProjectView = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
|
||||
let savedProjectViewSettings: ProjectViewSettings | false = false
|
||||
if (savedProjectView !== null) {
|
||||
savedProjectViewSettings = JSON.parse(savedProjectView) as ProjectViewSettings
|
||||
}
|
||||
|
||||
let projectViewSettings: ProjectViewSettings = {}
|
||||
if (savedProjectViewSettings) {
|
||||
projectViewSettings = savedProjectViewSettings
|
||||
}
|
||||
|
||||
projectViewSettings[projectId] = routeName
|
||||
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
|
||||
}
|
||||
|
||||
export const getProjectView = (projectId: IProject['id']) => {
|
||||
// TODO: remove migration when releasing 1.0
|
||||
const migratedProjectView = migrateStoredProjectRouteSettings()
|
||||
|
||||
if (migratedProjectView !== undefined && router.hasRoute(migratedProjectView)) {
|
||||
return migratedProjectView
|
||||
}
|
||||
|
||||
try {
|
||||
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
|
||||
if (!projectViewSettingsString) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
|
||||
if (!router.hasRoute(projectViewSettings[projectId])) {
|
||||
throw new Error()
|
||||
}
|
||||
return projectViewSettings[projectId]
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
}
|
5
frontend/src/helpers/randomId.ts
Normal file
5
frontend/src/helpers/randomId.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const DEFAULT_ID_LENGTH = 9
|
||||
|
||||
export function createRandomID(idLength = DEFAULT_ID_LENGTH) {
|
||||
return Math.random().toString(36).slice(2, idLength)
|
||||
}
|
26
frontend/src/helpers/redirectToProvider.ts
Normal file
26
frontend/src/helpers/redirectToProvider.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import type {IProvider} from '@/types/IProvider'
|
||||
import {parseURL} from 'ufo'
|
||||
|
||||
export function getRedirectUrlFromCurrentFrontendPath(provider: IProvider): string {
|
||||
// We're not using the redirect url provided by the server to allow redirects when using the electron app.
|
||||
// The implications are not quite clear yet hence the logic to pass in another redirect url still exists.
|
||||
const url = parseURL(window.location.href)
|
||||
return `${url.protocol}//${url.host}/auth/openid/${provider.key}`
|
||||
}
|
||||
|
||||
export const redirectToProvider = (provider: IProvider) => {
|
||||
|
||||
console.log({provider})
|
||||
|
||||
const redirectUrl = getRedirectUrlFromCurrentFrontendPath(provider)
|
||||
const state = createRandomID(24)
|
||||
localStorage.setItem('state', state)
|
||||
|
||||
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=openid email profile&state=${state}`
|
||||
}
|
||||
export const redirectToProviderOnLogout = (provider: IProvider) => {
|
||||
if (provider.logoutUrl.length > 0) {
|
||||
window.location.href = `${provider.logoutUrl}`
|
||||
}
|
||||
}
|
15
frontend/src/helpers/replaceAll.ts
Normal file
15
frontend/src/helpers/replaceAll.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* This function replaces all text, no matter the case.
|
||||
*
|
||||
* See https://stackoverflow.com/a/7313467/10924593
|
||||
*
|
||||
* @parma str
|
||||
* @param search
|
||||
* @param replace
|
||||
* @returns {*}
|
||||
*/
|
||||
export const replaceAll = (str: string, search: string, replace: string) => {
|
||||
const esc = search.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
|
||||
const reg = new RegExp(esc, 'ig')
|
||||
return str.replace(reg, replace)
|
||||
}
|
34
frontend/src/helpers/saveCollapsedBucketState.ts
Normal file
34
frontend/src/helpers/saveCollapsedBucketState.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type {IBucket} from '@/modelTypes/IBucket'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const key = 'collapsedBuckets'
|
||||
|
||||
export type CollapsedBuckets = {[id: IBucket['id']]: boolean}
|
||||
|
||||
function getAllState() {
|
||||
const saved = localStorage.getItem(key)
|
||||
return saved === null
|
||||
? {}
|
||||
: JSON.parse(saved)
|
||||
}
|
||||
|
||||
export const saveCollapsedBucketState = (
|
||||
projectId: IProject['id'],
|
||||
collapsedBuckets: CollapsedBuckets,
|
||||
) => {
|
||||
const state = getAllState()
|
||||
state[projectId] = collapsedBuckets
|
||||
for (const bucketId in state[projectId]) {
|
||||
if (!state[projectId][bucketId]) {
|
||||
delete state[projectId][bucketId]
|
||||
}
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(state))
|
||||
}
|
||||
|
||||
export function getCollapsedBucketState(projectId : IProject['id']) {
|
||||
const state = getAllState()
|
||||
return typeof state[projectId] !== 'undefined'
|
||||
? state[projectId]
|
||||
: {}
|
||||
}
|
22
frontend/src/helpers/saveLastVisited.ts
Normal file
22
frontend/src/helpers/saveLastVisited.ts
Normal file
@ -0,0 +1,22 @@
|
||||
const LAST_VISITED_KEY = 'lastVisited'
|
||||
|
||||
export const saveLastVisited = (name: string | undefined, params: object, query: object) => {
|
||||
if (typeof name === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(LAST_VISITED_KEY, JSON.stringify({name, params, query}))
|
||||
}
|
||||
|
||||
export const getLastVisited = () => {
|
||||
const lastVisited = localStorage.getItem(LAST_VISITED_KEY)
|
||||
if (lastVisited === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(lastVisited)
|
||||
}
|
||||
|
||||
export const clearLastVisited = () => {
|
||||
return localStorage.removeItem(LAST_VISITED_KEY)
|
||||
}
|
19
frontend/src/helpers/scrollIntoView.ts
Normal file
19
frontend/src/helpers/scrollIntoView.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function scrollIntoView(el: HTMLElement | null | undefined) {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const boundingRect = el.getBoundingClientRect()
|
||||
const scrollY = window.scrollY
|
||||
|
||||
if (
|
||||
boundingRect.top > (scrollY + window.innerHeight) ||
|
||||
boundingRect.top < scrollY
|
||||
) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
5
frontend/src/helpers/setTitle.ts
Normal file
5
frontend/src/helpers/setTitle.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function setTitle(title : undefined | string) {
|
||||
document.title = (typeof title === 'undefined' || title === '')
|
||||
? 'Vikunja'
|
||||
: `${title} | Vikunja`
|
||||
}
|
95
frontend/src/helpers/time/calculateDayInterval.test.ts
Normal file
95
frontend/src/helpers/time/calculateDayInterval.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import {test, expect} from 'vitest'
|
||||
|
||||
import {calculateDayInterval} from './calculateDayInterval'
|
||||
|
||||
const days = {
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
sunday: 0,
|
||||
} as Record<string, number>
|
||||
|
||||
for (const n in days) {
|
||||
test(`today on a ${n}`, () => {
|
||||
expect(calculateDayInterval('today', days[n])).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const n in days) {
|
||||
test(`tomorrow on a ${n}`, () => {
|
||||
expect(calculateDayInterval('tomorrow', days[n])).toBe(1)
|
||||
})
|
||||
}
|
||||
|
||||
const nextMonday = {
|
||||
monday: 0,
|
||||
tuesday: 6,
|
||||
wednesday: 5,
|
||||
thursday: 4,
|
||||
friday: 3,
|
||||
saturday: 2,
|
||||
sunday: 1,
|
||||
} as Record<string, number>
|
||||
|
||||
for (const n in nextMonday) {
|
||||
test(`next monday on a ${n}`, () => {
|
||||
expect(calculateDayInterval('nextMonday', days[n])).toBe(nextMonday[n])
|
||||
})
|
||||
}
|
||||
|
||||
const thisWeekend = {
|
||||
monday: 5,
|
||||
tuesday: 4,
|
||||
wednesday: 3,
|
||||
thursday: 2,
|
||||
friday: 1,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
} as Record<string, number>
|
||||
|
||||
for (const n in thisWeekend) {
|
||||
test(`this weekend on a ${n}`, () => {
|
||||
expect(calculateDayInterval('thisWeekend', days[n])).toBe(thisWeekend[n])
|
||||
})
|
||||
}
|
||||
|
||||
const laterThisWeek = {
|
||||
monday: 2,
|
||||
tuesday: 2,
|
||||
wednesday: 2,
|
||||
thursday: 2,
|
||||
friday: 0,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
} as Record<string, number>
|
||||
|
||||
for (const n in laterThisWeek) {
|
||||
test(`later this week on a ${n}`, () => {
|
||||
expect(calculateDayInterval('laterThisWeek', days[n])).toBe(laterThisWeek[n])
|
||||
})
|
||||
}
|
||||
|
||||
const laterNextWeek = {
|
||||
monday: 7 + 2,
|
||||
tuesday: 7 + 2,
|
||||
wednesday: 7 + 2,
|
||||
thursday: 7 + 2,
|
||||
friday: 7 + 0,
|
||||
saturday: 7 + 0,
|
||||
sunday: 7 + 0,
|
||||
} as Record<string, number>
|
||||
|
||||
for (const n in laterNextWeek) {
|
||||
test(`later next week on a ${n} (this week)`, () => {
|
||||
expect(calculateDayInterval('laterNextWeek', days[n])).toBe(laterNextWeek[n])
|
||||
})
|
||||
}
|
||||
|
||||
for (const n in days) {
|
||||
test(`next week on a ${n}`, () => {
|
||||
expect(calculateDayInterval('nextWeek', days[n])).toBe(7)
|
||||
})
|
||||
}
|
28
frontend/src/helpers/time/calculateDayInterval.ts
Normal file
28
frontend/src/helpers/time/calculateDayInterval.ts
Normal file
@ -0,0 +1,28 @@
|
||||
type Day<T extends number = number> = T
|
||||
|
||||
export function calculateDayInterval(dateString: string, currentDay = (new Date().getDay())): Day {
|
||||
switch (dateString) {
|
||||
case 'today':
|
||||
return 0
|
||||
case 'tomorrow':
|
||||
return 1
|
||||
case 'nextMonday':
|
||||
// Monday is 1, so we calculate the distance to the next 1
|
||||
return (currentDay + (8 - currentDay * 2)) % 7
|
||||
case 'thisWeekend':
|
||||
// Saturday is 6 so we calculate the distance to the next 6
|
||||
return (6 - currentDay) % 6
|
||||
case 'laterThisWeek':
|
||||
if (currentDay === 5 || currentDay === 6 || currentDay === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 2
|
||||
case 'laterNextWeek':
|
||||
return calculateDayInterval('laterThisWeek', currentDay) + 7
|
||||
case 'nextWeek':
|
||||
return 7
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
24
frontend/src/helpers/time/calculateNearestHours.ts
Normal file
24
frontend/src/helpers/time/calculateNearestHours.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export function calculateNearestHours(currentDate: Date = new Date()): number {
|
||||
if (currentDate.getHours() <= 9 || currentDate.getHours() > 21) {
|
||||
return 9
|
||||
}
|
||||
|
||||
if (currentDate.getHours() <= 12) {
|
||||
return 12
|
||||
}
|
||||
|
||||
if (currentDate.getHours() <= 15) {
|
||||
return 15
|
||||
}
|
||||
|
||||
if (currentDate.getHours() <= 18) {
|
||||
return 18
|
||||
}
|
||||
|
||||
if (currentDate.getHours() <= 21) {
|
||||
return 21
|
||||
}
|
||||
|
||||
// Same case as in the first if, will never be called
|
||||
return 9
|
||||
}
|
92
frontend/src/helpers/time/calculateNearestTime.test.ts
Normal file
92
frontend/src/helpers/time/calculateNearestTime.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import {test, expect} from 'vitest'
|
||||
|
||||
import {calculateNearestHours} from './calculateNearestHours'
|
||||
|
||||
test('5:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(5)
|
||||
expect(calculateNearestHours(date)).toBe(9)
|
||||
})
|
||||
|
||||
test('7:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(7)
|
||||
expect(calculateNearestHours(date)).toBe(9)
|
||||
})
|
||||
|
||||
test('7:41', () => {
|
||||
const date = new Date()
|
||||
date.setHours(7)
|
||||
date.setMinutes(41)
|
||||
expect(calculateNearestHours(date)).toBe(9)
|
||||
})
|
||||
|
||||
test('9:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(9)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(9)
|
||||
})
|
||||
|
||||
test('10:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(10)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(12)
|
||||
})
|
||||
|
||||
test('12:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(12)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(12)
|
||||
})
|
||||
|
||||
test('13:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(13)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(15)
|
||||
})
|
||||
|
||||
test('15:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(15)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(15)
|
||||
})
|
||||
|
||||
test('16:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(16)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(18)
|
||||
})
|
||||
|
||||
test('18:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(18)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(18)
|
||||
})
|
||||
|
||||
test('19:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(19)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(21)
|
||||
})
|
||||
|
||||
test('22:00', () => {
|
||||
const date = new Date()
|
||||
date.setHours(22)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(9)
|
||||
})
|
||||
|
||||
test('22:40', () => {
|
||||
const date = new Date()
|
||||
date.setHours(22)
|
||||
date.setMinutes(0)
|
||||
expect(calculateNearestHours(date)).toBe(9)
|
||||
})
|
15
frontend/src/helpers/time/createDateFromString.test.ts
Normal file
15
frontend/src/helpers/time/createDateFromString.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {test, expect} from 'vitest'
|
||||
|
||||
import {createDateFromString} from './createDateFromString'
|
||||
|
||||
test('YYYY-MM-DD HH:MM', () => {
|
||||
const dateString = '2021-02-06 12:00'
|
||||
const date = createDateFromString(dateString)
|
||||
expect(date).toBeInstanceOf(Date)
|
||||
expect(date.getDate()).toBe(6)
|
||||
expect(date.getMonth()).toBe(1)
|
||||
expect(date.getFullYear()).toBe(2021)
|
||||
expect(date.getHours()).toBe(12)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
expect(date.getSeconds()).toBe(0)
|
||||
})
|
19
frontend/src/helpers/time/createDateFromString.ts
Normal file
19
frontend/src/helpers/time/createDateFromString.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Returns a new date from any format in a way that all browsers, especially safari, can understand.
|
||||
*
|
||||
* @see https://kolaente.dev/vikunja/frontend/issues/207
|
||||
*
|
||||
* @param dateString
|
||||
* @returns {Date}
|
||||
*/
|
||||
export function createDateFromString(dateString: string | Date) {
|
||||
if (dateString instanceof Date) {
|
||||
return dateString
|
||||
}
|
||||
|
||||
if (dateString.includes('-')) {
|
||||
dateString = dateString.replace(/-/g, '/')
|
||||
}
|
||||
|
||||
return new Date(dateString)
|
||||
}
|
68
frontend/src/helpers/time/formatDate.ts
Normal file
68
frontend/src/helpers/time/formatDate.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {format, formatDistanceToNow} from 'date-fns'
|
||||
|
||||
// FIXME: support all locales and load dynamically
|
||||
import {enGB, de, fr, ru} from 'date-fns/locale'
|
||||
|
||||
import {i18n} from '@/i18n'
|
||||
import {createSharedComposable, type MaybeRef} from '@vueuse/core'
|
||||
import {computed, unref} from 'vue'
|
||||
|
||||
const locales = {en: enGB, de, ch: de, fr, ru}
|
||||
|
||||
export function dateIsValid(date: Date | null) {
|
||||
if (date === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return date instanceof Date && !isNaN(date)
|
||||
}
|
||||
|
||||
export const formatDate = (date, f, locale = i18n.global.t('date.locale')) => {
|
||||
if (!dateIsValid(date)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
date = createDateFromString(date)
|
||||
|
||||
return date ? format(date, f, {locale: locales[locale]}) : ''
|
||||
}
|
||||
|
||||
export function formatDateLong(date) {
|
||||
return formatDate(date, 'PPPPpppp')
|
||||
}
|
||||
|
||||
export function formatDateShort(date) {
|
||||
return formatDate(date, 'PPpp')
|
||||
}
|
||||
|
||||
export const formatDateSince = (date) => {
|
||||
if (!dateIsValid(date)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
date = createDateFromString(date)
|
||||
|
||||
return formatDistanceToNow(date, {
|
||||
locale: locales[i18n.global.t('date.locale')],
|
||||
addSuffix: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatISO(date) {
|
||||
return date ? new Date(date).toISOString() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Because `Intl.DateTimeFormat` is expensive to instatiate we try to reuse it as often as possible,
|
||||
* by creating a shared composable.
|
||||
*/
|
||||
export const useDateTimeFormatter = createSharedComposable((options?: MaybeRef<Intl.DateTimeFormatOptions>) => {
|
||||
return computed(() => new Intl.DateTimeFormat(i18n.global.locale.value, unref(options)))
|
||||
})
|
||||
|
||||
export function useWeekDayFromDate() {
|
||||
const dateTimeFormatter = useDateTimeFormatter({weekday: 'short'})
|
||||
|
||||
return computed(() => (date: Date) => dateTimeFormatter.value.format(date))
|
||||
}
|
5
frontend/src/helpers/time/getNextWeekDate.ts
Normal file
5
frontend/src/helpers/time/getNextWeekDate.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import {MILLISECONDS_A_WEEK} from '@/constants/date'
|
||||
|
||||
export function getNextWeekDate(): Date {
|
||||
return new Date((new Date()).getTime() + MILLISECONDS_A_WEEK)
|
||||
}
|
16
frontend/src/helpers/time/isoToKebabDate.ts
Normal file
16
frontend/src/helpers/time/isoToKebabDate.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
|
||||
// ✅ Format a date to YYYY-MM-DD (or any other format)
|
||||
function padTo2Digits(num: number) {
|
||||
return num.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
export function isoToKebabDate(isoDate: DateISO) {
|
||||
const date = new Date(isoDate)
|
||||
return [
|
||||
date.getFullYear(),
|
||||
padTo2Digits(date.getMonth() + 1), // January is 0, but we want it to be 1
|
||||
padTo2Digits(date.getDate()),
|
||||
].join('-') as DateKebab
|
||||
}
|
5
frontend/src/helpers/time/parseBooleanProp.ts
Normal file
5
frontend/src/helpers/time/parseBooleanProp.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function parseBooleanProp(booleanProp: string | undefined) {
|
||||
return (booleanProp === 'false' || booleanProp === '0')
|
||||
? false
|
||||
: Boolean(booleanProp)
|
||||
}
|
357
frontend/src/helpers/time/parseDate.ts
Normal file
357
frontend/src/helpers/time/parseDate.ts
Normal file
@ -0,0 +1,357 @@
|
||||
import {calculateDayInterval} from './calculateDayInterval'
|
||||
import {calculateNearestHours} from './calculateNearestHours'
|
||||
import {replaceAll} from '../replaceAll'
|
||||
|
||||
interface dateParseResult {
|
||||
newText: string,
|
||||
date: Date | null,
|
||||
}
|
||||
|
||||
interface dateFoundResult {
|
||||
foundText: string | null,
|
||||
date: Date | null,
|
||||
}
|
||||
|
||||
const monthsRegexGroup = '(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)'
|
||||
|
||||
function matchesDateExpr(text: string, dateExpr: string): boolean {
|
||||
return text.match(new RegExp('(^| )' + dateExpr, 'gi')) !== null
|
||||
}
|
||||
|
||||
export const parseDate = (text: string, now: Date = new Date()): dateParseResult => {
|
||||
if (matchesDateExpr(text, 'today')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('today')), 'today')
|
||||
}
|
||||
if (matchesDateExpr(text, 'tomorrow')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('tomorrow')), 'tomorrow')
|
||||
}
|
||||
if (matchesDateExpr(text, 'next monday')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('nextMonday')), 'next monday')
|
||||
}
|
||||
if (matchesDateExpr(text, 'this weekend')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('thisWeekend')), 'this weekend')
|
||||
}
|
||||
if (matchesDateExpr(text, 'later this week')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('laterThisWeek')), 'later this week')
|
||||
}
|
||||
if (matchesDateExpr(text, 'later next week')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('laterNextWeek')), 'later next week')
|
||||
}
|
||||
if (matchesDateExpr(text, 'next week')) {
|
||||
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('nextWeek')), 'next week')
|
||||
}
|
||||
if (matchesDateExpr(text, 'next month')) {
|
||||
const date: Date = new Date()
|
||||
date.setDate(1)
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
date.setHours(calculateNearestHours(date))
|
||||
date.setMinutes(0)
|
||||
date.setSeconds(0)
|
||||
|
||||
return addTimeToDate(text, date, 'next month')
|
||||
}
|
||||
if (matchesDateExpr(text, 'end of month')) {
|
||||
const curDate: Date = new Date()
|
||||
const date: Date = new Date(curDate.getFullYear(), curDate.getMonth() + 1, 0)
|
||||
date.setHours(calculateNearestHours(date))
|
||||
date.setMinutes(0)
|
||||
date.setSeconds(0)
|
||||
|
||||
return addTimeToDate(text, date, 'end of month')
|
||||
}
|
||||
|
||||
let parsed = getDateFromWeekday(text)
|
||||
if (parsed.date !== null) {
|
||||
return addTimeToDate(text, parsed.date, parsed.foundText)
|
||||
}
|
||||
|
||||
parsed = getDayFromText(text)
|
||||
if (parsed.date !== null) {
|
||||
const month = getMonthFromText(text, parsed.date)
|
||||
return addTimeToDate(month.newText, month.date, parsed.foundText)
|
||||
}
|
||||
|
||||
parsed = getDateFromTextIn(text, now)
|
||||
if (parsed.date !== null) {
|
||||
return addTimeToDate(text, parsed.date, parsed.foundText)
|
||||
}
|
||||
|
||||
parsed = getDateFromText(text)
|
||||
|
||||
if (parsed.date === null) {
|
||||
return {
|
||||
newText: replaceAll(text, parsed.foundText, ''),
|
||||
date: parsed.date,
|
||||
}
|
||||
}
|
||||
|
||||
return addTimeToDate(text, parsed.date, parsed.foundText)
|
||||
}
|
||||
|
||||
const addTimeToDate = (text: string, date: Date, previousMatch: string | null): dateParseResult => {
|
||||
previousMatch = previousMatch?.trim() || ''
|
||||
text = replaceAll(text, previousMatch, '')
|
||||
if (previousMatch === null) {
|
||||
return {
|
||||
newText: text,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
const timeRegex = ' (at|@) ([0-9][0-9]?(:[0-9][0-9]?)?( ?(a|p)m)?)'
|
||||
const matcher = new RegExp(timeRegex, 'ig')
|
||||
const results = matcher.exec(text)
|
||||
|
||||
if (results !== null) {
|
||||
const time = results[2]
|
||||
const parts = time.split(':')
|
||||
let hours = parseInt(parts[0])
|
||||
let minutes = 0
|
||||
if (time.endsWith('pm')) {
|
||||
hours += 12
|
||||
}
|
||||
if (parts.length > 1) {
|
||||
minutes = parseInt(parts[1])
|
||||
}
|
||||
|
||||
date.setHours(hours)
|
||||
date.setMinutes(minutes)
|
||||
date.setSeconds(0)
|
||||
}
|
||||
|
||||
const replace = results !== null ? results[0] : previousMatch
|
||||
return {
|
||||
newText: replaceAll(text, replace, '').trim(),
|
||||
date: date,
|
||||
}
|
||||
}
|
||||
|
||||
export const getDateFromText = (text: string, now: Date = new Date()) => {
|
||||
const fullDateRegex = /(^| )([0-9][0-9]?\/[0-9][0-9]?\/[0-9][0-9]([0-9][0-9])?|[0-9][0-9][0-9][0-9]\/[0-9][0-9]?\/[0-9][0-9]?|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?)/ig
|
||||
|
||||
// 1. Try parsing the text as a "usual" date, like 2021-06-24 or 06/24/2021
|
||||
let results: string[] | null = fullDateRegex.exec(text)
|
||||
let result: string | null = results === null ? null : results[0]
|
||||
let foundText: string | null = result
|
||||
let containsYear = true
|
||||
if (result === null) {
|
||||
// 2. Try parsing the date as something like "jan 21" or "21 jan"
|
||||
const monthRegex = new RegExp(`(^| )(${monthsRegexGroup} [0-9][0-9]?|[0-9][0-9]? ${monthsRegexGroup})`, 'ig')
|
||||
results = monthRegex.exec(text)
|
||||
result = results === null ? null : `${results[0]} ${now.getFullYear()}`.trim()
|
||||
foundText = results === null ? '' : results[0].trim()
|
||||
containsYear = false
|
||||
|
||||
if (result === null) {
|
||||
// 3. Try parsing the date as "27/01" or "01/27"
|
||||
const monthNumericRegex = /(^| )([0-9][0-9]?\/[0-9][0-9]?)/ig
|
||||
results = monthNumericRegex.exec(text)
|
||||
|
||||
// Put the year before or after the date, depending on what works
|
||||
result = results === null ? null : `${now.getFullYear()}/${results[0]}`
|
||||
if (result === null) {
|
||||
return {
|
||||
foundText,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
foundText = results === null ? '' : results[0]
|
||||
if (result === null || isNaN(new Date(result).getTime())) {
|
||||
result = results === null ? null : `${results[0]}/${now.getFullYear()}`
|
||||
}
|
||||
if (result === null || (isNaN(new Date(result).getTime()) && foundText !== '')) {
|
||||
const parts = foundText.split('/')
|
||||
result = `${parts[1]}/${parts[0]}/${now.getFullYear()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
return {
|
||||
foundText,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
const date = new Date(result)
|
||||
if (isNaN(date.getTime())) {
|
||||
return {
|
||||
foundText,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!containsYear && date < now) {
|
||||
date.setFullYear(date.getFullYear() + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
foundText,
|
||||
date,
|
||||
}
|
||||
}
|
||||
|
||||
export const getDateFromTextIn = (text: string, now: Date = new Date()) => {
|
||||
const regex = /(in [0-9]+ (hours?|days?|weeks?|months?))/ig
|
||||
const results = regex.exec(text)
|
||||
if (results === null) {
|
||||
return {
|
||||
foundText: '',
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
const foundText: string = results[0]
|
||||
const date = new Date(now)
|
||||
const parts = foundText.split(' ')
|
||||
switch (parts[2]) {
|
||||
case 'hours':
|
||||
case 'hour':
|
||||
date.setHours(date.getHours() + parseInt(parts[1]))
|
||||
break
|
||||
case 'days':
|
||||
case 'day':
|
||||
date.setDate(date.getDate() + parseInt(parts[1]))
|
||||
break
|
||||
case 'weeks':
|
||||
case 'week':
|
||||
date.setDate(date.getDate() + parseInt(parts[1]) * 7)
|
||||
break
|
||||
case 'months':
|
||||
case 'month':
|
||||
date.setMonth(date.getMonth() + parseInt(parts[1]))
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
foundText,
|
||||
date,
|
||||
}
|
||||
}
|
||||
|
||||
const getDateFromWeekday = (text: string): dateFoundResult => {
|
||||
const matcher = /(^| )(next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
|
||||
const results: string[] | null = matcher.exec(text.toLowerCase()) // The i modifier does not seem to work.
|
||||
if (results === null) {
|
||||
return {
|
||||
foundText: null,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
const date: Date = new Date()
|
||||
const currentDay: number = date.getDay()
|
||||
let day = 0
|
||||
|
||||
switch (results[3]) {
|
||||
case 'mon':
|
||||
case 'monday':
|
||||
day = 1
|
||||
break
|
||||
case 'tue':
|
||||
case 'tuesday':
|
||||
day = 2
|
||||
break
|
||||
case 'wed':
|
||||
case 'wednesday':
|
||||
day = 3
|
||||
break
|
||||
case 'thu':
|
||||
case 'thursday':
|
||||
day = 4
|
||||
break
|
||||
case 'fri':
|
||||
case 'friday':
|
||||
day = 5
|
||||
break
|
||||
case 'sat':
|
||||
case 'saturday':
|
||||
day = 6
|
||||
break
|
||||
case 'sun':
|
||||
case 'sunday':
|
||||
day = 0
|
||||
break
|
||||
default:
|
||||
return {
|
||||
foundText: null,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
const distance: number = (day + 7 - currentDay) % 7
|
||||
date.setDate(date.getDate() + distance)
|
||||
|
||||
// This a space at the end of the found text to not break parsing suffix strings like "at 14:00" in cases where the
|
||||
// matched string comes with a space at the end (last part of the regex).
|
||||
let foundText = results[0]
|
||||
if (foundText.endsWith(' ')) {
|
||||
foundText = foundText.slice(0, foundText.length - 1)
|
||||
}
|
||||
|
||||
return {
|
||||
foundText: foundText,
|
||||
date: date,
|
||||
}
|
||||
}
|
||||
|
||||
const getDayFromText = (text: string) => {
|
||||
const matcher = /(^| )(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)($| )/ig
|
||||
const results = matcher.exec(text)
|
||||
if (results === null) {
|
||||
return {
|
||||
foundText: null,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const date = new Date(now)
|
||||
const day = parseInt(results[0])
|
||||
date.setDate(day)
|
||||
|
||||
// If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
|
||||
// setting the day to 31 will "overflow" the date to the next month, but the first.
|
||||
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
|
||||
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
|
||||
while (date < now) {
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
}
|
||||
|
||||
if (date.getDate() !== day) {
|
||||
date.setDate(day)
|
||||
}
|
||||
|
||||
return {
|
||||
foundText: results[0],
|
||||
date: date,
|
||||
}
|
||||
}
|
||||
|
||||
const getMonthFromText = (text: string, date: Date) => {
|
||||
const matcher = new RegExp(monthsRegexGroup, 'ig')
|
||||
const results = matcher.exec(text)
|
||||
|
||||
if (results === null) {
|
||||
return {
|
||||
newText: text,
|
||||
date,
|
||||
}
|
||||
}
|
||||
|
||||
const fullDate = new Date(`${results[0]} 1 ${(new Date()).getFullYear()}`)
|
||||
date.setMonth(fullDate.getMonth())
|
||||
return {
|
||||
newText: replaceAll(text, results[0], ''),
|
||||
date,
|
||||
}
|
||||
}
|
||||
|
||||
const getDateFromInterval = (interval: number): Date => {
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
newDate.setHours(calculateNearestHours(newDate), 0, 0)
|
||||
|
||||
return newDate
|
||||
}
|
15
frontend/src/helpers/time/parseDateOrString.ts
Normal file
15
frontend/src/helpers/time/parseDateOrString.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export function parseDateOrString(rawValue: string | undefined | null, fallback: unknown): (unknown | string | Date) {
|
||||
if (rawValue === null || typeof rawValue === 'undefined') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if (rawValue.toLowerCase().includes('now') || rawValue.toLowerCase().includes('||')) {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
const d = new Date(rawValue)
|
||||
|
||||
return !isNaN(+d)
|
||||
? d
|
||||
: rawValue
|
||||
}
|
30
frontend/src/helpers/time/parseDateProp.ts
Normal file
30
frontend/src/helpers/time/parseDateProp.ts
Normal file
@ -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 - 1, date).toISOString() as DateISO
|
||||
} catch(e) {
|
||||
// ignore nonsense route queries
|
||||
return
|
||||
}
|
||||
}
|
7
frontend/src/helpers/time/parseKebabDate.ts
Normal file
7
frontend/src/helpers/time/parseKebabDate.ts
Normal file
@ -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())
|
||||
}
|
44
frontend/src/helpers/time/period.ts
Normal file
44
frontend/src/helpers/time/period.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {
|
||||
SECONDS_A_DAY,
|
||||
SECONDS_A_HOUR,
|
||||
SECONDS_A_MINUTE,
|
||||
SECONDS_A_WEEK,
|
||||
} from '@/constants/date'
|
||||
|
||||
export type PeriodUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years'
|
||||
|
||||
/**
|
||||
* Convert time period given as seconds to days, hour, minutes, seconds
|
||||
*/
|
||||
export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: number } {
|
||||
if (seconds % SECONDS_A_DAY === 0) {
|
||||
if (seconds % SECONDS_A_WEEK === 0) {
|
||||
return {unit: 'weeks', amount: seconds / SECONDS_A_WEEK}
|
||||
} else {
|
||||
return {unit: 'days', amount: seconds / SECONDS_A_DAY}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
unit: 'hours',
|
||||
amount: seconds / SECONDS_A_HOUR,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert time period of days, hour, minutes, seconds to duration in seconds
|
||||
*/
|
||||
export function periodToSeconds(period: number, unit: PeriodUnit): number {
|
||||
switch (unit) {
|
||||
case 'minutes':
|
||||
return period * SECONDS_A_MINUTE
|
||||
case 'hours':
|
||||
return period * SECONDS_A_HOUR
|
||||
case 'days':
|
||||
return period * SECONDS_A_DAY
|
||||
case 'weeks':
|
||||
return period * SECONDS_A_WEEK
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
30
frontend/src/helpers/utils.ts
Normal file
30
frontend/src/helpers/utils.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export function findIndexById<T extends {id: string | number}>(array : T[], id : string | number) {
|
||||
return array.findIndex(({id: currentId}) => currentId === id)
|
||||
}
|
||||
|
||||
export function findById<T extends {id: string | number}>(array : T[], id : string | number) {
|
||||
return array.find(({id: currentId}) => currentId === id)
|
||||
}
|
||||
|
||||
interface ObjectWithId {
|
||||
id: number
|
||||
}
|
||||
|
||||
export function includesById(array: ObjectWithId[], id: string | number) {
|
||||
return array.some(({id: currentId}) => currentId === id)
|
||||
}
|
||||
|
||||
// https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_isnil
|
||||
export function isNil(value: unknown) {
|
||||
return value == null
|
||||
}
|
||||
|
||||
export function omitBy(obj: Record<string, unknown>, check: (value: unknown) => boolean) {
|
||||
if (isNil(obj)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, value]) => !check(value)),
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user