Event Grouping & Bulk Actions

When managing a large volume of events across multiple clients and time periods, handling items one by one quickly becomes inefficient. Users often need to move entire sets of related tasks together, not individual events. To support this, grouping and bulk interaction are essential.

The starting point is being able to look at the same dataset through different lenses. The demo supports switching between an assignee view, where each row represents a person responsible for the work, and a work type view, where each row represents a task category. A segmented control in the header allows adjusting the time granularity of the timeline while keeping a full year in view, enabling users to switch between quarterly, monthly, or weekly breakdowns. The view selector, grouping toggle, and segmented control are all rendered via the headerTemplate option, keeping the controls integrated into the timeline header. The resourceTemplate option adapts the resource column accordingly, displaying employee avatars and job titles in assignee view, and work type color indicators in type view.

Once the right perspective is selected, individual events are not rendered one by one. Using the eventTemplate option, related tasks are automatically grouped into consolidated blocks per client and per quarter. These blocks can be expanded to reveal the individual tasks inside, or collapsed to keep the timeline readable at scale.

With grouping in place, bulk interaction becomes natural. An entire grouped block can be dragged along the timeline, moving all underlying events simultaneously. The system calculates the time delta and applies it uniformly to every event in the group. At the same time, individual tasks within a group remain accessible and can be edited through the edit button, which opens a range picker to update the event dates. Because both views are projections of the same event store, a change made in one view is immediately reflected in the other.

Get started with this example

Demo

Loading the live demo

Preview in full screen

Group Timeline events into collapsible client/quarter blocks and bulk-drag groups — switch between assignee and work-type views on a full-year layout.

Code

import { CommonModule } from '@angular/common';
import { Component, OnInit, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  formatDate,
  MbscCalendarEvent,
  MbscDatepicker,
  MbscEventcalendar,
  MbscEventcalendarView,
  MbscEventUpdateEvent,
  MbscModule,
  Notifications,
  setOptions ,
} from '@mobiscroll/angular';

setOptions({
  locale:  ,
  theme: ' ',
  themeVariant: ' '
});

const assigneeResources = [
  {
    id: 1,
    name: 'Sarah Johnson',
    title: 'Senior HVAC Technician',
    color: '#4a7c9e',
    img: 'https://img.mobiscroll.com/demos/f1.png',
  },
  {
    id: 2,
    name: 'Mike Chen',
    title: 'Lead Facilities Engineer',
    color: '#7c6ba1',
    img: 'https://img.mobiscroll.com/demos/m1.png',
  },
  {
    id: 3,
    name: 'Emily Rodriguez',
    title: 'Electrical Systems Specialist',
    color: '#c76b8a',
    img: 'https://img.mobiscroll.com/demos/f2.png',
  },
  {
    id: 4,
    name: 'James Wilson',
    title: 'Master Electrician',
    color: '#5a9d76',
    img: 'https://img.mobiscroll.com/demos/m2.png',
  },
  {
    id: 5,
    name: 'Lisa Anderson',
    title: 'Building Automation Technician',
    color: '#d4a056',
    img: 'https://img.mobiscroll.com/demos/f3.png',
  },
  {
    id: 6,
    name: 'David Kim',
    title: 'HVAC Project Manager',
    color: '#4a9dad',
    img: 'https://img.mobiscroll.com/demos/m3.png',
  },
  {
    id: 7,
    name: 'Maria Garcia',
    title: 'Lighting Systems Specialist',
    color: '#c67e5c',
    img: 'https://img.mobiscroll.com/demos/f4.png',
  },
  {
    id: 8,
    name: 'Robert Taylor',
    title: 'Industrial maintenance Lead',
    color: '#7fa650',
    img: 'https://img.mobiscroll.com/demos/m4.png',
  },
];

const typeResources = [
  {
    id: 'installation',
    name: 'installation',
    color: '#3b5998',
  },
  {
    id: 'maintenance',
    name: 'maintenance',
    color: '#2d7a4f',
  },
  {
    id: 'repair',
    name: 'repair',
    color: '#b8621b',
  },
  {
    id: 'inspection',
    name: 'inspection',
    color: '#6b4fa3',
  },
  {
    id: 'upgrade',
    name: 'upgrade',
    color: '#a03a3a',
  },
];

