diff --git a/ui/src/app/calendar/booking-calendar.component.ts b/ui/src/app/calendar/booking-calendar.component.ts index 905a58bdf..090a78ff7 100644 --- a/ui/src/app/calendar/booking-calendar.component.ts +++ b/ui/src/app/calendar/booking-calendar.component.ts @@ -74,6 +74,8 @@ export class BookingCalendarComponent implements OnInit, OnChanges, AfterViewIni @ViewChild('fc') calendar!: FullCalendarComponent; calendarOptions = signal({}); + searchStart = DateTime.now().startOf('week').toISO(); + searchEnd = DateTime.now().endOf('week').toISO(); constructor( private translate: TranslateService, @@ -94,11 +96,9 @@ export class BookingCalendarComponent implements OnInit, OnChanges, AfterViewIni events: this.refetch, eventClick: this.eventClicked.bind(this), }); - this.translate.onLangChange.subscribe((event) => { - this.calendarOptions.set({ ...this.calendarOptions(), locale: event.lang }); - //this.calendar.getApi().destroy(); - //this.calendar.getApi().render(); - }); + this.translate.onLangChange.subscribe((event) => + this.calendarOptions.set({ ...this.calendarOptions(), locale: event.lang }), + ); } ngOnInit() { @@ -118,19 +118,19 @@ export class BookingCalendarComponent implements OnInit, OnChanges, AfterViewIni ngOnChanges(changes: SimpleChanges) { if (changes.room && this.room) { - const earliestOpening = this.Calendar.getEarliestOpening(this.room); + const earliestOpening = this.Calendar.getEarliestOpening(this.room, this.searchStart, this.searchEnd); const minTime = earliestOpening.getHours() > 1 ? DateTime.fromJSDate(earliestOpening).minus({ hour: 1 }).toJSDate() : earliestOpening; - const latestClosing = this.Calendar.getLatestClosing(this.room); + const latestClosing = this.Calendar.getLatestClosing(this.room, this.searchStart, this.searchEnd); const maxTime = latestClosing.getHours() < 23 ? DateTime.fromJSDate(latestClosing).plus({ hour: 1 }).toJSDate() : latestClosing; this.calendarOptions.update((cos) => ({ ...cos, - hiddenDays: this.Calendar.getClosedWeekdays(this.room), + hiddenDays: this.Calendar.getClosedWeekdays(this.room, this.searchStart, this.searchEnd), slotMinTime: DateTime.fromJSDate(minTime).toFormat('HH:mm:ss'), slotMaxTime: DateTime.fromJSDate(maxTime).toFormat('HH:mm:ss'), timeZone: this.room.localTimezone, @@ -141,8 +141,22 @@ export class BookingCalendarComponent implements OnInit, OnChanges, AfterViewIni this.calendar.getApi().refetchEvents(); } } - refetch = (input: { startStr: string; timeZone: string }, success: (events: EventInput[]) => void) => + + refetch = (input: { startStr: string; timeZone: string }, success: (events: EventInput[]) => void) => { + this.searchStart = input.startStr; + this.searchEnd = DateTime.fromISO(input.startStr).endOf('week').toISO() as string; + const hidden = this.Calendar.getClosedWeekdays(this.room, this.searchStart, this.searchEnd); + const earliestOpening = this.Calendar.getEarliestOpening(this.room, this.searchStart, this.searchEnd); + const latestClosing = this.Calendar.getLatestClosing(this.room, this.searchStart, this.searchEnd); + this.calendarOptions.update((cos) => ({ + ...cos, + hiddenDays: hidden, + slotMinTime: DateTime.fromJSDate(earliestOpening).toFormat('HH:mm:ss'), + slotMaxTime: DateTime.fromJSDate(latestClosing).toFormat('HH:mm:ss'), + })); + this.moreEventsNeeded.emit({ date: input.startStr, timeZone: input.timeZone, success: success }); + }; eventClicked(arg: EventClickArg): void { if (arg.event.extendedProps?.availableMachines > 0) { diff --git a/ui/src/app/calendar/calendar.service.ts b/ui/src/app/calendar/calendar.service.ts index 7cac6745c..e3e646a45 100644 --- a/ui/src/app/calendar/calendar.service.ts +++ b/ui/src/app/calendar/calendar.service.ts @@ -14,7 +14,7 @@ */ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { DateTime } from 'luxon'; +import { DateTime, Interval } from 'luxon'; import type { Observable } from 'rxjs'; import { ExamEnrolment } from '../enrolment/enrolment.model'; import { Course, Exam, ExamSection, MaintenancePeriod } from '../exam/exam.model'; @@ -68,7 +68,7 @@ export class CalendarService { constructor( private http: HttpClient, - private DateTime: DateTimeService, + private DateTimeService: DateTimeService, private Session: SessionService, ) {} @@ -101,7 +101,7 @@ export class CalendarService { const lang = this.Session.getUser().lang; const locale = lang.toLowerCase() + '-' + lang.toUpperCase(); const options: Intl.DateTimeFormatOptions = { weekday: 'short' }; - const weekday = this.DateTime.getDateForWeekday; + const weekday = this.DateTimeService.getDateForWeekday; return { SUNDAY: { ord: 7, name: weekday(0).toLocaleDateString(locale, options) }, MONDAY: { ord: 1, name: weekday(1).toLocaleDateString(locale, options) }, @@ -163,30 +163,41 @@ export class CalendarService { return room.calendarExceptionEvents.map((e) => this.formatExceptionEvent(e, room.localTimezone)); } - getEarliestOpening(room: ExamRoom): Date { + getEarliestOpening(room: ExamRoom, start: string, end: string): Date { const tz = room.localTimezone; - const openings = room.defaultWorkingHours.map((dwh) => { - const start = DateTime.fromISO(dwh.startTime, { zone: tz }); - return DateTime.now().set({ hour: start.hour, minute: start.minute, second: start.second }); - }); - return DateTime.min(...openings) - .set({ minute: 0 }) - .toJSDate(); + const regularOpenings = room.defaultWorkingHours.map((dwh) => + this.normalize(DateTime.fromISO(dwh.startTime, { zone: tz })), + ); + const extraOpenings = room.calendarExceptionEvents + .filter((e) => !e.outOfService && e.startDate >= start && e.endDate <= end) + .flatMap((d) => this.daysBetween(DateTime.fromISO(d.startDate), DateTime.fromISO(d.endDate))) + .map((d) => this.normalize(d.start as DateTime)); + + return DateTime.min(...regularOpenings.concat(extraOpenings)).toJSDate(); } - getLatestClosing(room: ExamRoom): Date { + getLatestClosing(room: ExamRoom, start: string, end: string): Date { const tz = room.localTimezone; - const closings = room.defaultWorkingHours.map((dwh) => { - const end = DateTime.fromISO(dwh.endTime, { zone: tz }); - return DateTime.now().set({ hour: end.hour, minute: end.minute, second: end.second }); - }); - return DateTime.max(...closings).toJSDate(); + const regularClosings = room.defaultWorkingHours.map((dwh) => + this.normalize(DateTime.fromISO(dwh.endTime, { zone: tz })), + ); + const extraClosings = room.calendarExceptionEvents + .filter((e) => !e.outOfService && e.startDate >= start && e.endDate <= end) + .flatMap((d) => this.daysBetween(DateTime.fromISO(d.startDate), DateTime.fromISO(d.endDate))) + .map((d) => this.normalize(d.end as DateTime)); + + return DateTime.max(...regularClosings.concat(extraClosings)).toJSDate(); } - getClosedWeekdays(room: ExamRoom): number[] { + getClosedWeekdays(room: ExamRoom, start: string, end: string): number[] { const weekdays = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; - const openedDays = room.defaultWorkingHours.map((dwh) => weekdays.indexOf(dwh.weekday)); - return [0, 1, 2, 3, 4, 5, 6].filter((x) => openedDays.indexOf(x) === -1); + const regularDays = room.defaultWorkingHours.map((d) => weekdays.indexOf(d.weekday)); + const extraDays = room.calendarExceptionEvents + .filter((e) => !e.outOfService && e.startDate >= start && e.endDate <= end) + .flatMap((d) => this.daysBetween(DateTime.fromISO(d.startDate), DateTime.fromISO(d.endDate))) + .map((d) => (d.start?.weekday === 7 ? 0 : (d.start as DateTime).weekday - 1)); // locale nuisances + + return [0, 1, 2, 3, 4, 5, 6].filter((x) => regularDays.concat(extraDays).indexOf(x) === -1); } listRooms$ = () => this.http.get('/app/rooms'); @@ -211,6 +222,8 @@ export class CalendarService { getExamInfo$ = (collaborative: boolean, id: number) => this.http.get(collaborative ? `/app/iop/exams/${id}/info` : `/app/student/exam/${id}/info`); + private daysBetween = (start: DateTime, end: DateTime) => Interval.fromDateTimes(start, end).splitBy({ day: 1 }); + private normalize = (d: DateTime) => DateTime.now().set({ hour: d.hour, minute: d.minute, second: d.second }); private adjustBack(date: DateTime): string { const offset = date.isInDST ? 1 : 0; return date.toUTC().plus({ hour: offset }).toISO() as string; diff --git a/ui/src/app/calendar/helpers/selected-room.component.ts b/ui/src/app/calendar/helpers/selected-room.component.ts index df6ff240b..67f4e9f32 100644 --- a/ui/src/app/calendar/helpers/selected-room.component.ts +++ b/ui/src/app/calendar/helpers/selected-room.component.ts @@ -51,7 +51,7 @@ import { CalendarService } from '../calendar.service'; } - @if (exceptionHours.length > 0) { + @if (exceptionHours().length > 0) {
{{ 'i18n_exception_datetimes' | translate }}:
diff --git a/ui/src/app/enrolment/active/dialogs/select-examination-event-dialog.component.ts b/ui/src/app/enrolment/active/dialogs/select-examination-event-dialog.component.ts index 6159fb3fd..e09d4f424 100644 --- a/ui/src/app/enrolment/active/dialogs/select-examination-event-dialog.component.ts +++ b/ui/src/app/enrolment/active/dialogs/select-examination-event-dialog.component.ts @@ -18,8 +18,8 @@ import type { OnInit } from '@angular/core'; import { Component, Input } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { parseISO } from 'date-fns'; import type { Exam, ExaminationEventConfiguration } from '../../../exam/exam.model'; +import { DateTime } from 'luxon'; @Component({ selector: 'xm-select-examination-event-dialog', @@ -84,7 +84,11 @@ export class SelectExaminationEventDialogComponent implements OnInit { ngOnInit() { // for all confs over this.configs = this.exam.examinationEventConfigurations - .filter((ec) => parseISO(ec.examinationEvent.start) > new Date() && ec.id !== this.existingEventId) + .filter( + (ec) => + DateTime.fromISO(ec.examinationEvent.start).toJSDate() > new Date() && + ec.id !== this.existingEventId, + ) .sort((a, b) => (a.examinationEvent.start < b.examinationEvent.start ? -1 : 1)); } diff --git a/ui/src/app/enrolment/finished/exam-feedback.component.ts b/ui/src/app/enrolment/finished/exam-feedback.component.ts index a3bc9e35e..cc97fac1c 100644 --- a/ui/src/app/enrolment/finished/exam-feedback.component.ts +++ b/ui/src/app/enrolment/finished/exam-feedback.component.ts @@ -17,12 +17,12 @@ import { HttpClient } from '@angular/common/http'; import { Component, Input, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { format } from 'date-fns'; import { Exam } from 'src/app/exam/exam.model'; import { AttachmentService } from '../../shared/attachment/attachment.service'; import { FileService } from '../../shared/file/file.service'; import type { ReviewedExam, Scores } from '../enrolment.model'; import { ExamAnswersDialogComponent } from './exam-answers-dialog.component'; +import { DateTime } from 'luxon'; @Component({ selector: 'xm-exam-feedback', @@ -76,6 +76,11 @@ export class ExamFeedbackComponent implements OnInit { downloadScoreReport = () => { const url = `/app/feedback/exams/${this.assessment.id}/report`; - this.Files.download(url, `${this.assessment.name}_${format(new Date(), 'dd-MM-yyyy')}.xlsx`, undefined, false); + this.Files.download( + url, + `${this.assessment.name}_${DateTime.now().toFormat('dd-LL-yyyy')}.xlsx`, + undefined, + false, + ); }; } diff --git a/ui/src/app/enrolment/wrong-location/wrong-location.service.ts b/ui/src/app/enrolment/wrong-location/wrong-location.service.ts index 817edf513..062e72787 100644 --- a/ui/src/app/enrolment/wrong-location/wrong-location.service.ts +++ b/ui/src/app/enrolment/wrong-location/wrong-location.service.ts @@ -14,7 +14,6 @@ */ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { format, parseISO } from 'date-fns'; import { DateTime } from 'luxon'; import { ToastrService } from 'ngx-toastr'; @@ -50,10 +49,10 @@ export class WrongLocationService { }; displayWrongUserAgent = (startsAtTxt: string) => { - const startsAt = parseISO(startsAtTxt); // TODO: what about timezone here? - if (startsAt > new Date()) { + const startsAt = DateTime.fromISO(startsAtTxt); // TODO: what about timezone here? + if (startsAt.toJSDate() > new Date()) { this.toast.warning( - `${this.translate.instant('i18n_seb_exam_about_to_begin')} ${format(startsAt, 'HH:mm')}`, + `${this.translate.instant('i18n_seb_exam_about_to_begin')} ${startsAt.toFormat('HH:mm')}`, '', // TODO: should we have some title for this (needs translation) { timeOut: 10000 }, ); diff --git a/ui/src/app/facility/schedule/exception-dialog.component.html b/ui/src/app/facility/schedule/exception-dialog.component.html index 29d901e96..d502ebdc4 100644 --- a/ui/src/app/facility/schedule/exception-dialog.component.html +++ b/ui/src/app/facility/schedule/exception-dialog.component.html @@ -31,38 +31,17 @@

  {{ 'i18n_exception_time' | tran

{{ 'i18n_repeating_info' | translate }}
-
- - -
- @for (ro of repeatOptions; track ro) { - - } -
-
+
+ +
+ @for (ro of repeatOptions; track ro) { + + } +
@if (repeats.toString() === 'ONCE') { diff --git a/ui/src/app/facility/schedule/exception-dialog.component.ts b/ui/src/app/facility/schedule/exception-dialog.component.ts index ea1fe6adc..6f988c235 100644 --- a/ui/src/app/facility/schedule/exception-dialog.component.ts +++ b/ui/src/app/facility/schedule/exception-dialog.component.ts @@ -1,7 +1,7 @@ import { formatDate, NgClass } from '@angular/common'; import { Component, Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { NgbActiveModal, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbActiveModal, NgbDropdownModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { areIntervalsOverlapping, eachDayOfInterval } from 'date-fns'; import { ToastrService } from 'ngx-toastr'; @@ -9,7 +9,7 @@ import { range } from 'ramda'; import { DatePickerComponent } from 'src/app/shared/date/date-picker.component'; import { DateTimePickerComponent } from 'src/app/shared/date/date-time-picker.component'; import { ExceptionWorkingHours } from '../../reservation/reservation.model'; -import { DateTimeService, REPEAT_OPTIONS } from '../../shared/date/date.service'; +import { DateTimeService, REPEAT_OPTION } from '../../shared/date/date.service'; import { ConfirmationDialogService } from '../../shared/dialogs/confirmation-dialog.service'; enum ORDINAL { @@ -22,7 +22,15 @@ enum ORDINAL { @Component({ standalone: true, - imports: [FormsModule, TranslateModule, NgClass, DateTimePickerComponent, DatePickerComponent, NgbTimepickerModule], + imports: [ + FormsModule, + TranslateModule, + NgClass, + DateTimePickerComponent, + DatePickerComponent, + NgbTimepickerModule, + NgbDropdownModule, + ], templateUrl: './exception-dialog.component.html', }) export class ExceptionDialogComponent { @@ -43,8 +51,8 @@ export class ExceptionDialogComponent { dayOfMonth = 1; selectableWeekDays: { selected: boolean; day: string; number: number }[]; selectableMonths: { selected: boolean; month: string; number: number }[]; - repeatOptions: REPEAT_OPTIONS[] = Object.values(REPEAT_OPTIONS); - repeats: REPEAT_OPTIONS = REPEAT_OPTIONS.once; + repeatOptions: REPEAT_OPTION[] = Object.values(REPEAT_OPTION); + repeats: REPEAT_OPTION = REPEAT_OPTION.once; isNumericNotWeekday = true; weeks = [range(1, 8), range(8, 15), range(15, 22), range(22, 29)]; ordinals: { ordinal: string; number: number }[] = Object.values(ORDINAL).map((o, i) => ({ ordinal: o, number: i })); @@ -82,7 +90,7 @@ export class ExceptionDialogComponent { this.endTime.minute = 59; } if ( - this.repeats === REPEAT_OPTIONS.once + this.repeats === REPEAT_OPTION.once ? this.startDate >= this.endDate : this.startTime.hour * 100 + this.startTime.minute >= this.endTime.hour * 100 + this.endTime.minute || new Date(this.startDate.getFullYear(), this.startDate.getMonth() + 1, this.startDate.getDate()) > @@ -335,7 +343,7 @@ export class ExceptionDialogComponent { } } - updateRepeatOption(select: REPEAT_OPTIONS) { + updateRepeatOption(select: REPEAT_OPTION) { this.repeats = select; } diff --git a/ui/src/app/shared/date/apply-dst.pipe.ts b/ui/src/app/shared/date/apply-dst.pipe.ts index c53a6241f..a5d5d262f 100644 --- a/ui/src/app/shared/date/apply-dst.pipe.ts +++ b/ui/src/app/shared/date/apply-dst.pipe.ts @@ -14,20 +14,20 @@ */ import type { PipeTransform } from '@angular/core'; import { Pipe } from '@angular/core'; -import { addHours, formatISO, parseISO } from 'date-fns'; import { DateTimeService } from './date.service'; +import { DateTime } from 'luxon'; @Pipe({ name: 'applyDst', standalone: true, }) export class ApplyDstPipe implements PipeTransform { - constructor(private DateTime: DateTimeService) {} + constructor(private DateTimeService: DateTimeService) {} transform = (input?: string): string => { if (!input) return ''; - const date = parseISO(input); - if (this.DateTime.isDST(date)) { - return formatISO(addHours(date, -1)); + const date = DateTime.fromISO(input); + if (this.DateTimeService.isDST(date.toJSDate())) { + return date.minus({ hours: 1 }).toISO() as string; } return input; }; diff --git a/ui/src/app/shared/date/date.service.ts b/ui/src/app/shared/date/date.service.ts index a7a58d956..3555e4c44 100644 --- a/ui/src/app/shared/date/date.service.ts +++ b/ui/src/app/shared/date/date.service.ts @@ -14,11 +14,10 @@ */ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { format, roundToNearestMinutes } from 'date-fns'; import { DateTime, WeekdayNumbers } from 'luxon'; import { range } from 'ramda'; -export enum REPEAT_OPTIONS { +export enum REPEAT_OPTION { once = 'ONCE', daily_weekly = 'DAILY_WEEKLY', monthly = 'MONTHLY', @@ -44,8 +43,7 @@ export class DateTimeService { return ''; } - getDuration = (timestamp: string) => - format(roundToNearestMinutes(DateTime.fromISO(timestamp, { zone: 'UTC' }).toJSDate()), 'HH:mm'); + getDuration = (timestamp: string) => DateTime.fromISO(timestamp, { zone: 'UTC' }).toFormat('HH:mm'); formatInTimeZone = (date: Date, tz: string) => DateTime.fromJSDate(date, { zone: tz }).toISO();