@@ -237,9 +221,6 @@ Licensed under the Elastic License 2.0. */
>
{{ $t('study.props.duration') }}
-
- {{ getError('duration') }}
-
{{ $t('study.dialog.description.duration') }}
@@ -254,29 +235,26 @@ Licensed under the Elastic License 2.0. */
}}
-
+
-
+
+ {{ $t('scheduler.frequency.days') }}
+
+
-
{{ $t('study.props.purpose') }}
+
+ {{ $t('study.props.purpose') }}
+
{{ $t('study.dialog.description.purpose') }}
+ />
{{ $t('study.props.participantInfo') }}*
-
- {{ getError('participantInfo') }}
-
+
{{ $t('study.dialog.description.participantInfo') }}
@@ -306,7 +282,9 @@ Licensed under the Elastic License 2.0. */
:name="'participantInfo'"
:placeholder="$t('study.placeholder.participantInfoInput')"
:auto-resize="true"
+ @input="clearError('participantInfo')"
/>
+
{{ $t('study.props.consentInfo') }}*
-
- {{ getError('consentInfo') }}
+
+
+ {{ $t('study.dialog.description.consentInfo') }}
-
{{ $t('study.dialog.description.consentInfo') }}
+
@@ -345,19 +325,9 @@ Licensed under the Elastic License 2.0. */
{{ $t('study.dialog.label.contactInfo') }}*
-
- {{ getError('contactInfo') }}
+
+ {{ $t('study.dialog.description.contactData') }}
-
- {{ getError('contactPerson') }}
-
-
- {{ getError('contactEmail') }}
-
-
{{ $t('study.dialog.description.contactData') }}
@@ -380,6 +350,9 @@ Licensed under the Elastic License 2.0. */
type="text"
class="w-full"
:placeholder="t('study.placeholder.contactPerson')"
+ @input="
+ clearError(['contactInfo', 'contactPerson', 'contactEmail'])
+ "
/>
@@ -392,6 +365,9 @@ Licensed under the Elastic License 2.0. */
required
type="email"
:placeholder="t('study.placeholder.contactEmail')"
+ @input="
+ clearError(['contactInfo', 'contactPerson', 'contactEmail'])
+ "
/>
@@ -406,6 +382,10 @@ Licensed under the Elastic License 2.0. */
/>
+
diff --git a/src/components/forms/ErrorLabel.vue b/src/components/forms/ErrorLabel.vue
new file mode 100644
index 0000000..2a620fc
--- /dev/null
+++ b/src/components/forms/ErrorLabel.vue
@@ -0,0 +1,8 @@
+
+
+ {{ Array.isArray(error) ? error.find((e) => !!e) : error }}
+
+
+
diff --git a/src/components/shared/MoreTable.vue b/src/components/shared/MoreTable.vue
index 5c7af00..01e631d 100644
--- a/src/components/shared/MoreTable.vue
+++ b/src/components/shared/MoreTable.vue
@@ -38,6 +38,7 @@ Licensed under the Elastic License 2.0. */
} from '../../generated-sources/openapi';
import { shortenText } from '../../utils/commonUtils';
import { useGlobalStore } from '../../stores/globalStore';
+
const dateFormat = useGlobalStore().getDateFormat;
interface MoreTableProps {
@@ -105,6 +106,7 @@ Licensed under the Elastic License 2.0. */
const enableEditMode = ref(false);
updateEditableStatus();
+
function updateEditableStatus(): void {
if (props.editableAccess) {
enableEditMode.value = props.columns.some((c) => c.editable);
@@ -112,6 +114,7 @@ Licensed under the Elastic License 2.0. */
enableEditMode.value = false;
}
}
+
watch(
() => props.editableAccess,
() => {
@@ -132,6 +135,7 @@ Licensed under the Elastic License 2.0. */
}
const rowIDsInEditMode: Ref = ref([]);
+
function isRowInEditMode(row: any): boolean {
if (row[props.rowId]) {
return rowIDsInEditMode.value.includes(row[props.rowId]);
@@ -140,6 +144,7 @@ Licensed under the Elastic License 2.0. */
}
const editingRows: Ref = ref([]);
+
function setRowToEditMode(row: any): void {
rowIDsInEditMode.value = [];
editingRows.value = [];
@@ -608,7 +613,7 @@ Licensed under the Elastic License 2.0. */
{{ emptyMessage ?? $t('moreTable.defaultEmptyMsg') }}
-
+
@@ -640,6 +645,7 @@ Licensed under the Elastic License 2.0. */
table tbody tr {
font-size: 0.906rem !important;
+
td:last-child {
width: 1%;
white-space: nowrap;
@@ -648,15 +654,19 @@ Licensed under the Elastic License 2.0. */
:deep(td.row-actions) {
pointer-events: none;
+
div {
display: flex;
justify-content: flex-end;
}
+
button {
margin: 0 0.188rem;
}
+
.p-button {
pointer-events: all;
+
&.p-disabled {
pointer-events: none;
}
@@ -681,6 +691,7 @@ Licensed under the Elastic License 2.0. */
&:after {
content: ', ';
}
+
&:last-of-type:after {
content: '';
}
diff --git a/src/components/shared/RelativeScheduler.vue b/src/components/shared/RelativeScheduler.vue
index 78998fc..0f9a2c8 100644
--- a/src/components/shared/RelativeScheduler.vue
+++ b/src/components/shared/RelativeScheduler.vue
@@ -1,25 +1,69 @@
@@ -323,74 +361,101 @@
{
+ const dateVal = Array.isArray(newVal) ? newVal[0] : newVal;
+ startTime = createLuxonDateTime(dateVal) || startTime;
+ clearError(['offsetCorrection']);
+ }
+ "
/>
-
{{ $t('scheduler.dialog.relativeSchedule.endValue') }}
-
+
{
+ const dateVal = Array.isArray(newVal) ? newVal[0] : newVal;
+ endTime = createLuxonDateTime(dateVal) || endTime;
+ clearError(['startTimeBeforeEnd', 'offsetCorrection']);
+ }
+ "
/>
-
- {{ getError('dtend') }}
-
+
+
{{ $t('scheduler.dialog.repeatEvent') }}
@@ -403,84 +468,73 @@
{{ $t('scheduler.dialog.repeatEvery') }}
-
+
-
-
-
- {{
- $t('scheduler.dialog.relativeSchedule.error.rrrule.notValid')
- }}
-
-
- {{
- `${$t(
- 'scheduler.dialog.relativeSchedule.rrrule.repeated',
- )}: ${frequencyXTimes} ${$t(
- 'scheduler.dialog.relativeSchedule.rrrule.times',
- )}`
- }}
-
-
-
- {{ getError('rrruleFreq') }}
+
+ {{
+ $t('scheduler.dialog.relativeSchedule.rrrule.runTime', {
+ repetitionNum: frequencyXTimes,
+ })
+ }}
+
{{ $t('scheduler.dialog.endAfter') }}
-
+
-
-
- {{
- `${$t('scheduler.dialog.relativeSchedule.rrrule.endsAfter', totalDays)} `
- }}
-
-
-
- {{ getError('rrruleEndAfter') }}
+
+ {{
+ `${$t('scheduler.dialog.relativeSchedule.rrrule.endsAfter', totalDays)} `
+ }}
+
+
+
+ {{ $t('scheduler.dialog.relativeSchedule.error.cannotRepeat') }}
+
@@ -489,7 +543,11 @@
:label="$t('global.labels.cancel')"
@click="cancel()"
/>
-
+
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/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..6ad5abe
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,2 @@
+export const minutesInDay = 1440;
+export const minutesInHour = 60;
diff --git a/src/i18n/de.json b/src/i18n/de.json
index bb14525..61826cc 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 darf {maxDuration} Tag(e) nicht überschreiten.",
"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": {
@@ -1052,12 +1039,11 @@
"dayExplanation": "Der Begriff 'Tag' bezeichnet die Korrelation zwischen dem Beginn der Studie eines einzelnen Teilnehmers und dem entsprechenden Tageswert. (Beispiel: Ein/e Teilnehmer:in meldet sich am {egLoginDate} an, der Studienbeginn ist auf Tag 2 festgelegt. Sie beginnen am {egStartDate}.)",
"rrrule": {
"endsAfter": "Endet 0 Tage nach dem individuellen Studienstart | Endet 1 Tag nach dem individuellen Studienstart | Endet {n} Tage nach dem individuellen Studienstart",
- "repeated": "Wiederholung",
- "times": "Mal"
+ "runTime": "Das Event wird {repetitionNum} Mal ausgeführt."
},
"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 +1056,16 @@
"addOffset": "Befülle den Offset des relativen Endzeitraum.",
"EndBeforeStart": "Bitte setzen Sie den relativen Startzeitpunkt vor dem Endzeitpunkt"
},
+ "scheduleTooLong": "Das Datenerhebungs-Event darf den Studienzeitraum von maximal {maxDuration} Tagen nicht überschreiten.",
+ "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 dürfen das Studienende nicht überschreiten. Das maximale Intervall beträgt {repValue} {repUnit}.",
+ "repetitionEndTooLong": "Das Ende der Wiederholungen darf die Studiendauer von {durValue} {durUnit} nicht überschreiten."
+ },
+ "cannotRepeat": "Das Datenerhebungs-Event kann nicht erneut stattfinden, da dies über das Ende der Studie hinausgehen würde."
}
}
},
@@ -1085,7 +1076,6 @@
"rruleEndIsEmpty": "Bitte legen Sie einen Endpunkt für Ihren Wiederholungszeitraum fest."
}
},
-
"tooltips": {
"deleteBtn": "Löschen",
"editBtn": "Bearbeiten",
@@ -1112,7 +1102,6 @@
"relativeDateInfo": "Damit wird der Zeitpunkt simuliert, an dem ein Teilnehmer in die Studie eintritt."
}
},
-
"userstatus": {
"active": "aktiv",
"approved": "genehmigt",
@@ -1125,7 +1114,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..1c18096 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 not exceed {maxDuration} day(s).",
"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": {
@@ -1052,8 +1039,7 @@
"dayExplanation": "The term 'Day' signifies the correlation between the start of an individual participant's study and the corresponding day value. (Example: Participant logs in on {egLoginDate}, study start is set to Day 2. They start on {egStartDate}.)",
"rrrule": {
"endsAfter": "Ends {days} day after individual study start | Ends {days} day after individual study start | Ends {days} days after individual study start",
- "repeated": "Repeated",
- "times": "times"
+ "runTime": "The observation will run {repetitionNum} times."
},
"placeholder": {
"dtstartOffset": "Enter offset in day(s)",
@@ -1070,11 +1056,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 exceed the study period of {maxDuration} days.",
+ "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 must not exceed the end of the study. The maximum interval is {repValue} {repUnit}.",
+ "repetitionEndTooLong": "The end of repetitions must not exceed the study duration of {durValue} {durUnit}."
+ },
+ "cannotRepeat": "The observation cannot occur again as it would exceed the end of the study."
}
}
},
@@ -1085,7 +1076,6 @@
"rruleEndIsEmpty": "Please set and end point for your repetition period."
}
},
-
"tooltips": {
"deleteBtn": "Delete",
"editBtn": "Edit",
@@ -1112,7 +1102,6 @@
"relativeDateInfo": "This simulates the point in time when a participant enters the study."
}
},
-
"userstatus": {
"active": "active",
"approved": "approved",
@@ -1122,7 +1111,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..d3555dc
--- /dev/null
+++ b/src/utils/durationUtils.ts
@@ -0,0 +1,50 @@
+import { Duration, DurationUnitEnum } from '../generated-sources/openapi';
+import { minutesInDay, minutesInHour } from '../constants';
+
+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..a2a3e93
--- /dev/null
+++ b/src/utils/relativeScheduleUtils.ts
@@ -0,0 +1,190 @@
+import { Duration } from '../generated-sources/openapi';
+import { DateTime } from 'luxon';
+import { roundAndCeil } from './dataUtils';
+import { minutesToDuration, valueToMinutes } from './durationUtils';
+import { ErrorValue } from '../composable/useErrorHandling';
+import i18n from '../i18n/i18n';
+import { minutesInDay } from '../constants';
+
+/**
+ * Validates the given event offsets and timestamps against a maximum duration constraint.
+ *
+ * @param {Duration} startOffset - The offset duration for the start of the event.
+ * @param {Duration} endOffset - The offset duration for the end of the event.
+ * @param {DateTime} [start] - Optional start timestamp of the event.
+ * @param {DateTime} [end] - Optional end timestamp of the event.
+ * @param {Duration} [maxDuration] - The maximum allowable duration for the event.
+ * @returns {ErrorValue | undefined} - An error object if validation fails; otherwise, undefined.
+ */
+export const correctEvent = (
+ startOffset: Duration,
+ endOffset: Duration,
+ start?: DateTime,
+ end?: DateTime,
+ maxDuration?: Duration,
+): ErrorValue | undefined => {
+ const maxValue = maxDuration?.value;
+ const { t } = i18n.global;
+
+ if (maxValue) {
+ const correctedEndOffset = endOffset.value || 0;
+ const correctedStartOffset = startOffset.value || 0;
+ if (correctedEndOffset > maxValue || correctedStartOffset > maxValue) {
+ return {
+ label: 'offsetCorrection',
+ value: t('scheduler.dialog.relativeSchedule.error.scheduleTooLong', {
+ maxDuration: maxValue,
+ }),
+ };
+ }
+
+ if (
+ correctedEndOffset < correctedStartOffset ||
+ (correctedStartOffset === correctedEndOffset &&
+ start?.isValid &&
+ end?.isValid &&
+ end <= start)
+ ) {
+ return {
+ label: 'offsetCorrection',
+ value: t(
+ 'scheduler.dialog.relativeSchedule.error.dtend.EndBeforeStart',
+ ),
+ };
+ }
+ }
+};
+
+/**
+ * Adjusts and validates event repetition parameters based on given durations,
+ * ensuring they stay within the maximum allowed duration and other constraints.
+ *
+ * @param {Duration} offsetStart - The starting offset duration of the event.
+ * @param {DateTime} startTime - The start timestamp of the event.
+ * @param {Duration} offsetEnd - The ending offset duration of the event.
+ * @param {DateTime} endTime - The end timestamp of the event.
+ * @param {Duration} frequency - The frequency of repetitions for the event.
+ * @param {Duration} frequencyEnd - The maximum duration till event repetitions can occur.
+ * @param {Duration} maxDuration - The maximum allowed duration for the entire event series.
+ * @param {boolean} [correctInPlace=false] - Whether to modify the provided frequency and frequencyEnd in place.
+ * @returns {{
+ * frequencyError: ErrorValue | undefined,
+ * frequencyEndError: ErrorValue | undefined,
+ * repetitionEnabled: boolean,
+ * numberOfRepetitions: number
+ * }} - An object containing error details (if any), and computed repetition settings.
+ */
+export const correctEventRepetition = (
+ offsetStart: Duration,
+ startTime: DateTime,
+ offsetEnd: Duration,
+ endTime: DateTime,
+ frequency: Duration,
+ frequencyEnd: Duration,
+ maxDuration: Duration,
+ correctInPlace: boolean = false,
+): {
+ frequencyError: ErrorValue | undefined;
+ frequencyEndError: ErrorValue | undefined;
+ repetitionEnabled: boolean;
+ numberOfRepetitions: number;
+} => {
+ const { t } = i18n.global;
+
+ 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 correctedFrequencyEnd = frequencyEnd;
+ let frequencyEndError: ErrorValue | undefined;
+
+ const timeRemaining =
+ maxDurationInMinutes - (valueToMinutes(offsetStart) - minutesInDay);
+ if (frequencyEndInMinutes > timeRemaining) {
+ correctedFrequencyEnd = minutesToDuration(timeRemaining, frequencyEnd.unit);
+ frequencyEndInMinutes = timeRemaining;
+ frequencyEndError = {
+ label: 'frequencyEndError',
+ value: t(
+ 'scheduler.dialog.relativeSchedule.error.rrrule.repetitionEndTooLong',
+ {
+ durValue: correctedFrequencyEnd.value,
+ durUnit: t(
+ `scheduler.frequency.${correctedFrequencyEnd.unit?.toString().toLowerCase()}s`,
+ ),
+ },
+ ),
+ };
+ }
+
+ 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 frequencyError: ErrorValue | undefined;
+ if (
+ correctedFrequency.value !== frequency.value ||
+ correctedFrequency.unit !== frequency.unit
+ ) {
+ frequencyError = {
+ label: 'frequencyError',
+ value: t(
+ 'scheduler.dialog.relativeSchedule.error.rrrule.repetitionTooLong',
+ {
+ repValue: correctedFrequency.value,
+ repUnit: t(
+ `scheduler.frequency.${correctedFrequency.unit?.toString().toLowerCase()}s`,
+ ),
+ },
+ ),
+ };
+ }
+
+ if (correctInPlace) {
+ frequency.value = correctedFrequency.value;
+ frequency.unit = correctedFrequency.unit;
+
+ frequencyEnd.value = correctedFrequencyEnd.value;
+ frequencyEnd.unit = correctedFrequencyEnd.unit;
+ }
+
+ const repetitionEnabled = maxFrequencyInMinutes > 0;
+
+ const numberOfRepetitions = Math.ceil(
+ Math.min(maxFrequencyInMinutes, frequencyEndInMinutes) /
+ correctedFrequencyInMinutes,
+ );
+
+ return {
+ frequencyError,
+ frequencyEndError,
+ 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..41c8a39
--- /dev/null
+++ b/src/utils/studyUtils.ts
@@ -0,0 +1,41 @@
+import {
+ Duration,
+ DurationUnitEnum,
+ Study,
+} from '../generated-sources/openapi';
+import { createLuxonDateTime } from './dateUtils';
+import { roundAndCeil } from './dataUtils';
+import { DateTime } from 'luxon';
+
+export const calcStudyDuration = (
+ plannedStart?: DateTime,
+ plannedEnd?: DateTime,
+ duration?: Duration,
+): Duration | undefined => {
+ if (duration) {
+ return duration;
+ }
+ const start = plannedStart?.set({
+ hour: 0,
+ minute: 0,
+ });
+ const end = plannedEnd?.set({
+ hour: 23,
+ minute: 59,
+ });
+ if (start?.isValid && end?.isValid) {
+ return {
+ value: roundAndCeil(end.diff(start, 'day').days),
+ unit: DurationUnitEnum.Day,
+ };
+ }
+};
+
+export const calcStudyDurationFromStudy = (
+ study?: Study,
+): Duration | undefined =>
+ calcStudyDuration(
+ createLuxonDateTime(study?.plannedStart),
+ createLuxonDateTime(study?.plannedEnd),
+ study?.duration,
+ );
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. */
-
+