const defaultEvents = [
  {
    id: 1,
    title: 'Office Tower HVAC System Overhaul',
    start: '2026-01-05T00:00',
    end: '2026-03-15T00:00',
    resource: 1,
    type: 'upgrade',
    clientGroup: 'Enterprise Solutions Group',
  },
  {
    id: 2,
    title: 'Executive Floor Climate Control Installation',
    start: '2026-01-10T00:00',
    end: '2026-03-20T00:00',
    resource: 1,
    type: 'installation',
    clientGroup: 'Enterprise Solutions Group',
  },
  {
    id: 3,
    title: 'Headquarters Heating System Maintenance',
    start: '2026-02-01T00:00',
    end: '2026-03-31T00:00',
    resource: 1,
    type: 'maintenance',
    clientGroup: 'Enterprise Solutions Group',
  },
  {
    id: 4,
    title: 'Building Infrastructure Assessment',
    start: '2026-01-15T00:00',
    end: '2026-03-25T00:00',
    resource: 2,
    type: 'inspection',
    clientGroup: 'Enterprise Solutions Group',
  },
  {
    id: 5,
    title: 'Campus Facilities Modernization',
    start: '2026-02-05T00:00',
    end: '2026-03-31T00:00',
    resource: 2,
    type: 'upgrade',
    clientGroup: 'Enterprise Solutions Group',
  },
  // Showing partial data. Download full source.
];

const selectData = [
  {
    text: 'View by Assignee',
    value: 'assignee',
  },
  {
    text: 'View by Type',
    value: 'type',
  },
];

interface GroupData {
  resource: string | number;
  clientGroup: string;
  year: number;
  period: number;
  events: MbscCalendarEvent[];
}

@Component({
  selector: 'app-root', 
  templateUrl: './app.component.html',
  providers: [Notifications],
  standalone: true,
  imports: [CommonModule, FormsModule, MbscModule],
})
export class AppComponent implements OnInit {
  @ViewChild('calendar', { static: false })
  calendar!: MbscEventcalendar;
  @ViewChild('editDatePickerRef', { static: false })
  editDatePicker!: MbscDatepicker;
  constructor(private notify: Notifications) {}

  rawEvents: MbscCalendarEvent[] = defaultEvents;
  displayEvents: MbscCalendarEvent[] = [];
  groupedEvents: MbscCalendarEvent[] = [];
  myResources: any[] = assigneeResources;

  groupBy = 'assignee';
  groupByClientQuarter = false;
  zoomLevel = 'month';
  editingEventId: number | null = null;
  editEventTitle = '';
  editEventDateRange: Date[] = [];

  assigneeResources = assigneeResources;
  typeResources = typeResources;
  selectData = selectData;
  formatDate = formatDate;

  myView: MbscEventcalendarView = {
    timeline: {
      type: 'year',
      resolutionHorizontal: 'month',
      eventHeight: 'variable',
    },
  };

  ngOnInit(): void {
    this.updateView();
  }

