Skip to content
Snippets Groups Projects
util.ts 8.79 KiB
Newer Older
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,
  shallowReadonly,
  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)
  async function wrapper(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> {
    isProcessing.value = true
      return (await fn(...args)) as Awaited<ReturnType<F>>
    } finally {
      isProcessing.value = false
export function useFormattedISODate(date: Ref<Date>) {
  return computed({
    get() {
      return formatISO(date.value, { representation: 'date' })
    },
    set(dateValue: string) {
      date.value = parseISO(dateValue)
    },
  })
}

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