diff --git a/src/composable/useErrorHandling.ts b/src/composable/useErrorHandling.ts
index 9bbcf71..90e23ae 100644
--- a/src/composable/useErrorHandling.ts
+++ b/src/composable/useErrorHandling.ts
@@ -8,6 +8,7 @@
*/
import axios, { AxiosError } from 'axios';
import useLoader from './useLoader';
+import { computed, ComputedRef, Ref, ref } from 'vue';
type UseErrorHandlingReturnType = {
handleIndividualError: (
@@ -59,3 +60,60 @@ export function useErrorHandling(): UseErrorHandlingReturnType {
activateGlobalErrorHandlingInterceptor,
};
}
+
+export type ErrorValue = {
+ label: string;
+ value: string;
+};
+
+export const useErrorQueue = (): {
+ errors: Ref
;
+ addError: (error: ErrorValue) => void;
+ clearError: (label: string | string[]) => void;
+ clearAllErrors: () => void;
+ getError: ComputedRef<
+ (label: string | string[]) => string | null | undefined
+ >;
+} => {
+ const errors = ref([]);
+
+ const addError = (error: ErrorValue): void => {
+ errors.value.push(error);
+ };
+
+ const clearError = (label: string | string[]): void => {
+ if (Array.isArray(label)) {
+ errors.value = errors.value.filter((el) => !label.includes(el.label));
+ } else {
+ errors.value = errors.value.filter((el) => el.label !== label);
+ }
+ };
+
+ const clearAllErrors = (): void => {
+ errors.value = [];
+ };
+
+ const getError = computed(
+ () =>
+ (label: string | string[]): string | null | undefined => {
+ if (Array.isArray(label)) {
+ for (const lbl of label) {
+ const error = errors.value.find((el) => el.label === lbl)?.value;
+ if (error !== null && error !== undefined) {
+ return error;
+ }
+ }
+ } else {
+ return errors.value.find((el) => el.label === label)?.value;
+ }
+ },
+ );
+
+ return {
+ errors,
+ addError,
+ clearError,
+ clearAllErrors,
+ getError,
+ };
+};
diff --git a/src/i18n/de.json b/src/i18n/de.json
index bb14525..6f4b394 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -68,7 +68,6 @@
"inComponentTitle": "In Aufzeichnung '{title}': "
}
},
-
"studyNavigation": {
"accessDialog": {
"header": "Zugriff verweigert",
@@ -85,7 +84,6 @@
"monitoringAndData": "Monitoring & Data"
}
},
-
"study": {
"singular": "Studie",
"plural": "Studien",
@@ -184,6 +182,7 @@
"error": {
"addTitle": "Der Studientitel ist ein erforderliches Feld. Bitte fügen Sie einen Titel hinzu.",
"addDuration": "Bitte fügen Sie einen Wert und eine Einheit im Feld hinzu.",
+ "durationSmallerThanStudySpan": "Die angegebene Dauer muss kleiner oder gleich der gesamten Studienzeit sein.",
"addConsentInfo": "Die Berechtigungsinformation ist ein erforderliches Feld. Bitte fügen Sie eine Beschreibung hinzu.",
"addParticipantInfo": "Die Teilnehmerinformation ist ein erforderliches Feld. Bitte fügen Sie eine Beschreibung hinzu.",
"addContactInfo": "Bitte fügen Sie eine Kontaktperson und eine Emailadresse ein.",
@@ -194,6 +193,7 @@
"titleInput": "Bitte geben Sie einen kurzen Titel ein.",
"purposeInput": "Stellen Sie eine kurze Beschreibung Ihres Forschungszwecks für Ihre Studien-Mitarbeiter bereit.",
"durationInput": "Bitte geben Sie eine Studiendauer ein.",
+ "durationUnitDropdown": "Einheit der Dauer",
"participantInfoInput": "Stellen Sie Informationen für Ihre Studienteilnehmer:innen bereit. Dies Beschreibung wird den Teilnehmern:innen auf der App angezeigt.",
"consentInfoInput": "Stellen Sie Informationen über die zu sammelnden Daten bereit. Dies wird den Teilnehmern:innen als Beschreibungstext der generellen Studienberechtigungen in der App angezeigt.",
"selectLanguage": "Sprache auswählen",
@@ -264,7 +264,6 @@
}
}
},
-
"studyGroup": {
"singular": "Studiengruppe",
"plural": "Studiengruppen",
@@ -300,7 +299,6 @@
"chooseGroup": "Studiengruppe auswählen"
}
},
-
"studyCollaborator": {
"dialog": {
"addRole": "Wählen Sie mindestens eine Rolle um fortzufahren, oder brechen Sie den Vorgang ab.",
@@ -339,7 +337,6 @@
"defaultEmptyMsg": "Noch keine Mitarbeiter hinzugefügt."
}
},
-
"monitoringData": {
"tabs": {
"lastDataPoints": "Letzte Datenpunkte",
@@ -347,7 +344,6 @@
"dataDownload": "Studiendaten herunterladen"
}
},
-
"monitoring": {
"title": "Monitoring",
"description": "Hier sehen Sie Live-Daten aus Ihrer Studie. Die Daten werden alle {duration} Sekunden aktualisiert.",
@@ -397,14 +393,12 @@
}
}
},
-
"data": {
"dataDownload": {
"title": "Studiendaten herunterladen",
"description": "Hier können die Studiendaten basierend auf diverse Filter heruntergeladen werden."
}
},
-
"participants": {
"singular": "Teilnehmer",
"plural": "Teilnehmer",
@@ -466,7 +460,6 @@
"chooseParticipant": "Teilnehmer:in auswählen"
}
},
-
"observation": {
"singular": "Aufzeichnung",
"plural": "Aufzeichnungen",
@@ -564,7 +557,6 @@
"chooseObservation": "Aufzeichnung auswählen"
}
},
-
"integration": {
"singular": "Integration",
"plural": "Integrationen",
@@ -627,7 +619,6 @@
"selectObservation": "Wählen Sie bitte ein Datenerhebungsmodul aus, an das sie Ihr Integrationsmodul verlinken wollen."
}
},
-
"intervention": {
"singular": "Intervention",
"plural": "Interventionen",
@@ -761,7 +752,6 @@
"provideActionConfig": "Fügen Sie die Handlungs-Konfigurations hinzu."
}
},
-
"timeline": {
"labels": {
"relativeDate": "Registrierungsdatum",
@@ -776,13 +766,11 @@
"participantJoined": "Teilnehmer:in beigetreten"
}
},
-
"moreTable": {
"defaultEmptyMsg": "Keine Einträge vorhanden",
"filterBy": "Filtern",
"saveLine": "Bitte speichern"
},
-
"cronSchedule": {
"singular": "Cron Scheduler",
"placeholders": {
@@ -841,7 +829,6 @@
"quickStartNote": "Sekunden und Jahre werden automatisch gesetzt.",
"limitDescription": ""
},
-
"scheduler": {
"singular": "Planer",
"type": {
@@ -1057,7 +1044,7 @@
},
"placeholder": {
"dtstartOffset": "Offset in Tage(n)",
- "dtstartTime": "Strat Zeit",
+ "dtstartTime": "Start Zeit",
"dtendOffset": "Offset in Tage(n)",
"dtendTime": "End Zeit",
"enterNumber": "Nummer eingeben"
@@ -1070,11 +1057,16 @@
"addOffset": "Befülle den Offset des relativen Endzeitraum.",
"EndBeforeStart": "Bitte setzen Sie den relativen Startzeitpunkt vor dem Endzeitpunkt"
},
+ "scheduleTooLong": "Das Datenerhebungs-Event darf nicht länger als die Studiendauer dauern.",
+ "startTimeBeforeEnd": "Startzeit muss vor der Endzeit liegen.",
"rrrule": {
- "frequency": "Fülle die Widerholungsrate ein.",
- "endAfter": "Fulle den Offset zum Enddatum ein.",
- "notValid": "Die eingegebenen Werte sind ungültig."
- }
+ "frequency": "Fülle die Wiederholungsrate ein.",
+ "endAfter": "Bitte geben Sie den Offset zum Enddatum ein.",
+ "notValid": "Die eingegebenen Werte sind ungültig.",
+ "repetitionTooLong": "Wiederholungen können nicht über das Ende der Studie hinausgehen.",
+ "repetitionEndTooLong": "Das Ende der Wiederholungen darf das Studienende nicht überschreiten."
+ },
+ "cannotRepeat": "Das Datenerhebungs-Event kann nicht erneut stattfinden, da dies über das Ende der Studie hinausgehen würde."
}
}
},
@@ -1085,7 +1077,6 @@
"rruleEndIsEmpty": "Bitte legen Sie einen Endpunkt für Ihren Wiederholungszeitraum fest."
}
},
-
"tooltips": {
"deleteBtn": "Löschen",
"editBtn": "Bearbeiten",
@@ -1112,7 +1103,6 @@
"relativeDateInfo": "Damit wird der Zeitpunkt simuliert, an dem ein Teilnehmer in die Studie eintritt."
}
},
-
"userstatus": {
"active": "aktiv",
"approved": "genehmigt",
@@ -1125,7 +1115,6 @@
"inputModel": {
"enterValue": "Bitte geben Sie einen Wert hinzu."
},
-
"title": "Titel",
"message": "Nachricht"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a3a3895..08b5b52 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -68,7 +68,6 @@
"inComponentTitle": "In Observation '{title}': "
}
},
-
"studyNavigation": {
"accessDialog": {
"header": "Access denied",
@@ -85,7 +84,6 @@
"monitoringAndData": "Monitoring & Data"
}
},
-
"study": {
"singular": "Study",
"plural": "Studies",
@@ -184,6 +182,7 @@
"error": {
"addTitle": "The study title is required. Please enter a study title.",
"addDuration": "Please enter both a duration and a unit.",
+ "durationSmallerThanStudySpan": "The specified duration must be less than or equal to the total study time.",
"addConsentInfo": "The consent information is required. Please enter a description.",
"addParticipantInfo": "The participant information is required. Please enter a description.",
"addContactInfo": "Please add a contact person and email address, which participants can use to contact your institute when they encounter problems.",
@@ -193,6 +192,7 @@
"placeholder": {
"titleInput": "Please provide a short title.",
"durationInput": "Please provide the duration of the study",
+ "durationUnitDropdown": "Duration unit",
"purposeInput": "Please provide a short description of your research purpose for your collaborators.",
"participantInfoInput": "Please provide information for your studies participants. This description will be visible on the participant's app.",
"consentInfoInput": "Please provide information on the data that will be collected. This description will be shown on the participant's app.",
@@ -264,7 +264,6 @@
}
}
},
-
"studyGroup": {
"singular": "Study group",
"plural": "Study groups",
@@ -300,7 +299,6 @@
"chooseGroup": "Choose a group"
}
},
-
"studyCollaborator": {
"dialog": {
"addRole": "Please choose at least one role to continue.",
@@ -339,7 +337,6 @@
"defaultEmptyMsg": "No collaborators added yet."
}
},
-
"monitoringData": {
"tabs": {
"lastDataPoints": "Latest Data Points",
@@ -347,7 +344,6 @@
"dataDownload": "Export study data"
}
},
-
"monitoring": {
"title": "Monitoring",
"description": "You can view the live data of your study here. Data will be updated every {duration} seconds.",
@@ -397,14 +393,12 @@
}
}
},
-
"data": {
"dataDownload": {
"title": "Download study data",
"description": "Here you can download the study data based on various filters."
}
},
-
"participants": {
"singular": "Participant",
"plural": "Participants",
@@ -466,7 +460,6 @@
"chooseParticipant": "Choose a participant"
}
},
-
"observation": {
"singular": "Observation",
"plural": "Observations",
@@ -564,7 +557,6 @@
"chooseObservation": "Choose an observation"
}
},
-
"integration": {
"singular": "Integration",
"plural": "Integrations",
@@ -627,7 +619,6 @@
"selectObservation": "Please select an observation you want to link to."
}
},
-
"intervention": {
"singular": "Intervention",
"plural": "Interventions",
@@ -761,7 +752,6 @@
"provideActionConfig": "Enter the config for the action"
}
},
-
"timeline": {
"labels": {
"relativeDate": "Enrollment date",
@@ -776,13 +766,11 @@
"participantJoined": "Participant joined"
}
},
-
"moreTable": {
"defaultEmptyMsg": "No records",
"filterBy": "Filter by",
"saveLine": "Please save"
},
-
"cronSchedule": {
"singular": "Cron Scheduler",
"placeholders": {
@@ -841,7 +829,6 @@
"quickStartNote": "Seconds and years are handled automatically.",
"limitDescription": "This version of the More Cron Scheduler only supports whole numbers, * or ?. Expressions like 0/1 or 3-20 are not supported for the time being."
},
-
"scheduler": {
"singular": "Scheduler",
"type": {
@@ -1070,11 +1057,16 @@
"addOffset": "Fill in the offset of the relative end period.",
"EndBeforeStart": "Please Set the relative start period before the end period"
},
+ "scheduleTooLong": "The observation must not last longer than the study duration.",
+ "startTimeBeforeEnd": "Start time must be before the end time.",
"rrrule": {
"frequency": "Fill in the repetition rate.",
"endAfter": "Fill in the offset to the end date.",
- "notValid": "Values entered are not valid"
- }
+ "notValid": "Values entered are not valid",
+ "repetitionTooLong": "Repetitions cannot extend beyond the end of the study.",
+ "repetitionEndTooLong": "The end of the repetitions must not exceed the study end."
+ },
+ "cannotRepeat": "The observation cannot occur again as it would exceed the end of the study."
}
}
},
@@ -1085,7 +1077,6 @@
"rruleEndIsEmpty": "Please set and end point for your repetition period."
}
},
-
"tooltips": {
"deleteBtn": "Delete",
"editBtn": "Edit",
@@ -1112,7 +1103,6 @@
"relativeDateInfo": "This simulates the point in time when a participant enters the study."
}
},
-
"userstatus": {
"active": "active",
"approved": "approved",
@@ -1122,7 +1112,6 @@
"droppedOut": "dropped out",
"kickedOut": "kicked out"
},
-
"inputModel": {
"enterValue": "Please enter a value."
},
diff --git a/src/style.pcss b/src/style.pcss
index 4a274e9..77d4f6b 100644
--- a/src/style.pcss
+++ b/src/style.pcss
@@ -1,13 +1,11 @@
@import 'styles/normalize.pcss';
@import 'index.pcss';
-@import 'primevue/resources/primevue.min.css';
@import 'primeicons/primeicons.css';
@import "styles/more-light/theme.pcss";
@import 'https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500;1,600&display=swap';
-
@import './styles/text-styles.pcss';
@import './styles/layout.pcss';
@import './styles/btn.pcss';
diff --git a/src/utils/dataUtils.ts b/src/utils/dataUtils.ts
index 59c8a7d..46a692e 100644
--- a/src/utils/dataUtils.ts
+++ b/src/utils/dataUtils.ts
@@ -1,7 +1,10 @@
-export const hasData = (data?: string | number): boolean => {
- return !(
+export const hasData = (data?: string | number): boolean =>
+ !(
data === undefined ||
data === null ||
- (typeof data === 'string' && data.trim() === '')
+ (typeof data === 'string' && data.trim() === '') ||
+ (typeof data === 'number' && isNaN(data))
);
-};
+
+export const roundAndCeil = (input: number): number =>
+ Math.ceil(Math.abs(input));
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
index da5653a..f4e5ed9 100644
--- a/src/utils/dateUtils.ts
+++ b/src/utils/dateUtils.ts
@@ -6,6 +6,8 @@
Foerderung der wissenschaftlichen Forschung).
Licensed under the Elastic License 2.0.
*/
+import { DateTime } from 'luxon';
+
export function dateToDateString(date: Date): string | undefined {
return dateToDateTimeString(date)?.substring(0, 10);
}
@@ -67,3 +69,71 @@ export function timeToHourMinuteString(
return time;
}
+
+export const dateIsValid = (date?: Date | string): boolean => {
+ if (!date) {
+ return false;
+ } else if (typeof date === 'string') {
+ return !!dateTimeFromString(date);
+ }
+ return DateTime.fromJSDate(date).isValid;
+};
+
+export type DateString = 'iso' | 'sql' | 'http';
+
+export const dateTimeFromString = (
+ input: string,
+ timezone?: string,
+ dateStringTypes: DateString[] = ['iso', 'sql', 'http'],
+): DateTime | undefined => {
+ for (const dateStringType of dateStringTypes) {
+ let dateTime: DateTime;
+ switch (dateStringType) {
+ case 'iso':
+ dateTime = DateTime.fromISO(input, { zone: timezone });
+ break;
+ case 'sql':
+ dateTime = DateTime.fromSQL(input, { zone: timezone });
+ break;
+ case 'http':
+ dateTime = DateTime.fromHTTP(input, { zone: timezone });
+ break;
+ default:
+ continue;
+ }
+ if (dateTime.isValid) {
+ return dateTime;
+ }
+ }
+ return undefined;
+};
+
+export const createLuxonDateTime = (
+ input?: Date | string,
+ timezone?: string,
+): DateTime | undefined => {
+ if (!input) {
+ return;
+ }
+ if (input instanceof Date) {
+ return DateTime.fromJSDate(input, { zone: timezone });
+ } else {
+ return dateTimeFromString(input, timezone);
+ }
+};
+
+export const timeFromString = (
+ input: string,
+): { hour?: number; minute?: number; second?: number } | undefined => {
+ if (input) {
+ const [hour, minute, second] = input
+ .split(':')
+ .map((part) => (part ? parseInt(part, 10) : undefined));
+
+ if (hour === undefined && minute === undefined && second === undefined) {
+ return undefined;
+ }
+ return { hour: hour ?? 0, minute: minute ?? 0, second: second ?? 0 };
+ }
+ return undefined;
+};
diff --git a/src/utils/durationUtils.ts b/src/utils/durationUtils.ts
new file mode 100644
index 0000000..ce9f990
--- /dev/null
+++ b/src/utils/durationUtils.ts
@@ -0,0 +1,52 @@
+import { Duration, DurationUnitEnum } from '../generated-sources/openapi';
+
+const minutesInDay = 1440;
+const minutesInHour = 60;
+
+export function valueToMinutes(duration: Duration): number {
+ const value = duration.value || 0;
+ switch (duration.unit) {
+ case DurationUnitEnum.Day:
+ return value * minutesInDay;
+ case DurationUnitEnum.Hour:
+ return value * minutesInHour;
+ case DurationUnitEnum.Minute:
+ return value;
+ default:
+ return 0;
+ }
+}
+
+export function minutesToDuration(
+ minutes: number,
+ originalUnit?: DurationUnitEnum,
+): Duration {
+ let value = 0;
+ let unit = originalUnit;
+
+ switch (unit) {
+ case DurationUnitEnum.Day:
+ value = minutes / minutesInDay;
+ if (value >= 1) {
+ break;
+ }
+ unit = DurationUnitEnum.Hour;
+ // eslint-disable-next-line no-fallthrough
+ case DurationUnitEnum.Hour:
+ value = minutes / minutesInHour;
+ if (value >= 1) {
+ break;
+ }
+ unit = DurationUnitEnum.Minute;
+ // eslint-disable-next-line no-fallthrough
+ default:
+ unit = DurationUnitEnum.Minute;
+ value = minutes;
+ break;
+ }
+
+ return {
+ value: Math.floor(value),
+ unit: unit,
+ };
+}
diff --git a/src/utils/relativeScheduleUtils.ts b/src/utils/relativeScheduleUtils.ts
new file mode 100644
index 0000000..08c7577
--- /dev/null
+++ b/src/utils/relativeScheduleUtils.ts
@@ -0,0 +1,152 @@
+import { Duration, DurationUnitEnum } from '../generated-sources/openapi';
+import { DateTime, DurationLike } from 'luxon';
+import { roundAndCeil } from './dataUtils';
+import { minutesToDuration, valueToMinutes } from './durationUtils';
+
+export const correctEvent = (
+ startOffset: Duration,
+ endOffset: Duration,
+ start?: DateTime,
+ end?: DateTime,
+ maxDuration?: Duration,
+): {
+ offsetCorrected: boolean;
+ correctStart?: DateTime;
+ correctEnd?: DateTime;
+} => {
+ const maxValue = maxDuration?.value;
+
+ let offsetCorrected = false;
+ if (maxValue) {
+ const { unit } = maxDuration;
+
+ if (unit === DurationUnitEnum.Hour || unit === DurationUnitEnum.Minute) {
+ startOffset.value = 1;
+ endOffset.value = 1;
+ offsetCorrected = true;
+ } else {
+ const originalEnd = endOffset.value;
+ const originalStart = startOffset.value;
+ endOffset.value = Math.min(endOffset.value || 0, maxValue);
+ startOffset.value = Math.min(startOffset.value || 0, maxValue);
+ if (endOffset.value < startOffset.value) {
+ endOffset.value = startOffset.value;
+ }
+ if (startOffset.value > endOffset.value) {
+ startOffset.value = endOffset.value;
+ }
+ offsetCorrected =
+ originalEnd !== endOffset.value || originalStart !== startOffset.value;
+
+ if (
+ startOffset.value === endOffset.value &&
+ start?.isValid &&
+ end?.isValid &&
+ end <= start
+ ) {
+ const timeGapOnWrongDiff: DurationLike = { hour: 1 };
+ const remainingMinutes = roundAndCeil(
+ start.diff(start.set({ hour: 23, minute: 59 })).minutes,
+ );
+
+ if (remainingMinutes >= 60) {
+ end = start.plus(timeGapOnWrongDiff);
+ } else {
+ start = end.minus(timeGapOnWrongDiff);
+ }
+ }
+ }
+ }
+
+ return {
+ offsetCorrected,
+ correctStart: start,
+ correctEnd: end,
+ };
+};
+
+export const correctEventRepetition = (
+ offsetStart: Duration,
+ startTime: DateTime,
+ offsetEnd: Duration,
+ endTime: DateTime,
+ frequency: Duration,
+ frequencyEnd: Duration,
+ maxDuration: Duration,
+): {
+ frequencyCorrected: boolean;
+ frequencyEndCorrected: boolean;
+ repetitionEnabled: boolean;
+ numberOfRepetitions: number;
+} => {
+ const startTimeDiffInMinutes = roundAndCeil(
+ startTime.diff(startTime.set({ hour: 23, minute: 59 }), 'minutes').minutes,
+ );
+
+ const endTimeDiffInMinutes = roundAndCeil(
+ endTime.diff(endTime.set({ hour: 23, minute: 59 }), 'minutes').minutes,
+ );
+
+ const offsetStartInMinutes =
+ valueToMinutes(offsetStart) - startTimeDiffInMinutes;
+
+ const offsetEndInMinutes = valueToMinutes(offsetEnd) - endTimeDiffInMinutes;
+
+ const offsetDuration = offsetEndInMinutes - offsetStartInMinutes;
+
+ const maxDurationInMinutes = valueToMinutes(maxDuration);
+
+ let frequencyEndInMinutes = valueToMinutes(frequencyEnd);
+ let frequencyEndCorrected = false;
+ if (frequencyEndInMinutes > maxDurationInMinutes) {
+ const correctedFrequencyEnd = minutesToDuration(
+ maxDurationInMinutes,
+ maxDuration.unit,
+ );
+ frequencyEnd.value = correctedFrequencyEnd.value;
+ frequencyEnd.unit = correctedFrequencyEnd.unit;
+ frequencyEndInMinutes = maxDurationInMinutes;
+ frequencyEndCorrected = true;
+ }
+
+ const remainingMinutes = maxDurationInMinutes - offsetStartInMinutes;
+
+ const maxFrequencyInMinutes = Math.max(remainingMinutes - offsetDuration, 1);
+
+ const frequencyInMinutes = Math.max(valueToMinutes(frequency), 0);
+
+ const correctedFrequencyInMinutes = Math.min(
+ frequencyInMinutes,
+ maxFrequencyInMinutes,
+ );
+
+ const correctedFrequency = minutesToDuration(
+ correctedFrequencyInMinutes,
+ frequency.unit,
+ );
+
+ let frequencyCorrected = false;
+ if (
+ frequency.value !== correctedFrequency.value ||
+ frequency.unit !== correctedFrequency.unit
+ ) {
+ frequency.value = correctedFrequency.value;
+ frequency.unit = correctedFrequency.unit;
+ frequencyCorrected = true;
+ }
+
+ const repetitionEnabled = maxFrequencyInMinutes > 0;
+
+ const numberOfRepetitions =
+ Math.ceil(
+ Math.min(maxFrequencyInMinutes, frequencyEndInMinutes) /
+ correctedFrequencyInMinutes,
+ ) - 1;
+
+ return {
+ frequencyCorrected,
+ frequencyEndCorrected,
+ repetitionEnabled,
+ numberOfRepetitions,
+ };
+};
diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts
new file mode 100644
index 0000000..6d498cd
--- /dev/null
+++ b/src/utils/stringUtils.ts
@@ -0,0 +1,5 @@
+export const flatJoin = (...classes: (string | string[])[]): string =>
+ classes.flat().join(' ');
+
+export const safeString = (value?: unknown): string =>
+ value === undefined || value === null ? '' : String(value).trim();
diff --git a/src/utils/studyUtils.ts b/src/utils/studyUtils.ts
new file mode 100644
index 0000000..32de6e2
--- /dev/null
+++ b/src/utils/studyUtils.ts
@@ -0,0 +1,28 @@
+import {
+ Duration,
+ DurationUnitEnum,
+ Study,
+} from '../generated-sources/openapi';
+import { createLuxonDateTime } from './dateUtils';
+import { roundAndCeil } from './dataUtils';
+
+export const studyDuration = (study?: Study): Duration | undefined => {
+ const duration = study?.duration;
+ if (duration) {
+ return duration;
+ }
+ const start = createLuxonDateTime(study?.plannedStart)?.set({
+ hour: 0,
+ minute: 0,
+ });
+ const end = createLuxonDateTime(study?.plannedEnd)?.set({
+ hour: 23,
+ minute: 59,
+ });
+ if (start && end) {
+ return {
+ value: roundAndCeil(end.diff(start, 'day').days),
+ unit: DurationUnitEnum.Day,
+ };
+ }
+};
diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue
index 59e31e8..18abf41 100644
--- a/src/views/Dashboard.vue
+++ b/src/views/Dashboard.vue
@@ -49,7 +49,7 @@ Licensed under the Elastic License 2.0. */
-
+