  groupEventsByClientQuarter(events: MbscCalendarEvent[]): MbscCalendarEvent[] {
    const groups = new Map<string, GroupData>();
    const result: MbscCalendarEvent[] = [];

    // Save old collapsed states
    const oldCollapsedStates: Record<string, boolean | undefined> = {};
    this.groupedEvents.forEach((ge: any) => {
      const stateKey = `${ge.resource}-${ge.clientGroup}-${ge.year}-${ge.period}`;
      oldCollapsedStates[stateKey] = ge.collapsed;
    });

    // Group events
    events.forEach((event: any) => {
      const resourceId = this.groupBy === 'assignee' ? event.resource : event.type;
      const eventStart = new Date(event.start as string | number | Date);
      const month = eventStart.getMonth();
      const year = eventStart.getFullYear();
      const period = Math.floor(month / 3);
      const groupKey = `${resourceId}-${event.clientGroup}-${year}-${period}`;

      if (!groups.has(groupKey)) {
        groups.set(groupKey, {
          resource: resourceId,
          clientGroup: event.clientGroup,
          year,
          period,
          events: [],
        });
      }
      groups.get(groupKey)!.events.push(event);
    });

    // Create grouped events
    groups.forEach((groupData, groupKey) => {
      const periodEvents = [...groupData.events].sort((a, b) => (a.start! < b.start! ? -1 : a.start! > b.start! ? 1 : 0));

      const resourceList = this.groupBy === 'assignee' ? assigneeResources : typeResources;
      const color = resourceList.find((r) => r.id === groupData.resource)?.color;
      const eventIds = periodEvents.map((e) => e.id).join('-');
      const earliestStart = periodEvents[0].start;
      const latestEnd = periodEvents.reduce((latest, e) => (e.end! > latest! ? e.end : latest), periodEvents[0].end);
      const newId = `group-${groupKey}-${eventIds}`;
      const stateKey = `${groupData.resource}-${groupData.clientGroup}-${groupData.year}-${groupData.period}`;

      result.push({
        id: newId,
        title: groupData.clientGroup,
        resource: groupData.resource,
        clientGroup: groupData.clientGroup,
        year: groupData.year,
        period: groupData.period,
        start: earliestStart,
        end: latestEnd,
        color,
        count: periodEvents.length,
        originalEvents: periodEvents,
        collapsed: stateKey in oldCollapsedStates ? oldCollapsedStates[stateKey] : true,
      } as any);
    });

    return result;
  }

  prepareEventsForDisplay(events: MbscCalendarEvent[]): MbscCalendarEvent[] {
    return events.map((event: any) => ({
      id: event.id,
      title: event.title,
      start: event.start,
      end: event.end,
      resource: this.groupBy === 'assignee' ? event.resource : event.type,
      type: event.type,
      assignee: event.resource,
      clientGroup: event.clientGroup,
      color: '#f8f9fa',
    }));
  }

  updateView(): void {
    const currentResources = this.groupBy === 'assignee' ? assigneeResources : typeResources;

    if (this.groupByClientQuarter) {
      this.groupedEvents = this.groupEventsByClientQuarter(this.rawEvents);
      this.displayEvents = this.groupedEvents;
    } else {
      this.groupedEvents = [];
      this.displayEvents = this.prepareEventsForDisplay(this.rawEvents);
    }

    this.myResources = currentResources;
    this.myView = {
      timeline: {
        type: 'year',
        resolutionHorizontal: this.zoomLevel as 'quarter' | 'month' | 'week',
        eventHeight: 'variable',
      },
    };
  }

  onZoomLevelChange(): void {
    this.updateView();
  }

  onGroupByChange(event: any): void {
    this.groupBy = event.value;
    this.updateView();
  }

  onGroupByClientQuarterChange(event: any): void {
    this.groupByClientQuarter = event.target.checked;
    this.updateView();
  }

