import { computedAsync, useNow } from '@vueuse/core' import { formatDistanceToNow, formatISO, parseISO } from 'date-fns' import DateFnEnUsLocale from 'date-fns/locale/en-US' import DOMPurify from 'dompurify' import { computed, ComputedGetter, ComputedRef, MaybeRefOrGetter, readonly, Ref, ref, shallowReadonly, shallowRef, toRef, toValue, useSlots, watch, watchEffect, } from 'vue' import { useI18n } from '@/i18n' import { SteeringUser } from '@/stores/auth' const dateFnLocales = { de: () => import('date-fns/locale/de'), } export function computedIter<T>(fn: ComputedGetter<Iterable<T>>): ComputedRef<T[]> { return computed(() => Array.from(fn())) } export const useId = (() => { let _id = 0 return function useId(prefix = 'component') { return readonly(ref(`${prefix}-${_id++}`)) } })() export function useAsyncFunction<F extends (...args: never[]) => Promise<unknown>>(fn: F) { const isProcessing = ref(false) const error = ref<Error | undefined>() async function wrapper(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> { isProcessing.value = true error.value = undefined try { return (await fn(...args)) as Awaited<ReturnType<F>> } catch (e) { if (e instanceof Error) { error.value = e } else { error.value = new Error(String(e)) } throw error.value } finally { isProcessing.value = false } } return { fn: wrapper, error, isProcessing } } type UseFormattedISODateOptions = { representation?: 'date' | 'time' | 'complete' | undefined stripOffset?: boolean | undefined } export function useFormattedISODate( date: Ref<Date>, defaultValue?: MaybeRefOrGetter<Date>, options?: UseFormattedISODateOptions | undefined, ): ComputedRef<string> export function useFormattedISODate( date: Ref<Date | null | undefined>, defaultValue?: MaybeRefOrGetter<Date>, options?: UseFormattedISODateOptions | undefined, ): ComputedRef<string | undefined> export function useFormattedISODate( date: Ref<Date | null | undefined>, defaultValue?: MaybeRefOrGetter<Date> | undefined, options?: UseFormattedISODateOptions | undefined, ) { const offsetPattern = /(Z|\+\d\d:\d\d)$/ function maybeStripOffset(isoDate: string) { return options?.stripOffset ? isoDate.replace(offsetPattern, '') : isoDate } function maybeAddOffset(isoDate: string, currentDate: Date | undefined) { if (!options?.stripOffset) return isoDate const match = isoDate.match(offsetPattern) if (match) return isoDate const _currentDate = currentDate ?? new Date() const offset = (formatISO(_currentDate).match(offsetPattern) as RegExpMatchArray)[0] return isoDate + offset } return computed({ get() { return date.value ? maybeStripOffset( formatISO(date.value, { representation: options?.representation ?? 'complete' }), ) : null }, set(dateValue: string | null | undefined) { const _defaultValue = toValue(defaultValue) const offsetRefDate = date.value ?? _defaultValue date.value = dateValue ? parseISO(maybeAddOffset(dateValue, offsetRefDate)) : _defaultValue ?? null }, }) } export function getClosestSlot(slotDurationMinutes: number, date?: Date): Date { date = date ?? new Date() const slotDurationMillis = slotDurationMinutes * 60 * 1000 const slots = Math.floor(date.getTime() / slotDurationMillis) const closestSlotTimestamp = slots * slotDurationMillis return new Date(closestSlotTimestamp) } export function getNextAvailableSlot(slotDurationMinutes: number, date?: Date): Date { date = date ?? new Date() const slotDurationMillis = slotDurationMinutes * 60 * 1000 const slots = Math.ceil(date.getTime() / slotDurationMillis) const nextSlotTimestamp = slots * slotDurationMillis return new Date(nextSlotTimestamp) } export function ensureDate(date: Date | string) { return typeof date === 'string' ? parseISO(date) : date } export function stripSecondsFromTimeString(time: string) { return /\d+:\d+:\d+$/.test(time) ? time.replace(/:\d+$/, '') : time } export function calculateDurationSeconds(start: Date | string, end: Date | string): number { return Math.abs(ensureDate(end).getTime() - ensureDate(start).getTime()) / 1000 } export function sanitizeHTML( html: string, preset?: 'inline-noninteractive' | 'safe-html' | 'strip', ): string { const inlineElements = [ 'b', 'big', 'i', 'small', 'tt', 'abbr', 'acronym', 'cite', 'code', 'dfn', 'em', 'kbd', 'strong', 'samp', 'var', 'br', 'q', 's', 'span', 'sub', 'sup', ] if (preset === 'inline-noninteractive') { return DOMPurify.sanitize(html, { ALLOWED_TAGS: inlineElements, ALLOWED_ATTR: ['style', 'title'], }) } if (preset === 'safe-html') { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }) } return DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }) } export function secondsToDurationString(seconds: number): string { const isNegative = seconds < 0 const _seconds = Math.abs(Math.round(seconds)) if (_seconds === 0) return `0s` const h = Math.floor(_seconds / 3600) const m = Math.floor((_seconds % 3600) / 60) const s = Math.round(_seconds % 60) const representation = `${h ? h + 'h ' : ''}${m ? m + 'min ' : ''}${s ? s + 's' : ''}`.trim() return isNegative ? `-${representation}` : representation } export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } export function mapToDomain( value: number, inputDomain: [number, number], outputDomain: [number, number], ) { const [x1, y1] = inputDomain const [x2, y2] = outputDomain value = clamp(value, x1, y1) return ((value - x1) * (y2 - x2)) / (y1 - x1) + x2 } export function asyncWritableComputed<T>( initialValue: T, config: { get: () => Promise<T> set: (value: T) => Promise<void> | void }, ) { const data = shallowRef(initialValue) watchEffect(async () => { data.value = await config.get() }) return computed<T>({ get: () => data.value, set: (value: T) => { config.set(value) }, }) } export function matchesSearch(sourceString: string, searchString: string) { const source = sourceString.toLowerCase() const terms = searchString .toLowerCase() .split(/\s+/) .map((s) => s.trim()) .filter((s) => !!s) for (const term of terms) { if (!source.includes(term)) return false } return true } export function computedDebounced<T>( value: Ref<T> | (() => T), delayBySeconds: MaybeRefOrGetter<number> | ((newValue: T, oldValue: T) => number), ) { const debouncedValue = ref<T>(toValue(value)) as Ref<T> let timer: ReturnType<typeof setTimeout> | undefined watch(toRef(value), (newValue, oldValue) => { if (timer) clearTimeout(timer) const delay = typeof delayBySeconds === 'function' ? delayBySeconds(newValue, oldValue) : toValue(delayBySeconds) if (delay === 0) { debouncedValue.value = newValue } else { timer = setTimeout(() => { debouncedValue.value = newValue }, delay * 1000) } }) return shallowReadonly(debouncedValue) } export function useDateFnLocale(language: MaybeRefOrGetter<string>) { return computedAsync(async () => { const localeString = toValue(language) as keyof typeof dateFnLocales let locale: Locale = DateFnEnUsLocale if (localeString in dateFnLocales) { locale = (await dateFnLocales[localeString]()).default } return locale }, DateFnEnUsLocale) } export function useRelativeDistanceToNow( date: MaybeRefOrGetter<Date | string | null | undefined>, options: { includeSeconds?: boolean; addSuffix?: boolean } = {}, ) { const { locale: appLocale } = useI18n() const dateFnLocale = useDateFnLocale(appLocale) return computedAsync(async () => { const dateValue = toValue(date) if (!dateValue) return '' return formatDistanceToNow(ensureDate(dateValue), { locale: dateFnLocale.value, ...options }) }, '') } export function usePersonName(user: MaybeRefOrGetter<SteeringUser>) { return computed(() => { const _user = toValue(user) const firstName = (_user?.firstName ?? '').trim() const lastName = (_user.lastName ?? '').trim() const username = _user.username.trim() const name = [firstName, lastName].join(' ') const initials = firstName && lastName ? [firstName[0], lastName[0]] : lastName ? [lastName[0]] : firstName ? [firstName[0]] : [username[0]] return { name, username, initials: initials.join('').toUpperCase(), } }) } export function useIsToday( date: MaybeRefOrGetter<Date | string>, today?: MaybeRefOrGetter<Date | string>, ) { const datetime = computed(() => ensureDate(toValue(date))) const now = typeof today === 'undefined' ? useNow({ interval: 60 * 1000 }) : computed(() => ensureDate(toValue(today))) return computed(() => { return ( now.value.getDate() === datetime.value.getDate() && now.value.getMonth() === datetime.value.getMonth() && now.value.getFullYear() === datetime.value.getFullYear() ) }) } export function areSetsEqual<T extends string | number | symbol>( firstIterable: Iterable<T>, secondIterable: Iterable<T>, ): boolean { const set1 = new Set(firstIterable) const set2 = new Set(secondIterable) if (set1.size !== set2.size) return false for (const item of set1) { if (!set2.has(item)) return false } return true } export function getFilenameFromURL(url: string, defaultValue?: string) { const basename = (url.split(/[\\/]/).pop() as string).trim() return !basename && defaultValue ? defaultValue : basename } function arrayPadLeft<T>(array: T[], maxLength: number, fillValue: T): T[] { return Array(Math.max(0, maxLength - array.length)) .fill(fillValue) .concat(array) } export function parseTime(time: string): number { let [days, hours, minutes, seconds] = Array(4).fill(0) if (time.includes(':')) { ;[days, hours, minutes, seconds] = arrayPadLeft<number | string>(time.split(':'), 4, 0) } else { // parses strings like: "3m32", "3 h 2s ", "9d2h3min1" const format = /^\s*(?:(?<days>\d+)\s*d)?\s*(?:(?<hours>\d+)\s*h)?\s*(?:(?<minutes>\d+)\s*m(?:in)?)?\s*(?:(?<seconds>\d+)\s*s?)?\s*$/ ;({ days, hours, minutes, seconds } = time.match(format)?.groups ?? {}) } return ( parseInt(days || 0) * 24 * 60 * 60 + parseInt(hours || 0) * 60 * 60 + parseInt(minutes || 0) * 60 + parseInt(seconds || 0) ) } export function useForeignSlotNames() { const slots = useSlots() return computed(() => { return Object.entries(slots) .filter(([, slot]) => slot && slot.name === '') .map(([key]) => key) }) } export function humanJoin(iterable: Iterable<string>, glue: string, lastGlue: string) { const array = Array.from(iterable) if (array.length === 0) return '' if (array.length === 1) return array[0] if (array.length === 2) return array.join(lastGlue) const lastValue = array.pop() as string return `${array.join(glue)}${lastGlue}${lastValue}` } export function useQuery( query: MaybeRefOrGetter<Record<string, string | number | null | undefined>>, ) { return computed(() => { const _query: Record<string, string> = {} for (const [key, value] of Object.entries(toValue(query))) { if (value === null || value === undefined) continue _query[key] = value.toString() } return new URLSearchParams(_query) }) }