diff --git a/projects/ngx-resource-calendar/src/lib/models/calendar-event.model.ts b/projects/ngx-resource-calendar/src/lib/models/calendar-event.model.ts index f99b888..007a0d3 100644 --- a/projects/ngx-resource-calendar/src/lib/models/calendar-event.model.ts +++ b/projects/ngx-resource-calendar/src/lib/models/calendar-event.model.ts @@ -1,7 +1,16 @@ export class CalendarEventModel { + // Event model's original data. data: T; + + // Display height in pixels. height: number; + + // Y-position on the calendar. position: number; + + // Display width in pixels. width: string; + + // Left margin. left: string; } diff --git a/projects/ngx-resource-calendar/src/lib/models/date-with-resources.model.ts b/projects/ngx-resource-calendar/src/lib/models/date-with-resources.model.ts new file mode 100644 index 0000000..8fee578 --- /dev/null +++ b/projects/ngx-resource-calendar/src/lib/models/date-with-resources.model.ts @@ -0,0 +1,10 @@ +import { DayModel } from './day.model'; +import { InternalResourceModel } from './internal-resource.model'; + +export interface DateWithEventsModel { + // Target date. + data: DayModel; + + // Date's resources. + resources: InternalResourceModel[]; +} diff --git a/projects/ngx-resource-calendar/src/lib/models/internal-resource.model.ts b/projects/ngx-resource-calendar/src/lib/models/internal-resource.model.ts new file mode 100644 index 0000000..23f78ce --- /dev/null +++ b/projects/ngx-resource-calendar/src/lib/models/internal-resource.model.ts @@ -0,0 +1,8 @@ +import { ResourceModel } from './resource.model'; +import { CalendarEventModel } from './calendar-event.model'; +import { EventModel } from './event.model'; + +export interface InternalResourceModel extends ResourceModel { + data: ResourceModel; + events: CalendarEventModel[]; +} diff --git a/projects/ngx-resource-calendar/src/lib/models/resource.model.ts b/projects/ngx-resource-calendar/src/lib/models/resource.model.ts index fdf94f9..6618f49 100644 --- a/projects/ngx-resource-calendar/src/lib/models/resource.model.ts +++ b/projects/ngx-resource-calendar/src/lib/models/resource.model.ts @@ -1,6 +1,7 @@ import { SlotModel } from './slot.model'; +import { CalendarEventModel } from './calendar-event.model'; export class ResourceModel { resourceNumber: number | string; - slots: SlotModel[]; + slots: SlotModel[] | CalendarEventModel[]; } diff --git a/projects/ngx-resource-calendar/src/lib/resource-calendar.component.html b/projects/ngx-resource-calendar/src/lib/resource-calendar.component.html new file mode 100644 index 0000000..69169e3 --- /dev/null +++ b/projects/ngx-resource-calendar/src/lib/resource-calendar.component.html @@ -0,0 +1,118 @@ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+ + +{{ + day.day | date: 'shortDate' + }} +{{ + resource.resourceNumber + }} +{{ + time | date: 'shortTime' + }} + +{{ + event.resourceNumber + }} +{{ + slot.startTime | date: 'shortTime' + }} diff --git a/projects/ngx-resource-calendar/src/lib/resource-calendar.component.scss b/projects/ngx-resource-calendar/src/lib/resource-calendar.component.scss new file mode 100644 index 0000000..4fb8e6d --- /dev/null +++ b/projects/ngx-resource-calendar/src/lib/resource-calendar.component.scss @@ -0,0 +1,57 @@ +.header { + flex-direction: row; + box-sizing: border-box; + display: flex; + place-content: stretch flex-start; + align-items: stretch; +} + +.calendar { + flex-direction: row; + box-sizing: border-box; + display: flex; + place-content: stretch flex-start; + align-items: stretch; +} + +.resources { + flex-direction: row; + box-sizing: border-box; + display: flex; + place-content: stretch flex-start; + align-items: stretch; +} + +.resource { + flex: 1 1 100%; + position: relative; + box-sizing: border-box; +} + +.hour-sub-slot { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.hour-row { + width: 10%; + min-width: 50px; +} + +.day-row { + position: relative; + width: 90%; +} + +.slot { + position: absolute; + overflow: hidden; + z-index: 1; +} + +.event { + position: absolute; + z-index: 2; + overflow: hidden; +} diff --git a/projects/ngx-resource-calendar/src/lib/resource-calendar.component.ts b/projects/ngx-resource-calendar/src/lib/resource-calendar.component.ts index bff0a7b..ef65f1b 100644 --- a/projects/ngx-resource-calendar/src/lib/resource-calendar.component.ts +++ b/projects/ngx-resource-calendar/src/lib/resource-calendar.component.ts @@ -11,190 +11,14 @@ import { DayModel } from './models/day.model'; import { HourModel } from './models/hour.model'; import { SlotModel } from './models/slot.model'; import { CalendarEventModel } from './models/calendar-event.model'; +import { ResourceModel } from './models/resource.model'; +import { DateWithEventsModel } from './models/date-with-resources.model'; +import { InternalResourceModel } from './models/internal-resource.model'; @Component({ selector: 'pinja-resource-calendar', - template: ` -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- -
- -
- -
- -
-
-
-
- - - {{ - day.day | date: 'shortDate' - }} - {{ - resource.resourceNumber - }} - {{ - time | date: 'shortTime' - }} - - {{ - event.resourceNumber - }} - {{ - slot.startTime | date: 'shortTime' - }} - `, - styles: [ - ` - .header { - flex-direction: row; - box-sizing: border-box; - display: flex; - place-content: stretch flex-start; - align-items: stretch; - } - - .calendar { - flex-direction: row; - box-sizing: border-box; - display: flex; - place-content: stretch flex-start; - align-items: stretch; - } - - .resources { - flex-direction: row; - box-sizing: border-box; - display: flex; - place-content: stretch flex-start; - align-items: stretch; - } - - .resource { - flex: 1 1 100%; - position: relative; - box-sizing: border-box; - } - - .hour-sub-slot { - display: flex; - align-items: center; - justify-content: flex-end; - } - - .hour-row { - width: 10%; - min-width: 50px; - } - - .day-row { - position: relative; - width: 90%; - } - - .slot { - position: absolute; - overflow: hidden; - z-index: 1; - } - - .event { - position: absolute; - z-index: 2; - overflow: hidden; - } - `, - ], + templateUrl: 'resource-calendar.component.html', + styleUrl: 'resource-calendar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ResourceCalendarComponent implements OnChanges { @@ -206,12 +30,12 @@ export class ResourceCalendarComponent implements OnChanges { /** * First hour to draw in the calendar. */ - @Input() startHour: number = null; + @Input() startHour: number | null = null; /** * Last hour to draw in the calendar. */ - @Input() endHour: number = null; + @Input() endHour: number | null = null; /** * An array of events to show on view. @@ -219,7 +43,18 @@ export class ResourceCalendarComponent implements OnChanges { @Input() events: EventModel[] = []; /** - * How long is one slot duration in minutes. + * Duration (in minutes) of a calendar slot within an hour. + * This value must be between 1 and 60 (inclusive) to ensure valid + * slot breakdowns within an hour. A value of 30 defines slots + * of 30 minutes each, while a value of 60 defines a single slot + * spanning the entire hour. Choosing a value that is not a + * divisor of 60 will result in leftover minutes that cannot be used for + * complete slots. + * @example + * 60/30=2 ✔️ + * 60/15=4 ✔️ + * 60/45=1.3 ❌ + * 60/25=2.4 ❌ */ @Input() slotDurationInMinutes = 15; @@ -271,21 +106,23 @@ export class ResourceCalendarComponent implements OnChanges { /** * Dates with events in resources */ - public datesWithEvents: any[] = []; + public datesWithEvents: DateWithEventsModel[] = []; - public ngOnChanges(changes: SimpleChanges) { + public ngOnChanges(changes: SimpleChanges): void { + // Rebuild slots for hours when the date list changes. if (changes.dates && changes.dates.currentValue) { const dates: DayModel[] = changes.dates.currentValue; + this.hours = []; + if ( - dates.length === 0 || - this.startHour === null || - this.endHour === null + dates.length > 0 && + typeof this.startHour === 'number' && + typeof this.endHour === 'number' ) { - this.hours = []; - } else { - this.hours = []; + // For each hour, we calculate possible slots based on the user specified duration. for (let hour = this.startHour; hour <= this.endHour; hour++) { const slots: Date[] = []; + for ( let hourSlot = 0; hourSlot < 60; @@ -293,6 +130,7 @@ export class ResourceCalendarComponent implements OnChanges { ) { slots.push(this.createDate(dates[0].day, hour, hourSlot)); } + this.hours.push({ slots }); } } @@ -303,27 +141,31 @@ export class ResourceCalendarComponent implements OnChanges { } } - private setResourceEvents() { - if (this.dates && this.dates.length > 0) { - this.datesWithEvents = []; - this.dates.forEach((d) => { - const resources = []; - const startTime = this.createDate(d.day, this.startHour, 0); - - d.resources.forEach((r) => { - resources.push({ - data: r, - slots: this.getSlots(r.slots, startTime), - events: this.getEvents(r.resourceNumber, startTime), - }); - }); - - this.datesWithEvents.push({ - data: d, - resources, - }); - }); + /** + * Creates dates with events by combining resources to dates. + * @private + */ + private setResourceEvents(): void { + if (!Array.isArray(this.dates) || this.dates.length === 0) { + return; } + + this.datesWithEvents = this.dates.map((dayModel: DayModel): DateWithEventsModel => { + const startTime: Date = this.createDate(dayModel.day, this.startHour, 0); + const resources: InternalResourceModel[] = dayModel.resources.map(( + resourceModel: ResourceModel + ): InternalResourceModel => ({ + resourceNumber: resourceModel.resourceNumber, + data: resourceModel, + slots: this.getSlots(resourceModel.slots, startTime), + events: this.getEvents(resourceModel.resourceNumber, startTime), + })); + + return { + data: dayModel, + resources, + }; + }); } /** @@ -337,61 +179,71 @@ export class ResourceCalendarComponent implements OnChanges { return []; } - const endDate = new Date(day); + const endDate: Date = new Date(day); endDate.setDate(endDate.getDate() + 1); - const dayStart = day.getTime(); - const dayEnd = endDate.getTime(); + const dayStart: number = day.getTime(); + const dayEnd: number = endDate.getTime(); - const events = this.events.filter( - (m) => + const events: EventModel[] = this.events.filter( + (m: EventModel) => m.resourceNumber === resourceNumber && m.startTime.getTime() >= dayStart && m.endTime.getTime() < dayEnd ); // Calculate position and height for events - return events.map((event) => { - return { - data: event, - position: this.calculatePosition(event, day), - height: this.calculateHeight(event), - left: event.left || '0', - width: event.width || '100%', - }; - }); + return events.map((event: EventModel): CalendarEventModel => ({ + data: event, + position: this.calculatePosition(event, day), + height: this.calculateHeight(event), + left: event.left || '0', + width: event.width || '100%', + })); } /** * Gets slots for a day's resource */ private getSlots( - slots: SlotModel[], - day: Date + slots: SlotModel[] | CalendarEventModel[], + day: Date, ): CalendarEventModel[] { if (!slots || slots.length === 0) { return []; } // Calculate position and height for slots - return slots.map((slot) => { + return slots.map(( + slot: SlotModel | CalendarEventModel + ): CalendarEventModel => { + const plainSlot: SlotModel = this.instanceOfCalendarEventModel(slot) ? slot.data : slot; return { - data: slot, - position: this.calculatePosition(slot, day), - height: this.calculateHeight(slot), - left: slot.left || '0', - width: slot.width || '100%', + data: plainSlot, + position: this.calculatePosition(plainSlot, day), + height: this.calculateHeight(plainSlot), + left: plainSlot.left || '0', + width: plainSlot.width || '100%', }; }); } /** - * Calculates events top position. Floors to closest minute. - * - * @param event Event + * Checks if the given slot is CalendarEventModel. + * @private + */ + private instanceOfCalendarEventModel( + slot: SlotModel | CalendarEventModel + ): slot is CalendarEventModel { + const fieldName: keyof CalendarEventModel = 'data'; + return fieldName in slot; + } + + /** + * Calculates events top position. Floors to the closest minute. */ private calculatePosition(event: EventModel | SlotModel, day: Date): number { - const diffInMinutes = + const diffInMinutes: number = (event.startTime.getTime() - day.getTime()) / 1000 / 60; return Math.floor( @@ -401,15 +253,13 @@ export class ResourceCalendarComponent implements OnChanges { /** * Calculates events height. Floors to nearest minute. - * - * @param event Event */ private calculateHeight(event: EventModel | SlotModel): number { - const diffInMinutes = + const diffInMinutes: number = (event.endTime.getTime() - event.startTime.getTime()) / 1000 / 60; if (diffInMinutes <= 0) { - return 1 * this.height; + return this.height; } return (diffInMinutes / this.slotDurationInMinutes) * this.height; @@ -419,12 +269,12 @@ export class ResourceCalendarComponent implements OnChanges { * Creates a new date from given date, hour and minutes. * * @param date Date time - * @param hours Time in hours - * @param minutes Time in minutes + * @param hours Time in hours (in local timezone) + * @param minutes Time in minutes (in local timezone) * @returns Date time set time */ private createDate(date: Date, hours: number, minutes: number): Date { - const newDate = new Date(date); + const newDate: Date = new Date(date); newDate.setHours(hours); newDate.setMinutes(minutes); return newDate;