opf/openproject

View on GitHub
frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts

Summary

Maintainability
D
2 days
Test Coverage
import {
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  EventEmitter,
  Injector,
  Input,
  Output,
  SecurityContext,
  ViewChild,
  ViewEncapsulation,
} from '@angular/core';
import { FullCalendarComponent } from '@fullcalendar/angular';
import { States } from 'core-app/core/states/states.service';
import * as moment from 'moment';
import { Moment } from 'moment';
import { StateService } from '@uirouter/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { DomSanitizer } from '@angular/platform-browser';
import timeGrid from '@fullcalendar/timegrid';
import {
  CalendarOptions,
  Duration,
  EventApi,
  EventInput,
} from '@fullcalendar/core';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource';
import { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
import interactionPlugin from '@fullcalendar/interaction';
import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
import { TimeEntryEditService } from 'core-app/shared/components/time_entries/edit/edit.service';
import { TimeEntryCreateService } from 'core-app/shared/components/time_entries/create/create.service';
import { ColorsService } from 'core-app/shared/components/colors/colors.service';
import { BrowserDetector } from 'core-app/core/browser/browser-detector.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { FilterOperator } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { OpCalendarService } from 'core-app/features/calendar/op-calendar.service';

interface CalendarViewEvent {
  el:HTMLElement;
  event:EventApi;
}

interface CalendarMoveEvent {
  el:HTMLElement;
  event:EventApi;
  oldEvent:EventApi;
  delta:Duration;
  revert:() => void;
}

// An array of all the days that are displayed. The zero index represents Monday.
export type DisplayedDays = [boolean, boolean, boolean, boolean, boolean, boolean, boolean];

const TIME_ENTRY_CLASS_NAME = 'te-calendar--time-entry';
const DAY_SUM_CLASS_NAME = 'te-calendar--day-sum';
const ADD_ENTRY_CLASS_NAME = 'te-calendar--add-entry';
const ADD_ICON_CLASS_NAME = 'te-calendar--add-icon';
const ADD_ENTRY_PROHIBITED_CLASS_NAME = '-prohibited';

@Component({
  templateUrl: './te-calendar.template.html',
  styleUrls: ['./te-calendar.component.sass'],
  selector: 'te-calendar',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    OpCalendarService,
    TimeEntryEditService,
    TimeEntryCreateService,
    HalResourceEditingService,
  ],
})
export class TimeEntryCalendarComponent {
  @ViewChild(FullCalendarComponent) ucCalendar:FullCalendarComponent;

  @Input() projectIdentifier:string;

  @Input() static = false;

  @Input() set displayedDays(days:DisplayedDays) {
    this.setHiddenDays(days);
  }

  @Output() entries = new EventEmitter<CollectionResource<TimeEntryResource>>();

  // Not used by the calendar but rather is the maximum/minimum of the graph.
  public minHour = 1;

  public maxHour = 12;

  public labelIntervalHours = 2;

  public scaleRatio = 1;

  public calendarEvents:Function;

  protected memoizedTimeEntries:{ start:Date, end:Date, entries:Promise<CollectionResource<TimeEntryResource>> };

  public memoizedCreateAllowed = false;

  public hiddenDays:number[] = [];

  public text = {
    logTime: this.i18n.t('js.button_log_time'),
  };