  onEventUpdate(args: MbscEventUpdateEvent): void {
    const updatedEvent = args.event as any;
    const oldEvent = args.oldEvent as any;

    if (!this.groupByClientQuarter) {
      const oldResource = (args.oldEvent as any).resource;
      const newResource = updatedEvent.resource;
      const resourceChanged = oldResource !== newResource;

      this.rawEvents = this.rawEvents.map((e: any) => {
        if (e.id === updatedEvent.id) {
          const update: any = { start: updatedEvent.start, end: updatedEvent.end };
          if (resourceChanged) {
            if (this.groupBy === 'assignee') {
              update.resource = newResource;
            } else {
              update.type = newResource;
            }
          }
          return { ...e, ...update };
        }
        return e;
      });

      if (resourceChanged) {
        let fromName: string;
        let toName: string;
        if (this.groupBy === 'assignee') {
          const fromRes = assigneeResources.find((r) => r.id === oldResource);
          const toRes = assigneeResources.find((r) => r.id === newResource);
          fromName = fromRes ? fromRes.name : String(oldResource);
          toName = toRes ? toRes.name : String(newResource);
        } else {
          fromName = String(oldResource);
          toName = String(newResource);
        }
        this.notify.toast({ message: `"${updatedEvent.title}" moved from ${fromName} to ${toName}.` });
      }
      return;
    }

    // Grouped view
    const newStart = new Date(updatedEvent.start).getTime();
    const newEnd = new Date(updatedEvent.end).getTime();
    const oldStart = new Date(oldEvent.start).getTime();
    const oldEnd = new Date(oldEvent.end).getTime();
    const startDelta = newStart - oldStart;
    const endDelta = newEnd - oldEnd;

    if (startDelta === 0 && endDelta === 0) return;

    const movedGroupedEvent = this.groupedEvents.find((ge: any) => ge.id === oldEvent.id) as any;
    if (!movedGroupedEvent) return;

    const { clientGroup: clientGroupName, resource: resourceId, collapsed: wasCollapsed } = movedGroupedEvent;
    const isMove = startDelta === endDelta;

    let eventsToUpdate: any[];

    if (isMove) {
      eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent: any) => ({
        ...originalEvent,
        start: new Date(new Date(originalEvent.start).getTime() + startDelta),
        end: new Date(new Date(originalEvent.end).getTime() + startDelta),
      }));
    } else {
      const oldGroupDuration = oldEnd - oldStart;
      const newGroupDuration = newEnd - newStart;

      eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent: any) => {
        const evStart = new Date(originalEvent.start).getTime();
        const evEnd = new Date(originalEvent.end).getTime();
        const relativeStart = oldGroupDuration > 0 ? (evStart - oldStart) / oldGroupDuration : 0;
        const relativeEnd = oldGroupDuration > 0 ? (evEnd - oldStart) / oldGroupDuration : 1;
        const mappedStart = newStart + relativeStart * newGroupDuration;
        const mappedEnd = newStart + relativeEnd * newGroupDuration;
        const childDurationDays = (mappedEnd - mappedStart) / (1000 * 60 * 60 * 24);
        const finalEnd = childDurationDays < 1 ? mappedStart + 1000 * 60 * 60 * 24 : mappedEnd;
        return {
          ...originalEvent,
          start: new Date(mappedStart),
          end: new Date(finalEnd),
        };
      });
    }

    // Sync into rawEvents
    const updatedIds = new Set(eventsToUpdate.map((e: any) => e.id));
    const updatedMap = new Map(eventsToUpdate.map((e: any) => [e.id, e]));

    this.rawEvents = this.rawEvents.map((e: any) => (updatedIds.has(e.id) ? updatedMap.get(e.id)! : e));

    // Rebuild view
    this.updateView();

    // Restore collapsed state
    const newYear = new Date(eventsToUpdate[0].start).getFullYear();
    const newPeriod = Math.floor(new Date(eventsToUpdate[0].start).getMonth() / 3);

    const newGroupedEvent = this.groupedEvents.find(
      (ge: any) => ge.resource === resourceId && ge.clientGroup === clientGroupName && ge.year === newYear && ge.period === newPeriod,
    ) as any;

    if (newGroupedEvent) {
      newGroupedEvent.collapsed = wasCollapsed;
      this.displayEvents = [...this.groupedEvents];
    }

    const actionLabel = isMove ? 'moved' : 'resized';
    this.notify.toast({
      message: `${eventsToUpdate.length} event(s) for ${clientGroupName} have been ${actionLabel}.`,
    });
  }

  toggleGroupCollapse(groupEvent: any): void {
    this.groupedEvents = this.groupedEvents.map((ge: any) => (ge.id === groupEvent.id ? { ...ge, collapsed: !ge.collapsed } : ge));
    this.displayEvents = [...this.groupedEvents];

    setTimeout(() => {
      this.calendar.refresh();
    }, 200);
  }

  getUniqueItems(originalEvents: any[]): { count: number; label: string } {
    const uniqueItems: Record<string, any> = {};

    originalEvents.forEach((ev: any) => {
      if (this.groupBy === 'assignee') {
        const typeObj = typeResources.find((r) => r.id === ev.type);
        if (typeObj) uniqueItems[typeObj.id] = typeObj;
      } else {
        const emp = assigneeResources.find((r) => r.id === ev.resource);
        if (emp) uniqueItems[emp.id] = emp;
      }
    });

    return {
      count: Object.keys(uniqueItems).length,
      label: this.groupBy === 'assignee' ? 'type' : 'employee',
    };
  }

  getDetailInfo(event: any): { detailText: string; typeDotColor?: string; avatarUrl?: string } {
    let detailText = '';
    let typeDotColor: string | undefined;
    let avatarUrl: string | undefined;

    if (this.groupBy === 'assignee') {
      const typeObj = typeResources.find((t) => t.id === event.type);
      if (typeObj) {
        detailText = typeObj.name;
        typeDotColor = typeObj.color;
      }
    } else {
      const employee = assigneeResources.find((r) => r.id === event.assignee);
      if (employee) {
        detailText = employee.name;
        avatarUrl = employee.img;
      }
    }

    return { detailText, typeDotColor, avatarUrl };
  }

  formatEventDate(date: any): string {
    return formatDate('DD MMM', new Date(date));
  }

  editEvent(ev: any): void {
    this.editingEventId = ev.id;
    this.editEventTitle = ev.title;
    this.editEventDateRange = [new Date(ev.start), new Date(ev.end)];
    this.editDatePicker.open();
  }

  onEditDateChange(args: any): void {
    const dates = args.value;
    const startVal = dates && dates[0];
    const endVal = dates && dates[1];

    if (this.editingEventId !== null && startVal && endVal) {
      const oldEvent = this.rawEvents.find((e: any) => e.id === this.editingEventId) as any;
      if (!oldEvent) return;

      const oldQuarter = Math.floor(new Date(oldEvent.start).getMonth() / 3);
      const newQuarter = Math.floor(new Date(startVal).getMonth() / 3);
      const oldYear = new Date(oldEvent.start).getFullYear();
      const newYear = new Date(startVal).getFullYear();
      const quarterChanged = this.groupByClientQuarter && (oldQuarter !== newQuarter || oldYear !== newYear);

      const applyUpdate = () => {
        this.rawEvents = this.rawEvents.map((e: any) => (e.id === this.editingEventId ? { ...e, start: startVal, end: endVal } : e));
        this.updateView();
      };

      if (quarterChanged) {
        const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4'];
        const fromLabel = `${quarterNames[oldQuarter]} ${oldYear}`;
        const toLabel = `${quarterNames[newQuarter]} ${newYear}`;
        this.notify.confirm({
          title: 'Move to different group',
          message: `"${oldEvent.title}" will move from ${fromLabel} to ${toLabel}. Do you want to continue?`,
          okText: 'Move',
          cancelText: 'Cancel',
          callback: (result: boolean) => {
            if (result) {
              applyUpdate();
              this.notify.toast({ message: `"${oldEvent.title}" moved to ${toLabel}.` });
            }
          },
        });
      } else {
        applyUpdate();
        this.notify.toast({ message: `"${oldEvent.title}" dates updated.` });
      }
    }
  }

  getEventMarginLeft(ev: any, group: any): string {
    const groupStart = new Date(group.start).getTime();
    const groupEnd = new Date(group.end).getTime();
    const groupDuration = groupEnd - groupStart;
    const evStart = new Date(ev.start).getTime();
    return groupDuration > 0 ? ((evStart - groupStart) / groupDuration) * 100 + '%' : '0%';
  }

  getEventWidth(ev: any, group: any): string {
    const groupStart = new Date(group.start).getTime();
    const groupEnd = new Date(group.end).getTime();
    const groupDuration = groupEnd - groupStart;
    const evStart = new Date(ev.start).getTime();
    const evEnd = new Date(ev.end).getTime();
    return groupDuration > 0 ? ((evEnd - evStart) / groupDuration) * 100 + '%' : '100%';
  }
}

Duplicate Session

You have signed in on a different machine. To keep everything in sync, you have been signed out of this session.

If you need to invite team members, manage seats and accounts you can do it from the licenses page.

Duplicate Session

We noticed you are logged in on a different machine and have closed your old session.

If you need to invite team members, manage seats and accounts you can do it from the licenses page.