{"version":3,"file":"app-b7e1f368.f584a1446b2fc034d810.bundle.js","mappings":"AAEA","sources":["webpack://lernplattform-admin-frontend/../common/utils/DomUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/DownloadUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/EventTimeFrameUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/ExamMediaUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/FormulaUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/FullscreenUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/FunctionUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/HttpRequestUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/LearnGoalUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/LectureQuizUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/LectureUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/LevelUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/LocaleUtils.ts","webpack://lernplattform-admin-frontend/../common/utils/LogUtils.ts"],"sourcesContent":["import { Disposable } from 'aurelia-framework';\n\nexport class DomUtils {\n  /**\n   * create an event object to fire\n   *\n   * shamelessly stolen from:\n   * https://ilikekillnerds.com/2015/08/aurelia-custom-elements-custom-callback-events-tutorial/\n   */\n  public static createEvent<T>(name: string, bubbles: boolean, detail?: T): CustomEvent<T> {\n    return new CustomEvent<T>(name, {\n      detail,\n      bubbles,\n      cancelable: true,\n    });\n  }\n\n  public static fireEvent<T extends Record<string, any>>(element: HTMLElement | Element, options: FireEventOptions<T>): CustomEvent<T> {\n    const event = this.createEvent(options.name, !!options.bubbles, options.detail);\n    element.dispatchEvent(event);\n    return event;\n  }\n\n  public static passThroughEvent<T extends Record<string, any>>(element: HTMLElement | Element, event: Event & { detail: T }): CustomEvent<T> {\n    // eslint-disable-next-line no-console\n    if(event.bubbles) console.warn('a bubbling event is passed through - this may cause event listeners to execute twice');\n    return DomUtils.fireEvent(element, {\n      name: event.type,\n      bubbles: event.bubbles,\n      detail: event.detail,\n    });\n  }\n\n  public static getLabelTextWidth(text: string, required?: boolean): number {\n    const label = document.createElement('label');\n    label.classList.add('mdc-floating-label');\n    label.style.transform = 'scale(.75)';\n    label.style.opacity = '0';\n    label.style.visibility = 'hidden';\n    document.body.appendChild(label);\n    label.innerHTML = `${text}${required ? '*' : ''}`;\n    const { width } = label.getBoundingClientRect();\n    label.remove();\n    return width;\n  }\n\n  public static extractChangeEventValueAsNumber(event: Event): number {\n    const target = event.target as HTMLInputElement | HTMLSelectElement;\n    const extractedValue = parseInt(target.value, 10);\n\n    return extractedValue;\n  }\n\n  public static extractChangeEventValueAsString(event: Event): string {\n    const target = event.target as HTMLInputElement | HTMLSelectElement;\n    return target.value;\n  }\n\n  /**\n   * @returns `true`, if any parent element along the way to the root has the given dataAttribute\\\n   *          `false` otherwise\n   */\n  public static isInsideElementWithDataAttribute(element: HTMLElement, attributeName: string): boolean {\n    return this.isInsideElementMatchingCriterion(element, e => !!e.dataset[attributeName]);\n  }\n\n  /**\n   * @returns `true`, if the callback returns true for any parent element along the way to the root\\\n   *          `false` otherwise\n   */\n  public static isInsideElementMatchingCriterion(element: HTMLElement, callback: (currentElement: HTMLElement) => boolean): boolean {\n    let currentElement: Node | null = element;\n\n    while(currentElement) {\n      if(currentElement instanceof HTMLElement && callback(currentElement)) {\n        return true;\n      }\n\n      currentElement = currentElement.parentNode;\n    }\n\n    return false;\n  }\n\n  public static getComputedSize(element: HTMLElement): [number | null, number | null] {\n    const computedStyle = window.getComputedStyle(element);\n\n    return [\n      DomUtils.parseComputedNumberValue(computedStyle.width),\n      DomUtils.parseComputedNumberValue(computedStyle.height),\n    ];\n  }\n\n  /**\n   * Applies an attribute to the element and returns a disposable that removes it again\n   */\n  public static applyAttributeModification(element: Element, name: string, value: string, {\n    force = true,\n    restoreOnDispose = true,\n  }: {\n    /**\n     * `true`: always apply\n     * `false`: only if attribute is not yet set\n     */\n    force?: boolean;\n    /**\n     * `true`: restore original value on dispose\n     * `false`: remove attribute on dispose\n     */\n    restoreOnDispose?: boolean;\n  } = {}): Disposable {\n    if(force || !element.hasAttribute(name)) {\n      const originalValue = element.getAttribute(name);\n      element.setAttribute(name, value);\n\n      return {\n        dispose: () => {\n          if(element.getAttribute(name) === value) {\n            if(originalValue == null || !restoreOnDispose) {\n              element.removeAttribute(name);\n            } else {\n              element.setAttribute(name, originalValue);\n            }\n          }\n        },\n      };\n    }\n\n    return { dispose: () => void 0 }; // NOP disposable\n  }\n\n  private static parseComputedNumberValue(value: string): number | null {\n    if(value === '') return null;\n    return parseFloat(value.slice(0, -2));\n  }\n}\n\nexport type FireEventOptions<T> = {\n  name: string;\n  bubbles?: boolean;\n  detail?: T;\n};\n","import { AppUtils } from './AppUtils';\nimport filetypemime from 'magic-bytes.js';\n\nexport class DownloadUtils {\n  public static async createDownloadFromResponse({\n    response,\n    filename,\n  }: Omit<IDownloadOptions, 'url'> & {\n    response: Response;\n  }): Promise<void> {\n    const csvBlob = await response.blob();\n    await this.download({\n      url: URL.createObjectURL(csvBlob),\n      filename,\n      blank: false,\n      prefetch: false,\n    });\n  }\n\n  public static async download({\n    url,\n    filename,\n    blank = true,\n    prefetch = true,\n    mimeType,\n  }: IDownloadOptions & {\n    /**\n     * if true, file will be downloaded internally and the blob offered as the actual download.\n     * this enables more control over the download behavior, e.g. triggering a download dialog instead of opening supported files in the browser\n     */\n    prefetch?: boolean;\n  }): Promise<void> {\n    const sanitizedFilename = this.sanitizeFilename(filename, !AppUtils.isAndroidApp);\n    if(AppUtils.isMobileApp) {\n      const { cordova } = window;\n      if(AppUtils.isAndroidApp) {\n        try {\n          const guessedMimeType = mimeType ?? await this.guessMimeTypeOfUrl(url);\n          await new Promise((resolve, reject) => window.DownloadManagerAndroid.enqueue(\n            { url, fileName: sanitizedFilename, mimeType: guessedMimeType }, resolve, reject,\n          ));\n        } catch(err) {\n          console.error(err);\n          cordova.InAppBrowser.open(url, '_system');\n        }\n      }\n      else if(AppUtils.isIOSApp) {\n        cordova.InAppBrowser.open(url, '_system');\n      }\n      else {\n        this.downloadInApp({ url, filename: sanitizedFilename, mimeType });\n      }\n    } else if(prefetch) {\n      this.downloadWithPrefetch({ url, filename: sanitizedFilename, blank });\n    } else {\n      this.directDownload({ url, filename: sanitizedFilename, blank });\n    }\n  }\n\n  private static async downloadWithPrefetch({\n    url,\n    filename,\n    blank = true,\n  }: IDownloadOptions): Promise<void> {\n    try {\n      const response = await fetch(url, { method: 'GET' });\n      DownloadUtils.createDownloadFromResponse({ response, filename });\n    } catch{\n      // eslint-disable-next-line no-console\n      console.warn('Failed to pre-download, falling back to direct download');\n      this.directDownload({ url, filename, blank });\n    }\n  }\n\n  private static directDownload({\n    url,\n    filename,\n    blank = false,\n  }: IDownloadOptions): void {\n    const link = document.createElement('a');\n    link.href = url;\n    link.download = filename ?? '';\n    if(blank && !AppUtils.isElectronApp) link.target = '_blank';\n    link.click();\n    link.remove();\n  }\n\n  public static splitFileName(\n    filename: string,\n  ): [string, string] {\n    const parts = filename.split('.');\n    if(parts.length === 1) return [parts[0], ''];\n    return [parts.slice(0, -1).join('.'), `.${parts.slice(-1)[0]}`];\n  }\n\n  public static async getFreeFileName(\n    filename: string,\n    exists: (filename: string) => Promise<boolean>,\n  ): Promise<string> {\n    const [prefix, suffix] = this.splitFileName(filename);\n    let count = 0;\n    let newName = filename;\n    while(await exists(newName)) {\n      count += 1;\n      newName = `${prefix} (${count})${suffix}`;\n    }\n    return newName;\n  }\n\n  /**\n   * Strips prefixed timestamps and the first underscore.\n   * Replaces spaces with Unicode escape sequence to avoid path problems on iOS.\n   */\n  public static sanitizeFilename(filename: string | null | undefined, replaceSpaces = true): string | null | undefined {\n    if(filename == null) return filename;\n    // strip timestamp\n    let newFilename = filename?.replace(/^[0-9]+_/, '');\n    if(!replaceSpaces) return newFilename;\n    // replace spaces\n    newFilename = newFilename.replace(/ /g, '\\\\u0020');\n    return newFilename;\n  }\n\n  private static async guessMimeTypeOfBlob(blob: Blob): Promise<string | undefined> {\n    const buf = await blob.slice(0, 256).arrayBuffer();\n    const bytes = new Uint8Array(buf);\n    const res = filetypemime(Array.from(bytes));\n    return res[0]?.mime;\n  }\n\n  private static async guessMimeTypeOfUrl(url: string, maxSize = 4100, msTimeout = 5000): Promise<string | undefined>\n  {\n    const controller = new AbortController();\n    const timeout = setTimeout(() => controller.abort(), msTimeout);\n\n    const response = await fetch(url, { method: 'GET', signal: controller.signal });\n    if(!response.ok || response.body == null) return undefined;\n    const reader = response.body.getReader();\n\n    let size = 0;\n    const chunks = [];\n    for(;;) {\n      const { done, value } = await reader.read();\n      if(size > maxSize || done)\n      {\n        controller.abort();\n        break;\n      }\n      size += value.length;\n      chunks.push(value);\n    }\n    clearTimeout(timeout);\n\n    const bytes = new Uint8Array(size);\n    let offset = 0;\n    for(const chunk of chunks)\n    {\n      bytes.set(chunk, offset);\n      offset += chunk.length;\n    }\n    const res = filetypemime(Array.from(bytes));\n    return res[0]?.mime;\n  }\n\n  private static async downloadInApp({\n    url,\n    filename,\n    mimeType,\n  }: IDownloadOptions): Promise<void> {\n\n    const { cordova } = window;\n\n    if(AppUtils.isAndroidApp && window.device.sdkVersion < 30) {\n      console.error('Ancient android version');\n      return;\n    }\n\n    cordova.plugins.notification.local.schedule({ channel: 'downloadInfo', id: 42, smallIcon: 'res://notification_icon', title: `Downloading ${filename}…`, text: `Downloading ${filename}…`, sound: false, vibrate: false });\n\n    let dataBlob: Blob = new Blob();\n    try {\n      const res = await fetch(url, { method: 'GET' });\n      const blob = await res.blob(); \n      if(!blob) throw new Error('Failed to fetch data');\n      dataBlob = blob;\n    } catch(err) {\n      console.error(err);\n      cordova.plugins.notification.local.cancel([42]);\n      return;\n    }\n\n    const guessedType = mimeType ?? (await this.guessMimeTypeOfBlob(dataBlob));\n\n    if(AppUtils.isAndroidApp) {\n      try {\n        const reader = new FileReader();\n        const promise = new Promise(resolve => {\n          reader.onload = _ => {\n            const dataURL = reader.result as string;\n            resolve(dataURL.slice(dataURL.indexOf(',') + 1));\n          };\n        });\n        reader.readAsDataURL(dataBlob);\n        const payload = await promise;\n\n        await new Promise((resolve, reject) => cordova.plugins.luckyDownloads.saveAndOpen(\n          payload, guessedType, filename, AppUtils.getAppName() ?? '', resolve, reject,\n        ));\n      } catch(err) {\n        console.error(err);\n      }\n      cordova.plugins.notification.local.cancel([42]);\n      return;\n    }\n\n    const dirEntry = await new Promise<any>((resolve, reject) => window.resolveLocalFileSystemURL(cordova.file.documentsDirectory, resolve, reject));\n    const fileEntry = <any>(await new Promise((resolve, reject) => dirEntry.getFile(filename, { create: true, exclusive: false }, resolve, reject)));\n\n    fileEntry.createWriter(function(fileWriter: any) {\n      fileWriter.onwriteend = function() {\n        cordova.plugins.fileOpener2.showOpenWithDialog(\n          decodeURI(fileEntry.nativeURL),\n          guessedType,\n          {\n            error: (err: any) => { cordova.plugins.notification.local.cancel([42]); console.error(err); },\n            success: () => { cordova.plugins.notification.local.cancel([42]); },\n          },\n        );\n      };\n      fileWriter.onerror = console.error;\n      fileWriter.write(dataBlob);\n    });\n  }\n}\n\nexport interface IDownloadOptions {\n  url: string;\n  /** default filename for download */\n  filename: string | undefined | null;\n  /** Open in _blank */\n  blank?: boolean;\n  /** Mime type */\n  mimeType?: string;\n}\n","import Moment from 'moment';\n\nexport class EventTimeFrameUtils {\n  private constructor() {}\n\n  public static getTimeFrameParams(eventStart: string | Date, eventEnd: string | Date): IEventTimeFrameParams {\n    const dateStart = Moment(eventStart);\n    const dateEnd = Moment(eventEnd);\n\n    return {\n      dateAndTimeStart: dateStart.format('L, LT'),\n      dateAndTimeEnd: `${dateEnd.format('LT')}`,\n      timeStart: `${dateStart.format('LT')}`,\n      timeEnd: `${dateEnd.format('LT')}`,\n      date: dateStart.format('L'),\n      today: dateStart.isSame(Moment(), 'day'),\n    };\n  }\n}\n\nexport interface IEventTimeFrameParams {\n  date: string;\n  dateAndTimeEnd: string;\n  dateAndTimeStart: string;\n  timeEnd: string;\n  timeStart: string;\n  today: boolean;\n}\n","import { DomUtils } from 'common/utils/DomUtils';\n\nexport interface IOpenImageOverlayEventOptions {\n  blinkImgSrc?: string | null;\n  imgSrc: string;\n}\n\nexport class ExamMediaUtils {\n  public static blinkImageTimeout = 600;\n\n  public static emitOpenImageOverlayEvent(element: HTMLElement, options: IOpenImageOverlayEventOptions): void {\n    DomUtils.fireEvent(element, {\n      bubbles: true,\n      detail: {\n        blinkImgSrc: options.blinkImgSrc,\n        imgSrc: options.imgSrc,\n      },\n      name: 'open-image-overlay',\n    });\n  }\n}\n\nexport type OpenImageOverlayEvent = Event & { detail: IOpenImageOverlayEventOptions };\n","import {\n  FORMULA_UNIT_COLOR,\n  FORMULA_REFERENCE_END_IDENTIFIER,\n  FORMULA_REFERENCE_START_IDENTIFIER,\n  FORMULA_REFERENCE_TYPE_PREFIX_LENGTH,\n} from 'common/constants/formula.constants';\nimport {\n  FormulaStructure,\n  FormulaStructureShort,\n  IFormulaElementTypeBase,\n  IFormulaVariable,\n  IFormulaVariableShort,\n  IKeyValueEntry,\n  IKeyValueEntryShort,\n} from 'common/dto/course/content/formula.interface';\nimport { assertNotNullOrUndefined } from './assert';\nimport { ArrayUtils } from 'common/utils/ArrayUtils';\n\nexport interface IGenerateFormulaDisplayStringDto {\n  resultName: string;\n  resultArguments: string;\n  resultUnit: string;\n  formulaString?: string;\n  resultAfterFormula: boolean;\n  removeReferenceIdentifiers: boolean;\n  convertSpaces: boolean;\n}\n\nexport class FormulaUtils {\n\n  /**\n   * generate the formula display string, depending on\n   * result name, arguments and unit;\n   * provided formula string;\n   * optionally removing reference identifiers\n   */\n  public static generateDisplayString(dto: IGenerateFormulaDisplayStringDto): string {\n    const reverse = dto.resultAfterFormula;\n    const space = '\\\\space';\n    let generatedString = this.escapeProblematicSpecialCharacters(dto.resultName);\n\n    if(dto.resultArguments) {\n      generatedString = `${generatedString}${space}{(${this\n        .escapeProblematicSpecialCharacters(dto.resultArguments)})}`;\n    }\n\n    if(dto.resultUnit) {\n      generatedString = `${generatedString}${space}\\\\textcolor{${FORMULA_UNIT_COLOR}}{[${this\n        .escapeProblematicSpecialCharacters(dto.resultUnit)}]}`;\n    }\n\n    generatedString = this.combineStrings(reverse, generatedString, `${space} `);\n    generatedString = this.combineStrings(reverse, generatedString, '=', `${space} `);\n\n    if(dto.formulaString) {\n      generatedString =  this.combineStrings(\n        reverse,\n        generatedString,\n        dto.removeReferenceIdentifiers\n          ? this.removeReferenceIdentifiers(dto.formulaString)\n          : dto.formulaString,\n      );\n    }\n\n    if(dto.convertSpaces) {\n      generatedString = this.convertToLatexSpaces(generatedString);\n    }\n\n    return generatedString;\n  }\n\n  private static combineStrings(reverse: boolean, a: string, b: string, c?: string): string {\n    if(reverse) return `${c ?? ''}${b}${a}`;\n    return `${a}${b}${c ?? ''}`;\n  }\n\n  /**\n   * removing ?{ ... } wrappers for variables and other replaceable elements\n   */\n  public static removeReferenceIdentifiers(formulaString: string): string {\n    let cleanedFormulaString = formulaString;\n    let itemIndex = 0;\n    do {\n      itemIndex = cleanedFormulaString.indexOf(FORMULA_REFERENCE_START_IDENTIFIER);\n      if(itemIndex !== -1) {\n        const itemEndIndex = cleanedFormulaString.indexOf(FORMULA_REFERENCE_END_IDENTIFIER, itemIndex);\n        cleanedFormulaString = `${cleanedFormulaString\n          .slice(0, itemIndex)}${cleanedFormulaString\n          .slice(itemIndex + FORMULA_REFERENCE_START_IDENTIFIER.length + FORMULA_REFERENCE_TYPE_PREFIX_LENGTH, itemEndIndex)}${cleanedFormulaString\n          .slice(itemEndIndex + FORMULA_REFERENCE_END_IDENTIFIER.length)}`;\n      }\n    } while(itemIndex !== -1);\n    return cleanedFormulaString;\n  }\n\n  /**\n   * remove preceeding +\n   * return empty string in case isValid is false\n   */\n  public static cleanNumberString(input: string, isValid: boolean): string {\n    let cleanedValueString = input;\n    if(!isValid) return '';\n\n    // is anything left after the potentially remove \"+\"?\n    if(cleanedValueString.length) {\n      // remove preceeding +\n      if(input[0] === '+') {\n        cleanedValueString = input.slice(1);\n      }\n    }\n    return cleanedValueString;\n  }\n\n  /**\n   * add dot separators to pre decimal point digits (e.g.: 1234567 => 1.234.467)\n   * and ensure decimal point is a \",\"\n   */\n  public static convertToDisplayString(valueAsString: string, regex: RegExp): string {\n    const numberValidationRegex = new RegExp(regex);\n    if(!numberValidationRegex.test(valueAsString)) return '';\n\n    const groups = numberValidationRegex.exec(valueAsString);\n    assertNotNullOrUndefined(groups, 'Regex captured groups must be available to extract number');\n\n    const preDecimalPointDigits = groups[2];\n    let cleanedNumberString = '';\n    let digitsHandled = 0;\n    let addSeparatorsDone = false;\n    while(!addSeparatorsDone) {\n      if(preDecimalPointDigits.length - digitsHandled > 3) {\n        cleanedNumberString = `.${preDecimalPointDigits\n          .slice(preDecimalPointDigits.length - digitsHandled - 3, preDecimalPointDigits.length - digitsHandled)}${cleanedNumberString}`;\n        digitsHandled += 3;\n      } else {\n        cleanedNumberString = `${preDecimalPointDigits.slice(0, preDecimalPointDigits.length - digitsHandled)}${cleanedNumberString}`;\n        addSeparatorsDone = true;\n      }\n    }\n    let resultString = `${groups[1]}${cleanedNumberString}${groups[3].length ? ',' : ''}${groups[3]}`;\n    if(groups[4].length) resultString = `${resultString}${groups[5]}${groups[6]}${groups[7]}`;\n\n    return resultString;\n  }\n\n  /**\n   * using regex for validation before ...\n   * converting number string to float which can be used for calculations\n   */\n  public static convertStringToNumber(valueAsString: string, regex: RegExp): number | null {\n    const numberValidationRegex = new RegExp(regex);\n    if(!numberValidationRegex.test(valueAsString)) return null;\n\n    /**\n     * array indizes of groups contain:\n     * [0] .. complete matching string;\n     * [1] .. sign;\n     * [2] .. pre comma digits;\n     * [3] .. post comma digits;\n     * [4] .. complete \"times ten to the power of ...\" string (e.g.: \"e-2\");\n     * [5] .. e or E representing \"times ten to the power of ...\";\n     * [6] .. sign for \"ten to the power of - exponent\";\n     * [7] .. ditits representing the \"ten to the power of - exponent\";\n     */\n    const groups = numberValidationRegex.exec(valueAsString);\n\n    assertNotNullOrUndefined(groups, 'Regex captured groups must be available to extract number');\n    let cleanedValueString = `${groups[1]}${groups[2]}${groups[3].length ? '.' : ''}${groups[3]}`;\n    // append \"times ten to the power of ...\" if available\n    if(groups[4].length) cleanedValueString = `${cleanedValueString}${groups[5]}${groups[6]}${groups[7]}`;\n\n    return parseFloat(cleanedValueString);\n  }\n\n  /**\n   * normal means all attributes are named verbose;\n   * short means all attributes are shortened to save string length in DB\n   */\n  public static convertKeyValueListEntriesNormalToShort(\n    entries: IKeyValueEntry[],\n  ): IKeyValueEntryShort[] {\n    return entries.map(entry => ({\n      o: entry.order,\n      k: entry.key,\n      vs: entry.valueAsString,\n      ds: entry.displayString,\n      vn: entry.valueAsNumber,\n    }));\n  }\n\n  public static convertFormulaVariableNormalToShort(\n    entry: IFormulaVariable,\n  ): IFormulaVariableShort {\n    return {\n      id: entry.id,\n      name: entry.name,\n      unit: entry.unit,\n      ivs: entry.minValueAsString,\n      ids: entry.minDisplayString,\n      ivn: entry.minValueAsNumber,\n      avs: entry.maxValueAsString,\n      ads: entry.maxDisplayString,\n      avn: entry.maxValueAsNumber,\n    };\n  }\n\n  /**\n   * normal means all attributes are named verbose;\n   * short means all attributes are shortened to save string length in DB\n   */\n  public static convertKeyValueListEntriesShortToNormal(\n    entries: IKeyValueEntryShort[],\n  ): IKeyValueEntry[] {\n    return entries.map(entry => this.convertKeyValueListEntryShortToNormal(entry));\n  }\n\n  public static convertKeyValueListEntryShortToNormal(\n    entry: IKeyValueEntryShort,\n  ): IKeyValueEntry {\n    return {\n      order: entry.o,\n      key: entry.k,\n      valueAsString: entry.vs,\n      displayString: entry.ds,\n      valueAsNumber: entry.vn,\n    };\n  }\n\n  public static convertFormulaVariableShortToNormal(\n    entry: IFormulaVariableShort,\n  ): IFormulaVariable {\n    return {\n      id: entry.id,\n      name: entry.name,\n      unit: entry.unit,\n      minValueAsString: entry.ivs ?? '',\n      minDisplayString: entry.ids ?? '',\n      minValueAsNumber: entry.ivn ?? null,\n      maxValueAsString: entry.avs ?? '',\n      maxDisplayString: entry.ads ?? '',\n      maxValueAsNumber: entry.avn ?? null,\n    };\n  }\n\n  /**\n   * Katex can handle a lot regarding displaying special characters.\n   * But some need to be escaped.\n   */\n  public static escapeProblematicSpecialCharacters(input: string): string {\n    let output = input;\n\n    output = output.replace(/&/g, '\\\\&');\n    output = output.replace(/\\$/g, '\\\\$');\n    output = output.replace(/~/g, '\\\\text{\\\\textasciitilde}');\n\n    return output;\n  }\n\n  /**\n   * Special escaped characters in Katex display strings cannot be found\n   * easily by comparisons. To enable searching it is required to unescape\n   * all occurrences.\n   */\n  public static unescapeProblematicSpecialCharacters(input: string): string {\n    let output = input;\n\n    output = output.replace(/\\\\&/g, '&');\n    output = output.replace(/\\\\\\$/g, '$');\n    output = output.replace(/\\\\text{\\\\textasciitilde}/g, '~');\n\n    return output;\n  }\n\n  /**\n   * Replace characters that should be supported for user input and\n   * display in formulas, but which are not allowed in evaluatex.\n   * Replace with random string, with a chosen length of 6 as a possible\n   * sweet spot between minimum length and uniqueness to not conflict with user inputs.\n   */\n  public static replaceSpecialCharacters(input: string): string {\n    let output = input;\n    // replace german special characters\n    output = output.replace(/ö/g, 'zc4J0n');\n    output = output.replace(/Ö/g, 'qXWYky');\n    output = output.replace(/ä/g, 'Xukph7');\n    output = output.replace(/Ä/g, 'bx3Imw');\n    output = output.replace(/ü/g, 'pSxYIK');\n    output = output.replace(/Ü/g, 'GxpYaH');\n    output = output.replace(/ß/g, 'pS1YaK');\n    output = output.replace(/ẞ/g, 'G1pxaH');\n\n    // replace special characters\n    output = output.replace(/\\+/g, 'YnNGfA');\n    output = output.replace(/-/g, 'zyVmTm');\n    output = output.replace(/;/g, 'Uji8Kl');\n    output = output.replace(/\\(/g, 'AGG46w');\n    output = output.replace(/\\)/g, 'fvLs7y');\n    output = output.replace(/§/g, 'y0EFsK');\n    output = output.replace(/€/g, 'RK3nqx');\n    output = output.replace(/\\$/g, 'wDPt7h');\n    output = output.replace(/&/g, 'nmA08L');\n    output = output.replace(/\\//g, 'yni5BX');\n    output = output.replace(/\\*/g, 'qA2oVQ');\n    output = output.replace(/</g, 'nY9D1e');\n    output = output.replace(/>/g, 'hK0Prh');\n    output = output.replace(/\\[/g, 'u8U3hW');\n    output = output.replace(/]/g, 'K6ZqUt');\n    output = output.replace(/~/g, 'VUBh8r');\n    output = output.replace(/,/g, 'V1yh0r');\n\n    // replace greek alphabet\n    output = output.replace(/α/g, 'Yszzgn'); // alpha\n    output = output.replace(/Α/g, 'gyQX3a');\n    output = output.replace(/β/g, 'rQMqGd'); // beta\n    output = output.replace(/Β/g, 'kDkCWA');\n    output = output.replace(/γ/g, 'QJB5YS'); // gamma\n    output = output.replace(/Γ/g, 'lw5jyp');\n    output = output.replace(/δ/g, 'W59DdQ'); // delta\n    output = output.replace(/Δ/g, 'C7c3LH');\n    output = output.replace(/ε/g, 'nQHntN'); // epsilon\n    output = output.replace(/Ε/g, 'ELgeX6');\n    output = output.replace(/ζ/g, 'Lq6at9'); // zeta\n    output = output.replace(/Ζ/g, 'ZO0W6J');\n    output = output.replace(/η/g, 'TDd4AC'); // eta\n    output = output.replace(/Η/g, 'olCmu8');\n    output = output.replace(/θ/g, 'zJi7Pe'); // theta\n    output = output.replace(/Θ/g, 'ZQV74N');\n    output = output.replace(/ι/g, 'Vi8vGY'); // iota\n    output = output.replace(/Ι/g, 'SxVTLz');\n    output = output.replace(/κ/g, 'uBSHNp'); // kappa\n    output = output.replace(/Κ/g, 'ltUo7V');\n    output = output.replace(/λ/g, 'pN7r8b'); // lambda\n    output = output.replace(/Λ/g, 'KsZWAi');\n    output = output.replace(/μ/g, 'Jx22xt'); // mu\n    output = output.replace(/Μ/g, 'nRxV4J');\n    output = output.replace(/ν/g, 'Ys7qCl'); // nu\n    output = output.replace(/Ν/g, 'EjyAVN');\n    output = output.replace(/ξ/g, 'oLU5yk'); // xi\n    output = output.replace(/Ξ/g, 'wKW3Gf');\n    output = output.replace(/ο/g, 'dGzWSt'); // omicron\n    output = output.replace(/Ο/g, 'YgVGLN');\n    output = output.replace(/π/g, 'V26aqx'); // pi\n    output = output.replace(/Π/g, 'PMRQuX');\n    output = output.replace(/ρ/g, 'PjoICf'); // rho\n    output = output.replace(/Ρ/g, 'PbM6tS');\n    output = output.replace(/σ/g, 'jMG1Rr'); // sigma\n    output = output.replace(/ς/g, 'YI8TXf');\n    output = output.replace(/Σ/g, 'ApO4Vv');\n    output = output.replace(/τ/g, 'A8IHsg'); // tau\n    output = output.replace(/Τ/g, 'QVhg4I');\n    output = output.replace(/υ/g, 'n5ohND'); // upsion\n    output = output.replace(/Υ/g, 'NX3sVa');\n    output = output.replace(/φ/g, 'B0EjSw'); // phi\n    output = output.replace(/Φ/g, 'dWkb6j');\n    output = output.replace(/χ/g, 'gje9Dw'); // chi\n    output = output.replace(/Χ/g, 'CP34SK');\n    output = output.replace(/ψ/g, 'UpA8XT'); // psi\n    output = output.replace(/Ψ/g, 'bnu4Ed');\n    output = output.replace(/ω/g, 'ii5dyN'); // omega\n    output = output.replace(/Ω/g, 'loHXTN');\n    return output;\n  }\n\n  public static convertToLatexSpaces(input: string): string {\n    const output = input;\n    return output.replace(/\\s/g, ' \\\\space ');\n  }\n\n  public static replaceSpaces(input: string): string {\n    const output = input;\n    return output.replace(/\\s/g, 'xT1b6a');\n  }\n\n  public static parseFormulaAndConvertShortToNormal(formulaAsString: string): FormulaStructure | undefined {\n    const formulaStructureShort: FormulaStructureShort = JSON.parse(formulaAsString);\n    return {\n      sequence: formulaStructureShort.sequence,\n      operators: formulaStructureShort.operators,\n      variables: formulaStructureShort.variables.map(variable => FormulaUtils.convertFormulaVariableShortToNormal(variable)),\n      values: formulaStructureShort.values,\n      keyValueLists: formulaStructureShort.keyValueLists == null ? [] : formulaStructureShort.keyValueLists.map(keyValuelist => ({\n        id: keyValuelist.id,\n        name: keyValuelist.name,\n        entries: FormulaUtils.convertKeyValueListEntriesShortToNormal(keyValuelist.entries),\n      })),\n    };\n  }\n\n  public static convertElementsToRecordById<TFormulaElementType extends IFormulaElementTypeBase>(\n    array: TFormulaElementType[],\n  ): {\n    record: Record<number, TFormulaElementType>;\n    nextId: number;\n  } {\n    let nextId = 0;\n    if(array.length) {\n      nextId = (array.sort((a, b) => a.id - b.id))[array.length - 1].id + 1;\n    }\n    const record = ArrayUtils.toRecord(\n      array,\n      value => value.id,\n      value => value,\n    );\n    return { record, nextId };\n  }\n}\n","import screenfull, { Screenfull } from '../screenfull/screenfull';\n\nexport class FullscreenUtils {\n  private constructor() {}\n\n  private static FULLSCREEN_NOT_SUPPORTED_MSG = 'browser does not support FullscreenAPI';\n\n  public static get isEnabled(): boolean {\n    return screenfull.isEnabled;\n  }\n\n  public static isFullscreen(domElement: Element): boolean {\n    FullscreenUtils.assertIsEnabled(screenfull);\n    return screenfull.isFullscreen(domElement);\n  }\n\n  public static async request(domElement: Element, options?: FullscreenOptions): Promise<void> {\n    FullscreenUtils.assertIsEnabled(screenfull);\n    await screenfull.request(domElement, options);\n  }\n\n  public static async exit(domElement: Element): Promise<void> {\n    FullscreenUtils.assertIsEnabled(screenfull);\n    await screenfull.exit(domElement);\n  }\n\n  public static onChange(callback: () => void): void {\n    FullscreenUtils.assertIsEnabled(screenfull);\n    screenfull.on('change', callback);\n  }\n\n  public static offChange(callback: () => void): void {\n    FullscreenUtils.assertIsEnabled(screenfull);\n    screenfull.off('change', callback);\n  }\n\n  private static assertIsEnabled(sf: Screenfull | { isEnabled: false }): asserts sf is Screenfull {\n    if(!sf.isEnabled) throw new Error(FullscreenUtils.FULLSCREEN_NOT_SUPPORTED_MSG);\n  }\n}\n","export class FunctionUtils {\n  /**\n   * A rate limited function\n   */\n  public static rateLimit(callback: (...args: any[]) => void, rateIntervalMs: number, callBefore?: boolean): IRateLimitedFunction {\n    return callBefore\n      ? this.rateLimitCallBefore(callback, rateIntervalMs)\n      : this.rateLimitCallAfter(callback, rateIntervalMs);\n  }\n\n  /**\n   * A rate limited function which is called after the specified rateInterval\n   */\n  private static rateLimitCallAfter(callback: (...args: any[]) => void, rateIntervalMs: number): IRateLimitedFunction {\n    let timeout: number | null = null;\n\n    const rateLimited = (...args: any[]): void => {\n      if(!timeout) {\n        timeout = window.setTimeout(() => {\n          callback(args);\n          timeout = null;\n        }, rateIntervalMs);\n      }\n    };\n\n    rateLimited.cancel = (): void => {\n      if(timeout) {\n        window.clearTimeout(timeout);\n        timeout = null;\n      }\n    };\n\n    return rateLimited;\n  }\n\n  /**\n   * A rate limited function which can not be called again within the specified rateInterval\n   */\n  private static rateLimitCallBefore(callback: (...args: any[]) => void, rateIntervalMs: number): IRateLimitedFunction {\n    let timeout: number | null = null;\n\n    const rateLimited = (...args: any[]): void => {\n      if(!timeout) {\n        callback(args);\n        timeout = window.setTimeout(() => {\n          timeout = null;\n        }, rateIntervalMs);\n      }\n    };\n\n    rateLimited.cancel = (): void => {\n      if(timeout) {\n        window.clearTimeout(timeout);\n        timeout = null;\n      }\n    };\n\n    return rateLimited;\n  }\n\n  public static withTimeout<T>(promise: Promise<T>, timeout: number, fail: true): Promise<T>;\n  public static withTimeout<T>(promise: Promise<T>, timeout: number, fail: false): Promise<T | undefined>;\n  public static withTimeout<T>(promise: Promise<T>, timeout: number, fail: boolean): Promise<T | undefined> {\n    return Promise.race([\n      promise,\n      FunctionUtils.timeout(timeout).then(() => {\n        if(fail) {\n          throw new Error('promise timed out');\n        }\n\n        return undefined;\n      }),\n    ]);\n  }\n\n  public static debounce(callback: (...args: any[]) => void, timeoutMs: number): IDebouncedFunction {\n    let timeout: number | null = null;\n\n    const debounced = (...args: any[]): void => {\n      if(timeout !== null) {\n        window.clearTimeout(timeout);\n      }\n\n      timeout = window.setTimeout(() => {\n        callback(args);\n        timeout = null;\n      }, timeoutMs);\n    };\n\n    debounced.cancel = (): void => {\n      if(timeout) {\n        window.clearTimeout(timeout);\n        timeout = null;\n      }\n    };\n\n    return debounced;\n  }\n\n  public static timeout(timeMs: number, callback?: () => void): Promise<void> {\n    return new Promise(resolve => setTimeout(() => {\n      callback?.();\n      resolve();\n    }, timeMs));\n  }\n\n  public static async(callback: () => void): Promise<void> {\n    return FunctionUtils.timeout(0, callback);\n  }\n\n  public static async waitUntil(\n    callback: () => boolean,\n    waitUntilOptions?: Partial<IWaitUntilOptions>,\n  ): Promise<void> {\n    const options: IWaitUntilOptions = {\n      timeout: 1000,\n      errorMessage: 'waitUntil rejected because timeout was reached',\n      ...waitUntilOptions,\n    };\n\n    return new Promise((resolve, reject) => {\n      const tryResolve = (): void => {\n        try {\n          if(callback()) {\n            resolve();\n          } else {\n            setTimeout(tryResolve, 10);\n          }\n        } catch(e) {\n          reject(e);\n        }\n      };\n\n      tryResolve();\n\n      setTimeout(() => reject(options.errorMessage), options.timeout);\n    });\n  }\n\n  /**\n   * runs `fn` `totalAttempts` times\n   * @param onError can throw to cancel retries \n   */\n  public static async retry<T>(fn: () => Promise<T>, totalAttempts = 2, onError?: (e: unknown, attempt: number) => boolean | void): Promise<T> {\n    let lastError: unknown | undefined;\n    for(let r = 0; r < totalAttempts; r++) {\n      try {\n        return await fn();\n      } catch(e) {\n        lastError = e;\n        onError?.(e, r + 1);\n      }\n    }\n\n    throw lastError;\n  }\n}\n\nexport interface IRateLimitedFunction {\n  (...args: any[]): void;\n  cancel: () => void;\n}\n\nexport interface IDebouncedFunction {\n  (...args: any[]): void;\n  cancel: () => void;\n}\n\nexport interface IWaitUntilOptions {\n  timeout: number;\n  errorMessage: string;\n}","import { OmitStrict } from './types';\n\nexport class HttpRequestUtils {\n  public static get<T>(options: IGetOptions): Promise<T> {\n    return new Promise((resolve, reject) => {\n\n      const xhr = new XMLHttpRequest();\n      if(options.responseType) xhr.responseType = options.responseType;\n\n      if(options.abortController) {\n        options.abortController.signal.onabort = () => xhr.abort();\n      }\n\n      xhr.open('GET', options.url, true);\n\n      xhr.onload = () => resolve(options.transform?.(xhr) ?? xhr.response);\n\n      xhr.onerror = e => {\n        console.error(e);\n        reject(new Error(this.extractErrorText(xhr)));\n      };\n\n      xhr.onabort = () => reject(new Error('Aborted'));\n\n      if(options.progressHandler) xhr.onprogress = options.progressHandler;\n      xhr.send();\n    });\n  }\n\n  public static getArrayBuffer(options: OmitStrict<IGetOptions, 'responseType'>): Promise<ArrayBuffer> {\n    return this.get({ ...options, responseType: 'arraybuffer' });\n  }\n\n  /**\n   * Downloads a resource and immediately converts the body into a base64 dataUrl\n   */\n  public static getDataUrl(url: string): Promise<string> {\n    return this.get({\n      url,\n      responseType: 'arraybuffer',\n      transform: xhr => {\n        const mediaType = xhr.getResponseHeader('content-type');\n        const bytes = new Uint8Array(xhr.response);\n        const binaryString = Array.from(bytes, byte => String.fromCharCode(byte)).join('');\n        const base64String = btoa(binaryString); // btoa is \"deprecated\" in nodejs. Not in the browser.\n        return `data:${mediaType ? `${mediaType};` : ''}base64,${base64String}`;\n      },\n    });\n  }\n\n  private static extractErrorText(request: XMLHttpRequest): string {\n    try {\n      if(request.responseType === 'text' || request.responseType === '') return request.responseText;\n      if(request.status || request.statusText) return `${request.status} ${request.statusText}`;\n    } catch{\n      // accessing xhr response data may throw under certain circumstances\n    }\n\n    return 'unknown error';\n  }\n\n  public static async loadImage(src: string): Promise<HTMLImageElement> {\n    const imageElement = new Image();\n\n    await new Promise<void>((resolve, reject) => {\n      imageElement.onerror = (event, source, lineno, colno, error) => reject(error || new Error('unknown error'));\n      imageElement.onabort = () => {\n        // eslint-disable-next-line no-console\n        console.warn(`loading of image \"${src}\" was aborted`);\n        resolve();\n      };\n      imageElement.onload = () => resolve();\n      imageElement.src = src;\n    });\n\n    return imageElement;\n  }\n\n}\n\ninterface IGetOptions<T = any> {\n  url: string;\n  responseType?: XMLHttpRequestResponseType;\n  abortController?: AbortController;\n  transform?: (xhr: XMLHttpRequest) => T;\n  progressHandler?: (this: XMLHttpRequest, ev: ProgressEvent) => any;\n}\n","import { CoursePageDurationCalculator } from 'common/classes/CoursePageDurationCalculator';\nimport { DEFAULT_MAX_DAYS_TO_EXAM } from 'common/constants/course.constants';\nimport { ICourseContentDto } from 'common/dto/course/content/course.interface';\nimport { ICourseProgress } from 'common/dto/course/progress/course.progress.interface';\nimport { IAnswerHistoryEntry } from 'common/dto/course/quiz/quiz.response';\nimport {\n  ILearnGoal,\n  ILearningLearnGoalStats,\n  ILearningSimulationConfig,\n  ISpacedRepetitionAnswerHistoryEntry,\n  ISpacedRepetitionQuestion,\n  ITrainingLearnGoalStats,\n  ITrainingSimulatedData,\n  ITrainingSimulationConfig,\n  ITrainingSimulationQuestion,\n  SpacedRepetitionQuestionByQuestionId,\n} from 'common/dto/user/lern.goal.interface';\nimport Moment from 'moment';\nimport type Seedrandom from 'seedrandom';\nimport { ArrayUtils } from './ArrayUtils';\nimport { assertNotNullOrUndefined } from './assert';\nimport { CourseUtils } from './CourseUtils';\nimport { MathUtils } from './MathUtils';\nimport { QuestionUtils } from './QuestionUtils';\nimport { isNotNullish } from './types';\n\nexport class LearnGoalUtils {\n  /**\n   * Assumed time a question takes to answer\n   */\n  public static MINUTES_PER_QUESTION = 0.5;\n\n  /**\n   * Interval growth factor for spaced repetition\n   */\n  public static INTERVAL_GROWTH_FACTOR = 2.2;\n\n  /**\n   * Max days to simulate\n   */\n  public static MAX_SIMULATION_DAYS = 365;\n\n  /**\n   * Max days to exam\n   */\n  public static ABSOLUTE_MAX_DAYS_TO_EXAM = 365;\n\n  public static Moment: typeof Moment | null = null;\n  public static Seedrandom: typeof Seedrandom | null = null;\n\n  private constructor() { }\n\n  public static configure(config: {\n    Moment: typeof Moment;\n    Seedrandom: typeof Seedrandom;\n  }): void {\n    this.Moment = config.Moment;\n    this.Seedrandom = config.Seedrandom;\n  }\n\n  public static getLearningStatsForToday<T extends Date | string = string>(\n    course: ICourseContentDto<T>,\n    learnGoal: ILearnGoal | null,\n    totalProgress: number,\n    courseProgress: ICourseProgress,\n  ): ILearningLearnGoalStats {\n    const todaysRelativeProgress = totalProgress - courseProgress.courseProgressAtStartOfDay;\n    const calculator = new CoursePageDurationCalculator(course);\n    const totalCourseMinutes = calculator.totalPageDuration.durationAll / 60;\n\n    const minutesToLearnToday = learnGoal?.learningData[0] || 0;\n    const minutesLearnedToday = MathUtils.clamp((todaysRelativeProgress / 100) * totalCourseMinutes, 0, minutesToLearnToday);\n\n    const minutesLeft = Math.max(0, minutesToLearnToday - minutesLearnedToday);\n    const rawProgress = minutesToLearnToday === 0 ? 0 : (minutesLearnedToday * 100) / minutesToLearnToday;\n    const roundedProgess = minutesLeft ? Math.floor(rawProgress) : Math.ceil(rawProgress);\n    const progress = Math.min(100, roundedProgess);\n\n    return {\n      minutesLeft,\n      totalMinutes: minutesToLearnToday,\n      progress,\n    };\n  }\n\n  public static getMinutesLearnedToday<T extends Date | string = string>(\n    course: ICourseContentDto<T>,\n    learnGoal: ILearnGoal | null,\n    totalProgress: number,\n    courseProgress: ICourseProgress,\n  ): number {\n    const learningStats = LearnGoalUtils.getLearningStatsForToday(course, learnGoal, totalProgress, courseProgress);\n    return Math.floor(learningStats.totalMinutes - learningStats.minutesLeft);\n  }\n\n  // TODO: Keep an eye on this for performance\n  public static getTrainingStatsForDay<T extends Date | string = string>(\n    course: ICourseContentDto<T>,\n    learnGoal: ILearnGoal | null,\n    spacedRepetitionStateByQuestionId: Partial<Record<number, ISpacedRepetitionQuestion>>,\n    date?: Date,\n  ): ITrainingLearnGoalStats {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    const trainingQuestions = CourseUtils.getTrainingQuestions(course);\n    const { mainQuestions, subQuestionsByQuestionId } = QuestionUtils.getMainQuestionsAndSubQuestions(trainingQuestions);\n\n    const startOfDay = this.Moment(date).startOf('day').valueOf();\n    const endOfDay = this.Moment(date).endOf('day').valueOf();\n\n    let answeredNewQuestions = 0;\n    let answeredRepeatQuestions = 0;\n    let expectedRepeatQuestions = 0;\n    let totalWrongQuestions = 0;\n    let fixedWrongQuestions = 0;\n    for(const question of mainQuestions) {\n      const subQuestion = subQuestionsByQuestionId[question.id];\n      const answerHistory = spacedRepetitionStateByQuestionId[question.id]?.answerHistory || [];\n      const subAnswerHistory = (subQuestion && spacedRepetitionStateByQuestionId[subQuestion.id]?.answerHistory) || [];\n      const firstAnswer = answerHistory[0];\n      const firstSubAnswer = subAnswerHistory[0];\n\n      // A question pair is newly answered if main and sub question have been answered, and one of them was answered for the first time today\n      if(firstAnswer && (!subQuestion || firstSubAnswer) && (\n        (startOfDay <= firstAnswer.answerDate && firstAnswer.answerDate <= endOfDay) || (subQuestion && (\n          startOfDay <= firstSubAnswer.answerDate && firstSubAnswer.answerDate <= endOfDay))\n      )) {\n        answeredNewQuestions++;\n        if(subQuestion) answeredNewQuestions++;\n      }\n\n      const mainHistory = LearnGoalUtils.splitAnswerHistory(answerHistory, startOfDay, endOfDay);\n      const subHistory = LearnGoalUtils.splitAnswerHistory(subAnswerHistory, startOfDay, endOfDay);\n\n      // A question pair is scheduled for repetition if the main or sub question is due today and they were both last answered correctly\n      if((\n        (LearnGoalUtils.getDueDate(mainHistory.untilEndOfDayBefore, LearnGoalUtils.INTERVAL_GROWTH_FACTOR) || Infinity) <= endOfDay || (subQuestion &&\n          (LearnGoalUtils.getDueDate(subHistory.untilEndOfDayBefore, LearnGoalUtils.INTERVAL_GROWTH_FACTOR) || Infinity) <= endOfDay)\n        && mainHistory.lastAnswerBeforeToday?.correct\n        && (!subQuestion || subHistory.lastAnswerBeforeToday?.correct)\n      )) {\n        expectedRepeatQuestions++;\n        if(subQuestion) expectedRepeatQuestions++;\n\n        if(mainHistory.ofDay.length && (!subQuestion || subHistory.ofDay.length)) {\n          answeredRepeatQuestions++;\n          if(subQuestion) answeredRepeatQuestions++;\n        }\n      }\n\n      // A question pair is scheduled for fixing today if it or its sub question was last answered incorrectly at the start of the current day\n      if(mainHistory.lastAnswerBeforeToday?.correct === false || mainHistory.ofDay.some(a => !a.correct) || (subQuestion && (\n        subHistory.lastAnswerBeforeToday?.correct === false || subHistory.ofDay.some(a => !a.correct)\n      ))) {\n        totalWrongQuestions++;\n        if(subQuestion) totalWrongQuestions++;\n\n        // if it was scheduled for fixing today, and both main and subquestion have since been answered correctly, it counts as fixed\n        if(ArrayUtils.getLast(mainHistory.untilEndOfDay)?.correct && (!subQuestion || ArrayUtils.getLast(subHistory.untilEndOfDay)?.correct)) {\n          fixedWrongQuestions++;\n          if(subQuestion) fixedWrongQuestions++;\n        }\n      }\n    }\n\n    const expectedNewQuestions = learnGoal?.newQuestionsData[0] || 0;\n    answeredNewQuestions = Math.min(answeredNewQuestions, expectedNewQuestions);\n\n    const total = expectedNewQuestions + expectedRepeatQuestions + totalWrongQuestions;\n    const answered = answeredNewQuestions + answeredRepeatQuestions + fixedWrongQuestions;\n    const minutesLeft = Math.max(0, Math.ceil((total - answered) * LearnGoalUtils.MINUTES_PER_QUESTION));\n    const totalMinutes = Math.ceil(total * LearnGoalUtils.MINUTES_PER_QUESTION);\n    const progress = total === 0 ? 0 : Math.min(100, Math.floor(answered * 100 / total));\n\n    return {\n      answeredNewQuestions,\n      expectedNewQuestions,\n      answeredRepeatQuestions,\n      expectedRepeatQuestions,\n      totalWrongQuestions,\n      fixedWrongQuestions,\n      minutesLeft,\n      totalMinutes,\n      progress,\n    };\n  }\n\n  public static getMinutesTrainedToday<T extends Date | string = string>(\n    course: ICourseContentDto<T>,\n    learnGoal: ILearnGoal | null,\n    spacedRepetitionStateByQuestionId: Partial<Record<number, ISpacedRepetitionQuestion>>,\n    date?: Date,\n  ): number {\n    const trainingStats = LearnGoalUtils.getTrainingStatsForDay(course, learnGoal, spacedRepetitionStateByQuestionId, date);\n    return Math.floor(trainingStats.totalMinutes - trainingStats.minutesLeft);\n  }\n\n  public static calculateLearnGoal<T extends Date | string = string>(\n    course: ICourseContentDto<T>,\n    courseProgress: ICourseProgress,\n    examDate: Date | null,\n    questionsAnsweredBeforeToday: Array<ITrainingSimulationQuestion>,\n  ): ILearnGoal | null {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    const calculator = new CoursePageDurationCalculator(course);\n    const minutes = calculator.totalPageDuration.durationAll / 60;\n    const trainingQuestions = CourseUtils.getTrainingQuestions(course);\n\n    let learnGoal: ILearnGoal | null = null;\n    if(examDate) {\n      const days = Math.min(this.Moment(examDate).diff(this.Moment().startOf('day'), 'days'), this.ABSOLUTE_MAX_DAYS_TO_EXAM);\n      if(days > 0) {\n        const trainingConfig = LearnGoalUtils.guessTrainingSimulationParameters(days, trainingQuestions.length, questionsAnsweredBeforeToday.length);\n        const learningConfig = LearnGoalUtils.guessLearningSimulationParameters(days, minutes, courseProgress.courseProgressAtStartOfDay / 100 * minutes);\n        const trainingData = LearnGoalUtils.simulateTraining(trainingConfig, questionsAnsweredBeforeToday);\n        const learningData = LearnGoalUtils.simulateLearning(learningConfig);\n\n        learnGoal = {\n          learningData,\n          newQuestionsData: trainingData.newQuestionsByDay,\n          repeatQuestionsData: trainingData.repeatQuestionsByDay,\n          daysUntilExam: days,\n        };\n      }\n    }\n\n    return learnGoal;\n  }\n\n  public static guessTrainingSimulationParameters(\n    dayCount: number,\n    questionTotal: number,\n    questionsAnswered: number,\n  ): ITrainingSimulationConfig {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    const progress = questionsAnswered / questionTotal;\n\n    // 3 weeks before the exam\n    const fixedDeadline = dayCount - this.Moment.duration(3, 'weeks').asDays();\n\n    // \"half way between start of training and the exam date\", taking existing progress into account\n    const dynamicDeadline = Math.floor(dayCount * (1 - progress) / (2 - progress));\n\n    return {\n      daysToSimulate: dayCount,\n      maxDaysToSimulate: LearnGoalUtils.MAX_SIMULATION_DAYS,\n      questionCount: MathUtils.clamp(questionTotal - questionsAnswered, 0, questionTotal),\n      // choose the most lenient deadline\n      spreadNewQuestionsAcrossDays: Math.max(1, fixedDeadline, dynamicDeadline),\n      initialFailureRate: 0.2,\n      intervalGrowthFactor: LearnGoalUtils.INTERVAL_GROWTH_FACTOR,\n      powFactor: 1,\n    };\n  }\n\n  public static guessLearningSimulationParameters(dayCount: number, totalMinutes: number, completedMinutes: number): ILearningSimulationConfig {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    const progress = completedMinutes / totalMinutes;\n    const remainingMinutes = MathUtils.clamp(totalMinutes - completedMinutes, 0, totalMinutes);\n\n    // 3 weeks before the exam\n    const fixedDeadline = dayCount - this.Moment.duration(3, 'weeks').asDays();\n\n    // \"half way between start of training and the exam date\", taking existing progress into account\n    const dynamicDeadline = Math.floor(dayCount * (1 - progress) / (2 - progress));\n\n    // do not spread them thinner than at least 5 minutes per day\n    const fiveMinutesPerDayDeadline = Math.floor(remainingMinutes / 5);\n\n    return {\n      maxDaysToSimulate: LearnGoalUtils.MAX_SIMULATION_DAYS,\n      // at least one minute, not more than the five minutes per day spread, but from the remaining deadlines choose the more lenient one\n      spreadAcrossDays: Math.max(1, Math.min(fiveMinutesPerDayDeadline, Math.max(fixedDeadline, dynamicDeadline))),\n      totalMinutes: remainingMinutes,\n      powFactor: 1,\n    };\n  }\n\n  /**\n   * Simulates training with a simplified spaced repetition algorithm\n   * Simpflications:\n   * - easiness factor is not taken into account, a question is answered either correctly or wrong\n   * see comments in the code for further assumptions\n   */\n  public static simulateTraining(config: ITrainingSimulationConfig, pastQuestions: ITrainingSimulationQuestion[]): ITrainingSimulatedData {\n    assertNotNullOrUndefined(this.Seedrandom, 'must call LearnGoalUtils.configure()');\n\n    const rng = this.Seedrandom('lera-training-sim');\n\n    // Create the questions and distribute their due dates across spreadNewQuestionsAcrossDays days\n    const questions: ITrainingSimulationQuestion[] = [...pastQuestions];\n    for(let i = 0; i < config.questionCount; i++) {\n      questions.push({\n        lastAnsweredDay: null,\n        // the power can be used to have more new questions in the beginning for better balance\n        nextDueDay: ((i / config.questionCount) ** config.powFactor) * config.spreadNewQuestionsAcrossDays,\n        correctCount: 0,\n        wrongCount: 0,\n        seenCount: 0,\n      });\n    }\n\n    const newQuestionsByDay: number[] = [];\n    const repeatQuestionsByDay: number[] = [];\n    const daysToSimulate = Math.min(config.maxDaysToSimulate, config.daysToSimulate);\n\n    for(let day = 0; day < daysToSimulate; day++) {\n      const todaysQuestions = questions.filter(q => q.nextDueDay < day + 1);\n      const newQuestionCount = todaysQuestions.filter(q => q.lastAnsweredDay === null).length;\n\n      newQuestionsByDay.push(newQuestionCount);\n      repeatQuestionsByDay.push(todaysQuestions.length - newQuestionCount);\n\n      for(const question of todaysQuestions) {\n        question.seenCount++;\n        // assumption: the student improves on the same question each time\n        if(rng() >= config.initialFailureRate / (question.wrongCount + 1)) {\n          question.correctCount++;\n          // simplified: first time push the question to tomorrow, then by \"lastInterval*intervalGrowthFactor\" (ignore easiness factor)\n          question.nextDueDay += Math.max(1, question.lastAnsweredDay === null ? 1 : (day - question.lastAnsweredDay) * config.intervalGrowthFactor);\n          question.lastAnsweredDay = day;\n        } else {\n          // assumption: the student re-answers correctly on the same day\n          question.wrongCount++;\n          question.nextDueDay++;\n          question.correctCount = 1;\n          question.lastAnsweredDay = day;\n        }\n      }\n    }\n\n    return {\n      newQuestionsByDay,\n      repeatQuestionsByDay,\n      questions,\n    };\n  }\n\n  /**\n   * Simulates Learning by distributing discrete minutes across a range of days\n   * @returns a data set of minutes to learn, indexed by day\n   */\n  public static simulateLearning(config: ILearningSimulationConfig): number[] {\n    const minutesToLearnOnDay: number[] = [];\n    for(let currentMinute = 0; currentMinute < config.totalMinutes; currentMinute++) {\n      const toAdd = Math.min(config.totalMinutes - currentMinute, 1);\n      const dayToLearnThisMinuteOn = Math.floor(((currentMinute / config.totalMinutes) ** config.powFactor) * config.spreadAcrossDays);\n      if(dayToLearnThisMinuteOn >= config.maxDaysToSimulate) {\n        break;\n      }\n\n      minutesToLearnOnDay[dayToLearnThisMinuteOn] = (minutesToLearnOnDay[dayToLearnThisMinuteOn] ?? 0) + toAdd;\n    }\n\n    // the direct array access above can leave <empty item>s between days, e.g. spreading 2 minutes across 4 days\n    for(let i = 0; i < minutesToLearnOnDay.length; i++) {\n      if(!minutesToLearnOnDay[i]) minutesToLearnOnDay[i] = 0;\n    }\n\n    return minutesToLearnOnDay;\n  }\n\n  public static async getSimulationQuestionsForDate<T extends Date | string = string>(\n    courseContent: ICourseContentDto<T>,\n    simulationStartDate: Date,\n    spacedRepetitionQuestionByQuestionId: SpacedRepetitionQuestionByQuestionId,\n  ): Promise<Array<ITrainingSimulationQuestion>> {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    const trainingQuestions = CourseUtils.getTrainingQuestions(courseContent);\n    const timestamp = this.Moment(simulationStartDate.getTime()).endOf('day').subtract(1, 'day').valueOf();\n\n    const { mainQuestions, subQuestionsByQuestionId } = QuestionUtils.getMainQuestionsAndSubQuestions(trainingQuestions);\n\n    const questions: ITrainingSimulationQuestion[] = [];\n    for(const question of mainQuestions) {\n      const questionState = spacedRepetitionQuestionByQuestionId[question.id];\n      if(questionState) {\n        const subQuestion = subQuestionsByQuestionId[question.id];\n        const subQuestionState = subQuestion && spacedRepetitionQuestionByQuestionId[subQuestion.id];\n        const firstAnswer = questionState.answerHistory[0];\n        const firstSubAnswer = subQuestionState?.answerHistory[0];\n\n        if(firstAnswer && firstAnswer.answerDate <= timestamp && (!subQuestion || (firstSubAnswer && firstSubAnswer.answerDate <= timestamp))) {\n          const historyUntilYesterday = LearnGoalUtils.getHistoryUntil(questionState.answerHistory, timestamp);\n          const subHistoryUntilYesterday = subQuestionState && LearnGoalUtils.getHistoryUntil(subQuestionState.answerHistory, timestamp);\n          const dueDate = LearnGoalUtils.getDueDate(historyUntilYesterday, LearnGoalUtils.INTERVAL_GROWTH_FACTOR);\n          const subDueDate = subHistoryUntilYesterday && LearnGoalUtils.getDueDate(subHistoryUntilYesterday, LearnGoalUtils.INTERVAL_GROWTH_FACTOR);\n\n          if(dueDate && (!subQuestion || subDueDate)) {\n            const earlierDueDate = Math.min(dueDate, subDueDate ?? Infinity);\n            questions.push(LearnGoalUtils.createTrainingSimulationQuestion(historyUntilYesterday, simulationStartDate, earlierDueDate));\n\n            if(subQuestion && subHistoryUntilYesterday) {\n              questions.push(LearnGoalUtils.createTrainingSimulationQuestion(subHistoryUntilYesterday, simulationStartDate, earlierDueDate));\n            }\n          }\n        }\n      }\n    }\n\n    return questions;\n  }\n\n  public static cleanUpSpacedRepetitionState(questions: SpacedRepetitionQuestionByQuestionId): void {\n    Object.values(questions).filter(isNotNullish).filter(q => q.dirty).forEach(q => {\n      // Reassign because of lint rule\n      const updatedQuestion = q;\n\n      updatedQuestion.answerHistory = updatedQuestion.answerHistory\n        .sort((a, b) => a.answerDate - b.answerDate)\n        .filter((a, i, arr) => i === 0 || arr[i - 1].trainingSessionId !== a.trainingSessionId);\n\n      updatedQuestion.nextDueDate = LearnGoalUtils.getDueDate(updatedQuestion.answerHistory, LearnGoalUtils.INTERVAL_GROWTH_FACTOR);\n\n      updatedQuestion.dirty = false;\n    });\n  }\n\n  public static getDueDate(answerHistory: ISpacedRepetitionAnswerHistoryEntry[], intervalGrowthFactor: number): number | null {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    const lastEntry = ArrayUtils.getLast(answerHistory);\n    const secondToLastEntry = answerHistory[answerHistory.length - 2];\n\n    // if the question was never answered before, it will be selected randomly as a \"new question\"\n    if(!lastEntry) {\n      return null;\n    }\n\n    // last answer was wrong: it's due today\n    if(!lastEntry.correct) {\n      return this.Moment(lastEntry.answerDate).endOf('day').valueOf();\n    }\n\n    // question was answered correctly only once: due in 1 day\n    if(!secondToLastEntry || !secondToLastEntry.correct) {\n      return this.Moment(lastEntry.answerDate).add(1, 'day').valueOf();\n    }\n\n    // answered correctly at least twice in a row: multiply last interval by the interval growth factor. Minimum interval: 1 day\n    return this.Moment()\n      .add(Math.max(1000 * 60 * 60 * 24, (lastEntry.answerDate - secondToLastEntry.answerDate) * intervalGrowthFactor), 'milliseconds')\n      .endOf('day')\n      .valueOf();\n  }\n\n  public static getHistoryUntil(answerHistory: ISpacedRepetitionAnswerHistoryEntry[], date: number): ISpacedRepetitionAnswerHistoryEntry[] {\n    let index = 0;\n    for(let i = answerHistory.length - 1; i >= 0; i--) {\n      if(answerHistory[i].answerDate < date) {\n        index = i + 1;\n        break;\n      }\n    }\n    return answerHistory.slice(0, index);\n  }\n\n  public static getSpacedRepetitionStateByQuestionId(answerHistory: IAnswerHistoryEntry[]): SpacedRepetitionQuestionByQuestionId {\n    return ArrayUtils.toRecord(answerHistory, item => item.questionId, (item, existing) => {\n      const question = existing ?? {\n        answerHistory: [],\n        questionId: item.questionId,\n        nextDueDate: null,\n        dirty: true,\n      };\n\n      question.answerHistory.push({\n        answerDate: item.answeredTimestamp * 1000,\n        trainingSessionId: item.trainingSessionId,\n        correct: item.correct,\n      });\n\n      return question;\n    });\n  }\n\n  public static getDaysTillExamDate(examDate: string | Date): number {\n    return Moment(examDate).diff(Moment().startOf('day'), 'days');\n  }\n\n  public static getDaysToExamMaximumFromActiveUntil(activeUntil: Date | string | null): number {\n    if(!activeUntil) return DEFAULT_MAX_DAYS_TO_EXAM;\n    return Math.min(Math.max(Moment(activeUntil).diff(new Date(), 'days'), 0), LearnGoalUtils.ABSOLUTE_MAX_DAYS_TO_EXAM);\n  }\n\n  public static getDaysToExamMaximumFromExpirationInMonths(accessCodeExpirationInMonths: number): number {\n    const approximatedActiveUntil = new Date(new Date().getTime() + 1000 * 3600 * 24 * 30 * accessCodeExpirationInMonths);\n    return LearnGoalUtils.getDaysToExamMaximumFromActiveUntil(approximatedActiveUntil);\n  }\n\n  private static createTrainingSimulationQuestion(historyUntilYesterday: ISpacedRepetitionAnswerHistoryEntry[], simulationStartDate: Date, dueDate: number): ITrainingSimulationQuestion {\n    assertNotNullOrUndefined(this.Moment, 'must call LearnGoalUtils.configure()');\n\n    let correctCount = 0;\n    let wrongCount = 0;\n    let seenCount = 0;\n\n    for(const answer of historyUntilYesterday) {\n      if(answer.correct) correctCount++;\n      else wrongCount++;\n      seenCount++;\n    }\n\n    const lastAnswer = ArrayUtils.getLast(historyUntilYesterday);\n    return {\n      correctCount,\n      wrongCount,\n      seenCount,\n      lastAnsweredDay: lastAnswer?.correct ? this.Moment(lastAnswer.answerDate).diff(simulationStartDate, 'days') - 1 : 0,\n      nextDueDay: this.Moment(dueDate).diff(simulationStartDate, 'days'),\n    };\n  }\n\n  private static splitAnswerHistory(answerHistory: ISpacedRepetitionAnswerHistoryEntry[], startOfDay: number, endOfDay: number): {\n    /** history from the beginning until end of given day */\n    untilEndOfDay: ISpacedRepetitionAnswerHistoryEntry[];\n    /** history from the beginning until end of previous day */\n    untilEndOfDayBefore: ISpacedRepetitionAnswerHistoryEntry[];\n    /** history from the end of the previous day to the end of the current day */\n    ofDay: ISpacedRepetitionAnswerHistoryEntry[];\n    /** the last answer given before today */\n    lastAnswerBeforeToday: ISpacedRepetitionAnswerHistoryEntry | undefined;\n  } {\n    const untilEndOfDay = LearnGoalUtils.getHistoryUntil(answerHistory, endOfDay);\n    const untilEndOfDayBefore = LearnGoalUtils.getHistoryUntil(untilEndOfDay, startOfDay);\n    const ofDay = untilEndOfDay.slice(untilEndOfDayBefore.length);\n    const lastAnswerBeforeToday = ArrayUtils.getLast(untilEndOfDayBefore);\n    return { untilEndOfDay, untilEndOfDayBefore, ofDay, lastAnswerBeforeToday };\n  }\n}\n","import { IPointInfo } from 'common/dto/course/points/course.points.interface';\nimport { IAttendeeDto, ILectureQuizConfig, ISafeAttendeeDto, IScoreBoardEntry, LectureQuizState } from 'common/dto/distinar/distinar.interface';\nimport { QuizAnswerTime } from 'common/enum/quiz.attempt.status.enum';\nimport { FunctionUtils } from './FunctionUtils';\n\nexport class LectureQuizUtils {\n  public static getScoresWithPosition(\n    scores: IScoreBoardEntry[],\n    players: ISafeAttendeeDto[] | IAttendeeDto[],\n    limit?: number,\n  ): AttendeeScoreBoardEntryWithPosition[] {\n    if(scores.length === 0 || players.length === 0) return [];\n\n    const userIdsWithAScore = new Set(scores.map(s => s.userId));\n    const playersWithoutAScore = players.filter(p => !userIdsWithAScore.has(p.id));\n\n    const allScores: IScoreBoardEntry[] = [\n      ...scores,\n      ...playersWithoutAScore.map<IScoreBoardEntry>(p => ({\n        score: 0,\n        userId: p.id,\n      })),\n    ].sort((a, b) => b.score - a.score);\n\n    let lastPosition = 1;\n    let lastScore = Infinity;\n\n    const limitedScores = allScores.slice(0, limit);\n    const entries: Array<AttendeeScoreBoardEntryWithPosition> = [];\n\n    for(const [idx, scoreEntry] of limitedScores.entries()) {\n      const player = players.find(p => p.id === scoreEntry.userId);\n\n      // return empty array if we don't have the needed data yet\n      if(player == null) return [];\n\n      const playerPosition = scoreEntry.score === lastScore\n        ? lastPosition\n        : idx + 1;\n      lastPosition = playerPosition;\n      lastScore = scoreEntry.score;\n\n      entries.push({\n        ...scoreEntry,\n        position: playerPosition,\n        avatarSvgDownloadLink: player.avatarSvgDownloadLink,\n        nickname: player.nickname,\n        points: player.points,\n      });\n    }\n\n    return entries;\n  }\n\n  public static quizStateHasQuestion(quizState: LectureQuizState | null): boolean {\n    return quizState != null && [\n      LectureQuizState.Intro,\n      LectureQuizState.GetReady,\n      LectureQuizState.Answer,\n      LectureQuizState.Result,\n      LectureQuizState.Outro,\n    ].includes(quizState);\n  }\n\n  public static async runRevealTimer(\n    elapsedMsToSkip: number,\n    hasThirdPlace: boolean,\n    hasSecondPlace: boolean,\n    updateCallback: (position: PodiumPosition, step: 1 | 2) => void,\n  ): Promise<void> {\n    let remainingElapsedMsToSkip = elapsedMsToSkip;\n    const doTimeout = async (timeout: number): Promise<void> => {\n      await FunctionUtils.timeout(timeout - remainingElapsedMsToSkip);\n      remainingElapsedMsToSkip -= timeout;\n      if(remainingElapsedMsToSkip <= 0) remainingElapsedMsToSkip = 0;\n    };\n\n    // The timeouts here are arbitrary and vaguely matching the CSS transitions based on what \"feels\" good\n    await doTimeout(3000);\n\n    if(hasThirdPlace) {\n      updateCallback(3, 1);\n      await doTimeout(1300);\n      updateCallback(3, 2);\n      await doTimeout(1000);\n    }\n\n    if(hasSecondPlace) {\n      updateCallback(2, 1);\n      await doTimeout(1800);\n      updateCallback(2, 2);\n      await doTimeout(1000);\n    }\n\n    updateCallback(1, 1); // podium column grows from the ground...\n    await doTimeout(3500); // an extra long timeout for suspense!\n    updateCallback(1, 2); // boi-oing! Winner's avatar, name and points are revealed\n  }\n\n  public static getAnswerTimeLimitMs(\n    answerTime: QuizAnswerTime,\n    quizConfig: ILectureQuizConfig,\n  ): number {\n    let answerTimeLimitSeconds = quizConfig.longAnswerTimeLimitSeconds;\n    if(answerTime === QuizAnswerTime.Short) answerTimeLimitSeconds = quizConfig.shortAnswerTimeLimitSeconds;\n    else if(answerTime === QuizAnswerTime.Medium) answerTimeLimitSeconds = quizConfig.mediumAnswerTimeLimitSeconds;\n    return answerTimeLimitSeconds * 1000;\n  }\n}\n\nexport type AttendeeScoreBoardEntryWithPosition = {\n  nickname: string | null;\n  avatarSvgDownloadLink: string | null;\n  position : number;\n} & IScoreBoardEntry & IPointInfo;\n\nexport type PodiumPosition = 1 | 2 | 3;\n","import { LECTURE_STARTABLE_BEFORE_PLANNED_DATE_FROM_MIN } from 'common/constants/lecture.constants';\nimport { IAccessCodeCourseDto } from 'common/dto/course/content/course.interface';\nimport { EventType } from 'common/dto/course/content/course.types';\nimport { IEventDto, IStudentEventDto } from 'common/dto/tenant/event/tenant.event.interface';\nimport { EventStatus, StudentEventStatus } from 'common/enum/distinar.enum';\nimport Moment from 'moment';\n\nexport class LectureUtils {\n  public static isEventStartOrJoinableLecture(event: IEventDto<string>): boolean {\n    return !!(\n      // event is a lecture\n      event.type === EventType.Lecture\n      // event wasnt ended\n      && !this.hasEventEnded(event)\n      // max. 30min before planned start\n      && Moment(event.plannedDateFrom).diff(Moment(), 'minutes') <= LECTURE_STARTABLE_BEFORE_PLANNED_DATE_FROM_MIN)\n      // event planned end date hasnt been reached, or it was started\n      && !(\n        event.actualDateFrom == null\n        && Moment().isAfter(event.plannedDateTo)\n      );\n  }\n\n  public static isEventStarted(event: IEventDto<string>): boolean {\n    return event.actualDateFrom != null && !this.hasEventEnded(event);\n  }\n\n  public static hasEventEnded(event: IEventDto<string>): boolean {\n    return event.actualDateTo != null;\n  }\n\n  private static hasEventStartExpired(event: IEventDto<string>): boolean {\n    return event.actualDateFrom == null && Moment().isAfter(event.plannedDateTo);\n  }\n\n  public static isEventStartingShortly(event: IEventDto<string>): boolean {\n    return !this.isEventStarted(event)\n      && !this.hasEventStartExpired(event)\n      && !this.hasEventEnded(event)\n      && Moment(event.plannedDateFrom).diff(Moment(), 'minutes') <= LECTURE_STARTABLE_BEFORE_PLANNED_DATE_FROM_MIN;\n  }\n\n  public static isEventWaitingForLecturer(event: IEventDto<string>): boolean {\n    return this.isEventStartingShortly(event)\n      && Moment(event.plannedDateFrom).isBefore(Moment());\n  }\n\n  public static hasAttendedEndedEvent(event: IStudentEventDto<string>): boolean {\n    return !!((this.hasEventEnded(event) || (!this.isEventStarted(event) && this.hasEventStartExpired(event))) && event.attended);\n  }\n\n  public static hasNotAttendedEndedEvent(event: IStudentEventDto<string>): boolean {\n    return (this.hasEventEnded(event) || (!this.isEventStarted(event) && this.hasEventStartExpired(event))) && !event.attended;\n  }\n\n  public static getStudentEventStatus(event: IStudentEventDto<string>): StudentEventStatus | null {\n    if(event.type !== EventType.Lecture) return null;\n    if(this.isEventWaitingForLecturer(event)) return StudentEventStatus.WaitingForLecturer;\n    if(this.isEventStartingShortly(event)) return StudentEventStatus.StartingShortly;\n    if(this.isEventStarted(event)) return StudentEventStatus.IsStarted;\n    if(this.hasAttendedEndedEvent(event)) return StudentEventStatus.Attended;\n    if(this.hasNotAttendedEndedEvent(event)) return StudentEventStatus.NotAttended;\n    return null;\n  }\n\n  public static getLecturerEventStatus(event: IEventDto<string>): EventStatus | null {\n    if(event.type !== EventType.Lecture) return null;\n    if(this.isEventStartingShortly(event)) return EventStatus.Ready;\n    if(this.isEventStarted(event)) return EventStatus.IsStarted;\n    if(this.hasEventStartExpired(event)) return EventStatus.Expired;\n    if(this.hasEventEnded(event)) return EventStatus.Ended;\n\n    return EventStatus.WaitingForStart;\n  }\n\n  public static findBestCourseToJoin(event: IStudentEventDto<string>, availableCourses: IAccessCodeCourseDto<string>[], currentCourse: IAccessCodeCourseDto<string>): IAccessCodeCourseDto<string> {\n    // if the current course matches the event courses (if needed) and the tenant, we're good\n    if(this.courseMatchesEvent(event, currentCourse)) {\n      return currentCourse;\n    }\n\n    // find all courses that can join the event\n    const courseCandidates = availableCourses?.filter(userCourse => this.courseMatchesEvent(event, userCourse)) ?? [];\n\n    // prefer in order: same id as current course > the first course in the list > stay with the current course\n    return courseCandidates.find(c => c.id === currentCourse?.id)\n      || courseCandidates[0]\n      || currentCourse;\n  }\n\n  private static courseMatchesEvent(event: IStudentEventDto<string>, course: IAccessCodeCourseDto<string>): boolean {\n    return course.tenantId === event?.tenantId\n      && (!event.courses.length\n        || event.courses.some(eventCourse => eventCourse.id === course.id));\n  }\n}\n","import { LEVEL_POINTS, LEVEL_UNLOCKS, LevelColor } from 'common/constants/levels.constants';\nimport { MathUtils } from './MathUtils';\n\nexport class LevelUtils {\n  public static getCurrentLevel(currentPoints: number): number {\n    for(let i = LEVEL_POINTS.length - 1; i >= 0; i--) {\n      if(currentPoints >= LEVEL_POINTS[i]) return i + 1;\n    }\n    return 1;\n  }\n\n  public static getNextLevel(currentPoints: number): number | undefined {\n    const currentLevel = this.getCurrentLevel(currentPoints);\n    return currentLevel >= LEVEL_POINTS.length ? undefined : currentLevel + 1;\n  }\n\n  public static getCurrentLevelPercentage(currentPoints: number): number {\n    const currentLevelPointsMin = this.getCurrentLevelMinPoints(currentPoints);\n    const nextLevelPointsMin = LEVEL_POINTS[(this.getNextLevel(currentPoints) ?? this.getCurrentLevel(currentPoints)) - 1];\n    const percentage = ((currentPoints - currentLevelPointsMin) * 100) / (nextLevelPointsMin - currentLevelPointsMin);\n    const rounded = MathUtils.roundProgress(percentage);\n    return rounded > 100 ? 100 : rounded;\n  }\n\n  public static getCurrentLevelColor(currentLevel: number): LevelColor {\n    for(let i = LEVEL_UNLOCKS.length - 1; i >= 0; i--) {\n      if(currentLevel >= LEVEL_UNLOCKS[i].level) return LEVEL_UNLOCKS[i].color;\n    }\n    return LEVEL_UNLOCKS[0].color;\n  }\n\n  public static getCurrentLevelMinPoints(currentPoints: number): number {\n    const currentLevel = this.getCurrentLevel(currentPoints);\n    return this.getMinPointsForLevel(currentLevel);\n  }\n\n  public static getMinPointsForLevel(level: number): number {\n    return LEVEL_POINTS[level - 1] ?? LEVEL_POINTS[LEVEL_POINTS.length - 1];\n  }\n\n  public static getHighestLevel(): number {\n    return LEVEL_POINTS.length;\n  }\n}\n","import * as Moment from 'moment';\nimport { assertNotNullOrUndefined } from './assert';\n\nexport class LocaleUtils {\n  private static config: ILocaleUtilsConfig | null = null;\n\n  public static configure(config: ILocaleUtilsConfig): void {\n    this.config = config;\n  }\n  /**\n   * Define a '*-short' variant of a Moment locale from i18n\n   * The values are format strings.\n   * %s = string for future/past\n   * %d = number for everything else\n   * x = singular\n   * xx = plural\n   */\n  public static defineShortMomentLocale(i18nLocale: string): void {\n    const { config } = LocaleUtils;\n    assertNotNullOrUndefined(config, 'LocaleUtils must be configured');\n\n    const momentLocale = this.momentLocaleFromI18nLocale(i18nLocale);\n    const shortLocaleName = `${momentLocale}-short`;\n    if(Moment.locales().includes(shortLocaleName)) {\n      return;\n    }\n\n    const translationKey = config.processTranslation('relativeTime');\n\n    // Typing issue: tr always returns string, even if returnObjects is true\n    const relativeTime: Moment.RelativeTimeSpec = config.i18n.tr(translationKey, { returnObjects: true }) as unknown as Moment.RelativeTimeSpec;\n    for(const key of ['M', 'MM', 'd', 'dd', 'future', 'h', 'hh', 'm', 'mm', 'past', 's', 'ss', 'y', 'yy'] as Array<keyof Moment.RelativeTimeSpec>) {\n      if(typeof relativeTime[key] !== 'string') {\n        // eslint-disable-next-line no-console\n        console.warn(`missing translation for key 'moment.relativeTime.${String(key)}' in locale '${momentLocale}'`);\n      }\n    }\n\n    Moment.defineLocale(shortLocaleName, {\n      parentLocale: momentLocale,\n      relativeTime,\n    });\n\n    Moment.locale(momentLocale);\n  }\n\n  /**\n   * gets a Moment locale out of the current i18n state\n   */\n  public static momentLocaleFromI18nLocale(i18nLocale: string): string {\n    // This does not do much right now, but provides a hook for potential future locales that might not be identical between Moment and i18n\n    switch(i18nLocale) {\n      default: return i18nLocale;\n    }\n  }\n\n  /**\n   * Translates i18n localse to language names used in the \"FS\" question catalog\n   */\n  public static questionLanguageFromI18nLocale(i18nLocale: string | null): string | null {\n    switch(i18nLocale) {\n      case 'en': return 'EN';\n      case 'hr': return 'KR';\n      case 'sl': return 'SL';\n      default: return null;\n    }\n  }\n\n  public static shortLocale(): string {\n    return `${Moment.locale()}-short`;\n  }\n}\n\nexport interface ILocaleUtilsConfig {\n  processTranslation: (translationKey: string) => string;\n  // I18N Types have separate declarations of a private property 'ea'\n  i18n: any;\n}\n","import * as Sentry from '@sentry/browser';\nimport { CaptureConsole } from '@sentry/integrations';\nimport { Integrations } from '@sentry/tracing';\nimport { Config } from 'common/fe-config/config';\nexport class LogUtils {\n    static refreshConfiguration(options) {\n        const config = Config().sentry;\n        this.enabled = options.enabled;\n        if (config.logToConsole && config.captureConsole) {\n            console.error('Cannot capture and log the console at the same time, console output will be disabled');\n            this.logToConsole = false;\n        }\n        else {\n            this.logToConsole = config.logToConsole;\n        }\n        if (!this.enabled) {\n            Sentry.init({\n                enabled: false,\n            });\n            return;\n        }\n        const integrations = [];\n        if (config.automaticBrowserBreadcrumbs) {\n            integrations.push(new Integrations.BrowserTracing());\n        }\n        if (config.captureConsole) {\n            integrations.push(new CaptureConsole({\n                levels: ['error', 'warn'],\n            }));\n        }\n        Sentry.init({\n            enabled: true,\n            maxBreadcrumbs: 100,\n            dsn: config.dsn,\n            release: config.release,\n            environment: config.environment,\n            integrations,\n            tracesSampleRate: 1.0,\n        });\n    }\n    static disable() {\n        this.refreshConfiguration({ enabled: false });\n    }\n    static enable() {\n        this.refreshConfiguration({ enabled: true });\n    }\n    static addBreadcrumb(breadcrumb) {\n        Sentry.addBreadcrumb(breadcrumb);\n    }\n    static exception(exception) {\n        if (this.logToConsole)\n            console.error(exception);\n        return Sentry.captureException(exception);\n    }\n    static log(message) {\n        if (this.logToConsole)\n            console.log(message);\n        return Sentry.captureMessage(message, Sentry.Severity.Log);\n    }\n    static warn(message) {\n        if (this.logToConsole)\n            console.warn(message);\n        return Sentry.captureMessage(message, Sentry.Severity.Warning);\n    }\n    static error(message) {\n        if (this.logToConsole)\n            console.error(message);\n        return Sentry.captureMessage(message, Sentry.Severity.Error);\n    }\n}\nLogUtils.enabled = false;\nLogUtils.logToConsole = false;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiTG9nVXRpbHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9jb21tb24vdXRpbHMvTG9nVXRpbHMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQ0EsT0FBTyxLQUFLLE1BQU0sTUFBTSxpQkFBaUIsQ0FBQztBQUMxQyxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBRS9DLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSx5QkFBeUIsQ0FBQztBQU1qRCxNQUFNLE9BQU8sUUFBUTtJQUlYLE1BQU0sQ0FBQyxvQkFBb0IsQ0FBQyxPQUErQjtRQUNqRSxNQUFNLE1BQU0sR0FBRyxNQUFNLEVBQUUsQ0FBQyxNQUFNLENBQUM7UUFFL0IsSUFBSSxDQUFDLE9BQU8sR0FBRyxPQUFPLENBQUMsT0FBTyxDQUFDO1FBRS9CLElBQUcsTUFBTSxDQUFDLFlBQVksSUFBSSxNQUFNLENBQUMsY0FBYyxFQUFFO1lBQy9DLE9BQU8sQ0FBQyxLQUFLLENBQUMsc0ZBQXNGLENBQUMsQ0FBQztZQUN0RyxJQUFJLENBQUMsWUFBWSxHQUFHLEtBQUssQ0FBQztTQUMzQjthQUFNO1lBQ0wsSUFBSSxDQUFDLFlBQVksR0FBRyxNQUFNLENBQUMsWUFBWSxDQUFDO1NBQ3pDO1FBRUQsSUFBRyxDQUFDLElBQUksQ0FBQyxPQUFPLEVBQUU7WUFDaEIsTUFBTSxDQUFDLElBQUksQ0FBQztnQkFDVixPQUFPLEVBQUUsS0FBSzthQUNmLENBQUMsQ0FBQztZQUNILE9BQU87U0FDUjtRQUVELE1BQU0sWUFBWSxHQUF1QixFQUFFLENBQUM7UUFDNUMsSUFBRyxNQUFNLENBQUMsMkJBQTJCLEVBQUU7WUFDckMsWUFBWSxDQUFDLElBQUksQ0FBQyxJQUFJLFlBQVksQ0FBQyxjQUFjLEVBQUUsQ0FBQyxDQUFDO1NBQ3REO1FBRUQsSUFBRyxNQUFNLENBQUMsY0FBYyxFQUFFO1lBQ3hCLFlBQVksQ0FBQyxJQUFJLENBQUMsSUFBSSxjQUFjLENBQUM7Z0JBQ25DLE1BQU0sRUFBRSxDQUFDLE9BQU8sRUFBRSxNQUFNLENBQUM7YUFDMUIsQ0FBQyxDQUFDLENBQUM7U0FDTDtRQUVELE1BQU0sQ0FBQyxJQUFJLENBQUM7WUFDVixPQUFPLEVBQUUsSUFBSTtZQUNiLGNBQWMsRUFBRSxHQUFHO1lBQ25CLEdBQUcsRUFBRSxNQUFNLENBQUMsR0FBRztZQUNmLE9BQU8sRUFBRSxNQUFNLENBQUMsT0FBTztZQUN2QixXQUFXLEVBQUUsTUFBTSxDQUFDLFdBQVc7WUFDL0IsWUFBWTtZQUNaLGdCQUFnQixFQUFFLEdBQUc7U0FDdEIsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztJQUVNLE1BQU0sQ0FBQyxPQUFPO1FBQ25CLElBQUksQ0FBQyxvQkFBb0IsQ0FBQyxFQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsQ0FBQyxDQUFDO0lBQ2hELENBQUM7SUFFTSxNQUFNLENBQUMsTUFBTTtRQUNsQixJQUFJLENBQUMsb0JBQW9CLENBQUMsRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQztJQUMvQyxDQUFDO0lBRU0sTUFBTSxDQUFDLGFBQWEsQ0FBQyxVQUE2QjtRQUN2RCxNQUFNLENBQUMsYUFBYSxDQUFDLFVBQVUsQ0FBQyxDQUFDO0lBQ25DLENBQUM7SUFFTSxNQUFNLENBQUMsU0FBUyxDQUFDLFNBQWtCO1FBQ3hDLElBQUcsSUFBSSxDQUFDLFlBQVk7WUFBRSxPQUFPLENBQUMsS0FBSyxDQUFDLFNBQVMsQ0FBQyxDQUFDO1FBQy9DLE9BQU8sTUFBTSxDQUFDLGdCQUFnQixDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBQzVDLENBQUM7SUFFTSxNQUFNLENBQUMsR0FBRyxDQUFDLE9BQWU7UUFDL0IsSUFBRyxJQUFJLENBQUMsWUFBWTtZQUFFLE9BQU8sQ0FBQyxHQUFHLENBQUMsT0FBTyxDQUFDLENBQUM7UUFDM0MsT0FBTyxNQUFNLENBQUMsY0FBYyxDQUFDLE9BQU8sRUFBRSxNQUFNLENBQUMsUUFBUSxDQUFDLEdBQUcsQ0FBQyxDQUFDO0lBQzdELENBQUM7SUFFTSxNQUFNLENBQUMsSUFBSSxDQUFDLE9BQWU7UUFDaEMsSUFBRyxJQUFJLENBQUMsWUFBWTtZQUFFLE9BQU8sQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUM7UUFDNUMsT0FBTyxNQUFNLENBQUMsY0FBYyxDQUFDLE9BQU8sRUFBRSxNQUFNLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxDQUFDO0lBQ2pFLENBQUM7SUFFTSxNQUFNLENBQUMsS0FBSyxDQUFDLE9BQWU7UUFDakMsSUFBRyxJQUFJLENBQUMsWUFBWTtZQUFFLE9BQU8sQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUM7UUFDN0MsT0FBTyxNQUFNLENBQUMsY0FBYyxDQUFDLE9BQU8sRUFBRSxNQUFNLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQy9ELENBQUM7O0FBMUVjLGdCQUFPLEdBQUcsS0FBSyxDQUFDO0FBQ2hCLHFCQUFZLEdBQUcsS0FBSyxDQUFDIn0="],"names":[],"sourceRoot":""}