  calendarOptions:CalendarOptions = {
    editable: false,
    locale: this.i18n.locale,
    fixedWeekCount: false,
    headerToolbar: {
      right: '',
      center: 'title',
      left: 'prev,next today',
    },
    initialView: 'timeGridWeek',
    firstDay: this.configuration.startOfWeek(),
    hiddenDays: [],
    // This is a magic number that is derived by trial and error
    contentHeight: 550,
    slotEventOverlap: false,
    slotLabelInterval: `${this.labelIntervalHours}:00:00`,
    slotLabelFormat: (info:any) => ((this.maxHour - info.date.hour) / this.scaleRatio).toString(),
    allDaySlot: false,
    displayEventTime: false,
    slotMinTime: `${this.minHour - 1}:00:00`,
    slotMaxTime: `${this.maxHour}:00:00`,
    events: this.calendarEventsFunction.bind(this),
    eventOverlap: (stillEvent:any) => !stillEvent.classNames.includes(TIME_ENTRY_CLASS_NAME),
    plugins: [timeGrid, interactionPlugin],
    eventDidMount: this.alterEventEntry.bind(this),
    eventWillUnmount: this.beforeEventRemove.bind(this),
    eventClick: this.dispatchEventClick.bind(this),
    eventDrop: this.moveEvent.bind(this),
  };

  constructor(readonly states:States,
    readonly apiV3Service:ApiV3Service,
    readonly $state:StateService,
    private element:ElementRef,
    readonly i18n:I18nService,
    readonly injector:Injector,
    readonly notifications:HalResourceNotificationService,
    private sanitizer:DomSanitizer,
    private configuration:ConfigurationService,
    private timezone:TimezoneService,
    private timeEntryEdit:TimeEntryEditService,
    private timeEntryCreate:TimeEntryCreateService,
    private schemaCache:SchemaCacheService,
    private colors:ColorsService,
    private browserDetector:BrowserDetector,
  ) {}

  public calendarEventsFunction(fetchInfo:{ start:Date, end:Date },
    successCallback:(events:EventInput[]) => void,
    failureCallback:(error:unknown) => void):void|PromiseLike<EventInput[]> {
    this.fetchTimeEntries(fetchInfo.start, fetchInfo.end)
      .then((collection) => {
        this.entries.emit(collection);

        successCallback(this.buildEntries(collection.elements, fetchInfo));
      });
  }

  protected fetchTimeEntries(start:Date, end:Date) {
    if (!this.memoizedTimeEntries
      || this.memoizedTimeEntries.start.getTime() !== start.getTime()
      || this.memoizedTimeEntries.end.getTime() !== end.getTime()) {
      const promise = this
        .apiV3Service
        .time_entries
        .list({ filters: this.dmFilters(start, end), pageSize: 500 })
        .toPromise()
        .then((collection) => {
          this.memoizedCreateAllowed = !!collection.createTimeEntry;

          return collection;
        });

      this.memoizedTimeEntries = { start, end, entries: promise };
    }

    return this.memoizedTimeEntries.entries;
  }

  private buildEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }) {
    this.setRatio(entries);

    return this.buildTimeEntryEntries(entries)
      .concat(this.buildAuxEntries(entries, fetchInfo));
  }

  private setRatio(entries:TimeEntryResource[]) {
    const dateSums = this.calculateDateSums(entries);

    const maxHours = Math.max(...Object.values(dateSums), 0);

    const oldRatio = this.scaleRatio;

    if (maxHours > this.maxHour - this.minHour) {
      this.scaleRatio = this.smallerSuitableRatio((this.maxHour - this.minHour) / maxHours);
    } else {
      this.scaleRatio = 1;
    }

    if (oldRatio !== this.scaleRatio) {
      // This is a hack.
      // We already set the same function (different object) via angular.
      // But it will trigger repainting the calendar.
      // Weirdly, this.ucCalendar.getApi().rerender() does not.
      this.ucCalendar.getApi().setOption('slotLabelFormat', (info:any) => {
        const val = (this.maxHour - info.date.hour) / this.scaleRatio;
        return val.toString();
      });
    }
  }

  private buildTimeEntryEntries(entries:TimeEntryResource[]) {
    const hoursDistribution:{ [key:string]:Moment } = {};

    return entries.map((entry) => {
      let start:Moment;
      let end:Moment;
      const hours = this.timezone.toHours(entry.hours) * this.scaleRatio;

      if (hoursDistribution[entry.spentOn]) {
        start = hoursDistribution[entry.spentOn].clone().subtract(hours, 'h');
        end = hoursDistribution[entry.spentOn].clone();
      } else {
        start = moment(entry.spentOn).add(this.maxHour - hours, 'h');
        end = moment(entry.spentOn).add(this.maxHour, 'h');
      }

      hoursDistribution[entry.spentOn] = start;

      const color = this.colors.toHsl(this.entryName(entry));

      return this.timeEntry(entry, hours, start, end);
    }) as EventInput[];
  }

  private buildAuxEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }) {
    const dateSums = this.calculateDateSums(entries);

    const calendarEntries:EventInput[] = [];

    for (let m = moment(fetchInfo.start); m.diff(fetchInfo.end, 'days') <= 0; m.add(1, 'days')) {
      const duration = dateSums[m.format('YYYY-MM-DD')] || 0;

      calendarEntries.push(this.sumEntry(m, duration));

      if (this.memoizedCreateAllowed) {
        calendarEntries.push(this.addEntry(m, duration));
      }
    }

    return calendarEntries;
  }

  private calculateDateSums(entries:TimeEntryResource[]) {
    const dateSums:{ [key:string]:number } = {};

    entries.forEach((entry) => {
      const hours = this.timezone.toHours(entry.hours);

      if (dateSums[entry.spentOn]) {
        dateSums[entry.spentOn] += hours;
      } else {
        dateSums[entry.spentOn] = hours;
      }
    });

    return dateSums;
  }

  protected timeEntry(entry:TimeEntryResource, hours:number, start:Moment, end:Moment) {
    const color = this.colors.toHsl(this.entryName(entry));

    const classNames = [TIME_ENTRY_CLASS_NAME];

    const span = end.diff(start, 'm');

    if (span < 40) {
      classNames.push('-no-fadeout');
    }

    return {
      title: span < 20 ? '' : this.entryName(entry),
      startEditable: !!entry.update,
      start: start.format(),
      end: end.format(),
      backgroundColor: color,
      borderColor: color,
      classNames,
      entry,
    };
  }

  protected sumEntry(date:Moment, duration:number) {
    return {
      start: date.clone().add(this.maxHour - Math.min(duration * this.scaleRatio, this.maxHour - 0.5) - 0.5, 'h').format(),
      end: date.clone().add(this.maxHour - Math.min(((duration + 0.05) * this.scaleRatio), this.maxHour - 0.5), 'h').format(),
      classNames: DAY_SUM_CLASS_NAME,
      rendering: 'background' as const,
      startEditable: false,
      sum: this.i18n.t('js.units.hour', { count: this.formatNumber(duration) }),
    };
  }

  protected addEntry(date:Moment, duration:number) {
    const classNames = [ADD_ENTRY_CLASS_NAME];

    if (duration >= 24) {
      classNames.push(ADD_ENTRY_PROHIBITED_CLASS_NAME);
    }

    return {
      start: date.clone().format(),
      end: date.clone().add(this.maxHour - Math.min(duration * this.scaleRatio, this.maxHour - 1) - 0.5, 'h').format(),
      rendering: 'background' as const,
      classNames,
    };
  }

  protected dmFilters(start:Date, end:Date):Array<[string, FilterOperator, string[]]> {
    const startDate = moment(start).format('YYYY-MM-DD');
    const endDate = moment(end).subtract(1, 'd').format('YYYY-MM-DD');
    return [['spentOn', '<>d', [startDate, endDate]] as [string, FilterOperator, string[]],
      ['user_id', '=', ['me']] as [string, FilterOperator, [string]]];
  }

  private dispatchEventClick(event:CalendarViewEvent) {
    if (event.event.extendedProps.entry) {
      this.editEvent(event.event.extendedProps.entry);
    } else if (event.el.classList.contains(ADD_ENTRY_CLASS_NAME) && !event.el.classList.contains(ADD_ENTRY_PROHIBITED_CLASS_NAME)) {
      this.addEvent(moment(event.event.start!));
    }
  }

  private editEvent(entry:TimeEntryResource) {
    this
      .timeEntryEdit
      .edit(entry)
      .then((modificationAction) => {
        this.updateEventSet(modificationAction.entry, modificationAction.action);
      })
      .catch(() => {
        // do nothing, the user closed without changes
      });
  }

  private moveEvent(event:CalendarMoveEvent) {
    const { entry } = event.event.extendedProps;

    // Use end instead of start as when dragging, the event might be too long and would thus be start
    // on the day before by fullcalendar.
    entry.spentOn = moment(event.event.end!).format('YYYY-MM-DD');

    this
      .schemaCache
      .ensureLoaded(entry)
      .then((schema) => {
        this
          .apiV3Service
          .time_entries
          .id(entry)
          .patch(entry, schema)
          .subscribe(
            (event) => this.updateEventSet(event, 'update'),
            (e) => {
              this.notifications.handleRawError(e);
              event.revert();
            },
          );
      });
  }

  public addEventToday() {
    this.addEvent(moment(new Date()));
  }

  private addEvent(date:Moment) {
    if (!this.memoizedCreateAllowed) {
      return;
    }

    this
      .timeEntryCreate
      .create(date)
      .then((modificationAction) => {
        this.updateEventSet(modificationAction.entry, modificationAction.action);
      })
      .catch(() => {
        // do nothing, the user closed without changes
      });
  }

  private updateEventSet(event:TimeEntryResource, action:'update'|'destroy'|'create') {
    this.memoizedTimeEntries.entries.then((collection) => {
      const foundIndex = collection.elements.findIndex((x) => x.id === event.id);

      switch (action) {
        case 'update':
          collection.elements[foundIndex] = event;
          break;
        case 'destroy':
          collection.elements.splice(foundIndex, 1);
          break;
        case 'create':
          this
            .apiV3Service
            .time_entries
            .cache
            .updateFor(event);

          collection.elements.push(event);
          break;
      }

      this.ucCalendar.getApi().refetchEvents();
    });
  }

  private alterEventEntry(event:CalendarViewEvent) {
    this.appendAddIcon(event);
    this.appendSum(event);

    if (!event.event.extendedProps.entry) {
      return;
    }

    this.addTooltip(event);
    this.prependDuration(event);
    this.appendFadeout(event);
  }

  private appendAddIcon(event:CalendarViewEvent) {
    if (!event.el.classList.contains(ADD_ENTRY_CLASS_NAME)) {
      return;
    }

    const addIcon = document.createElement('div');
    addIcon.classList.add(ADD_ICON_CLASS_NAME);
    addIcon.innerText = '+';
    event.el.append(addIcon);
  }

  private appendSum(event:CalendarViewEvent) {
    if (event.event.extendedProps.sum) {
      event.el.innerHTML = event.event.extendedProps.sum;
    }
  }

  private addTooltip(event:CalendarViewEvent) {
    if (this.browserDetector.isMobile) {
      return;
    }

    jQuery(event.el).tooltip({
      content: this.tooltipContentString(event.event.extendedProps.entry),
      items: '.fc-event',
      close() {
        jQuery('.ui-helper-hidden-accessible').remove();
      },
      track: true,
    });
  }

  private removeTooltip(event:CalendarViewEvent) {
    jQuery(event.el).tooltip('disable');
  }

  private prependDuration(event:CalendarViewEvent) {
    const timeEntry = event.event.extendedProps.entry;

    if (this.timezone.toHours(timeEntry.hours) < 0.5) {
      return;
    }

    const formattedDuration = this.timezone.formattedDuration(timeEntry.hours);

    jQuery(event.el)
      .find('.fc-event-title')
      .prepend(`<div class="fc-duration">${formattedDuration}</div>`);
  }

  /* Fade out event text to the bottom to avoid it being cut of weirdly.
  * Multiline ellipsis with an unknown height is not possible, hence we blur the text.
  * The gradient needs to take the background color of the element into account (hashed over the event
  * title) which is why the style is set in code.
  *
  * We do not print anything on short entries (< 0.5 hours),
  * which leads to the fc-short class not being applied by full calendar. For other short events, the css rules
  * need to deactivate the fc-fadeout.
   */
  private appendFadeout(event:CalendarViewEvent) {
    const timeEntry = event.event.extendedProps.entry;

    if (this.timezone.toHours(timeEntry.hours) < 0.5) {
      return;
    }

    const $element = jQuery(event.el);
    const fadeout = jQuery('<div class="fc-fadeout"></div>');

    const hslaStart = this.colors.toHsla(this.entryName(timeEntry), 0);
    const hslaEnd = this.colors.toHsla(this.entryName(timeEntry), 100);

    fadeout.css('background', `-webkit-linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%`);

    ['-moz-linear-gradient', '-o-linear-gradient', 'linear-gradient', '-ms-linear-gradient'].forEach(((style) => {
      fadeout.css('background-image', `${style}(${hslaStart} 0%, ${hslaEnd} 100%`);
    }));

    $element
      .append(fadeout);
  }

  private beforeEventRemove(event:CalendarViewEvent) {
    if (!event.event.extendedProps.entry) {
      return;
    }

    this.removeTooltip(event);
  }

  private entryName(entry:TimeEntryResource) {
    let { name } = entry.project;
    if (entry.workPackage) {
      name += ` - ${this.workPackageName(entry)}`;
    }

    return name || '-';
  }

  private workPackageName(entry:TimeEntryResource) {
    return `#${idFromLink(entry.workPackage.href)}: ${entry.workPackage.name}`;
  }

  private tooltipContentString(entry:TimeEntryResource) {
    return `
        <ul class="tooltip--map">
          <li class="tooltip--map--item">
            <span class="tooltip--map--key">${this.i18n.t('js.time_entry.project')}:</span>
            <span class="tooltip--map--value">${this.sanitizedValue(entry.project.name)}</span>
          </li>
          <li class="tooltip--map--item">
            <span class="tooltip--map--key">${this.i18n.t('js.time_entry.work_package')}:</span>
            <span class="tooltip--map--value">${entry.workPackage ? this.sanitizedValue(this.workPackageName(entry)) : this.i18n.t('js.placeholders.default')}</span>
          </li>
          <li class="tooltip--map--item">
            <span class="tooltip--map--key">${this.i18n.t('js.time_entry.activity')}:</span>
            <span class="tooltip--map--value">${this.sanitizedValue(entry.activity.name)}</span>
          </li>
          <li class="tooltip--map--item">
            <span class="tooltip--map--key">${this.i18n.t('js.time_entry.hours')}:</span>
            <span class="tooltip--map--value">${this.timezone.formattedDuration(entry.hours)}</span>
          </li>
          <li class="tooltip--map--item">
            <span class="tooltip--map--key">${this.i18n.t('js.time_entry.comment')}:</span>
            <span class="tooltip--map--value">${entry.comment.raw || this.i18n.t('js.placeholders.default')}</span>
          </li>
        `;
  }

  private sanitizedValue(value:string) {
    return this.sanitizer.sanitize(SecurityContext.HTML, value);
  }

  protected formatNumber(value:number):string {
    return this.i18n.toNumber(value, { precision: 2 });
  }

  private smallerSuitableRatio(value:number):number {
    for (let divisor = this.labelIntervalHours + 1; divisor < 100; divisor++) {
      const candidate = this.labelIntervalHours / divisor;

      if (value >= candidate) {
        return candidate;
      }
    }

    return 1;
  }

  protected setHiddenDays(displayedDays:DisplayedDays) {
    const hiddenDays:number[] = Array
      .from(displayedDays, (value, index) => {
        if (!value) {
          return (index + 1) % 7;
        }
        return null;
      })
      .filter((value) => value !== null) as number[];

    this.calendarOptions = { ...this.calendarOptions, hiddenDays };
  }
}