---
title: "Vue Timeline Event Grouping & Bulk Actions Example | Mobiscroll"
component: "Timeline"
framework: "Vue"
library_version: "6.1.5"
url: "https://demo.mobiscroll.com/vue/timeline/event-grouping-bulk-action"
---

# Event Grouping & Bulk Actions - Vue Timeline demo

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

- [Live demo](https://demo.mobiscroll.com/vue/timeline/event-grouping-bulk-action)
- [View SFC JS code on GitHub](https://github.com/acidb/mobiscroll-demos-vue/tree/main/src/demos/eventcalendar/timeline/event-grouping-bulk-action)
- [View SFC TS code on GitHub](https://github.com/acidb/mobiscroll-demos-vue-ts/tree/main/src/demos/eventcalendar/timeline/event-grouping-bulk-action)
- This demo in other frameworks: [JavaScript](https://demo.mobiscroll.com/javascript/timeline/event-grouping-bulk-action.md), [jQuery](https://demo.mobiscroll.com/jquery/timeline/event-grouping-bulk-action.md), [Angular](https://demo.mobiscroll.com/angular/timeline/event-grouping-bulk-action.md), [React](https://demo.mobiscroll.com/react/timeline/event-grouping-bulk-action.md)

## Description

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 [`header`](https://mobiscroll.com/docs/vue/eventcalendar/timeline#slot-header) slot, keeping the controls integrated into the timeline header. The [`resource` slot](https://mobiscroll.com/docs/vue/eventcalendar/timeline#slot-resource) 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 [`timelineEvent`](https://mobiscroll.com/docs/vue/eventcalendar/timeline#slot-timelineEvent) slot, 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.

## What this demo shows

- A desktop monthly timeline for event grouping and bulk actions, with January to December months arranged horizontally and resources arranged vertically on the left.
- **Header navigation** The year label in the top left opens month navigation, while the blue previous and next arrows and the Today button on the right make it easy to move between months and jump back to the current day.
- **Custom parts of the header** In the header there are three custom options all arranged to the right but before the navigation arrows and today button: a checkable Group by Client/Quarter option, allows related projects to be organized by customer and reporting period. Next to it a dropdown select from which the user can select to show resourses view by ASssignee or View by Type. The last option is a segmented button with which you can change the zoom level of the calendar by default is set to Monthly, but you can change to Quearterly or Weekly.
- **Month view** The fixed strip below the header shows the selected years months from January to December, with the current month highlighted.

## Best for

- **Portfolio management** Reviewing client or portfolio-level work across a full year while keeping individual projects available on demand.
- **Program planning** Coordinating multiple related projects by customer, quarter, assignee, or project type.
- **Enterprise project roadmaps** Showing long-running initiatives with expandable summary rows and detailed project cards in the same timeline.
- **Capital project tracking** Planning installation, maintenance, inspection, upgrade, and repair work across months or quarters.
- **Long-term resource planning** Comparing workload by employee or work category without changing the underlying project data.
- **Bulk schedule adjustments** Moving grouped project work together when related tasks need to shift as a set.

## API used in this demo

- [`theme`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-theme) - option
- [`themeVariant`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-themeVariant) - option
- [`cssClass`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-cssClass) - option
- [`data`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-data) - option
- [`view`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-view) - option
- [`resources`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-resources) - option
- [`dragToMove`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-dragToMove) - option
- [`dragToResize`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-dragToResize) - option
- [`dragToCreate`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-dragToCreate) - option
- [`clickToCreate`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-clickToCreate) - option
- [`dragBetweenResources`](https://mobiscroll.com/docs/vue/eventcalendar/api#opt-dragBetweenResources) - option
- [`event-updated`](https://mobiscroll.com/docs/vue/eventcalendar/api#event-onEventUpdated) - event
- [`theme`](https://mobiscroll.com/docs/vue/forms/checkbox#opt-theme) - option
- [`inputStyle`](https://mobiscroll.com/docs/vue/select/api#opt-inputStyle) - option
- [`display`](https://mobiscroll.com/docs/vue/select/api#opt-display) - option
- [`touchUi`](https://mobiscroll.com/docs/vue/select/api#opt-touchUi) - option
- [`data`](https://mobiscroll.com/docs/vue/select/api#opt-data) - option
- [`change`](https://mobiscroll.com/docs/vue/select/api#event-onChange) - event
- [`close`](https://mobiscroll.com/docs/vue/notifications/toast#event-onClose) - event
- [`controls`](https://mobiscroll.com/docs/vue/datepicker/api#opt-controls) - option
- [`select`](https://mobiscroll.com/docs/vue/datepicker/api#opt-select) - option
- [`display`](https://mobiscroll.com/docs/vue/datepicker/api#opt-display) - option
- [`touchUi`](https://mobiscroll.com/docs/vue/datepicker/api#opt-touchUi) - option
- [`showRangeLabels`](https://mobiscroll.com/docs/vue/datepicker/api#opt-showRangeLabels) - option
- [`headerText`](https://mobiscroll.com/docs/vue/datepicker/api#opt-headerText) - option
- [`isOpen`](https://mobiscroll.com/docs/vue/datepicker/api#opt-isOpen) - option
- [`close`](https://mobiscroll.com/docs/vue/datepicker/api#event-onClose) - event
- [`change`](https://mobiscroll.com/docs/vue/datepicker/api#event-onChange) - event
- [`close`](https://mobiscroll.com/docs/vue/notifications/confirm#event-onClose) - event

## Code

### SFC JS

```vue
<script setup>
import {
  formatDate,
  MbscCalendarNav,
  MbscCalendarNext,
  MbscCalendarPrev,
  MbscCalendarToday,
  MbscCheckbox,
  MbscConfirm,
  MbscDatepicker,
  MbscEventcalendar,
  MbscSegmented,
  MbscSegmentedGroup,
  MbscSelect,
  MbscToast,
  setOptions
} from '@mobiscroll/vue'
import { computed, ref } from 'vue'

setOptions({
  theme: 'ios',
  themeVariant: 'light'
})

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: '#7fa650',
    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: '#4a9dad',
    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: '#5a9d76',
    img: 'https://img.mobiscroll.com/demos/m3.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 selectData = [
  {
    text: 'View by Assignee',
    value: 'assignee'
  },
  {
    text: 'View by Type',
    value: 'type'
  }
]

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 zoomLevel = ref('month')
const myView = computed(() => ({
  timeline: {
    type: 'year',
    resolutionHorizontal: zoomLevel.value,
    eventHeight: 'variable'
  }
}))

// Reactive state
const rawEvents = ref([...defaultEvents])
const displayEvents = ref([])
const groupedEvents = ref([])
const myResources = ref(assigneeResources)
const groupBy = ref('assignee')
const groupByClientQuarter = ref(false)
const isToastOpen = ref(false)
const toastMessage = ref('')
const calendarRef = ref(null)
const isConfirmOpen = ref(false)
const confirmMessage = ref('')
let confirmCallback = null
const editingEventId = ref(null)
const editEventTitle = ref('')
const editEventDateRange = ref([])
const isDatePickerOpen = ref(false)

function groupEventsByClientQuarter(events) {
  const groups = new Map()
  const result = []

  // Save old collapsed states
  const oldCollapsedStates = {}
  groupedEvents.value.forEach((ge) => {
    const stateKey = `${ge.resource}-${ge.clientGroup}-${ge.year}-${ge.period}`
    oldCollapsedStates[stateKey] = ge.collapsed
  })

  // Group events
  events.forEach((event) => {
    const resourceId = groupBy.value === 'assignee' ? event.resource : event.type
    const eventStart = new Date(event.start)
    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 = groupBy.value === '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
    })
  })

  return result
}

function prepareEventsForDisplay(events) {
  return events.map((event) => ({
    id: event.id,
    title: event.title,
    start: event.start,
    end: event.end,
    resource: groupBy.value === 'assignee' ? event.resource : event.type,
    type: event.type,
    assignee: event.resource,
    clientGroup: event.clientGroup,
    color: '#f8f9fa'
  }))
}

function updateView() {
  const currentResources = groupBy.value === 'assignee' ? assigneeResources : typeResources

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

  myResources.value = currentResources
}

function handleEventUpdated(args) {
  const updatedEvent = args.event
  const oldEvent = args.oldEvent
  const newStart = new Date(updatedEvent.start).getTime()
  const newEnd = new Date(updatedEvent.end).getTime()

  if (groupByClientQuarter.value) {
    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 = groupedEvents.value.find((ge) => ge.id === oldEvent.id)
    if (!movedGroupedEvent) return

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

    let eventsToUpdate

    if (isMove) {
      eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent) => ({
        ...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) => {
        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)
        return {
          ...originalEvent,
          start: new Date(mappedStart),
          end: new Date(childDurationDays < 1 ? mappedStart + 1000 * 60 * 60 * 24 : mappedEnd)
        }
      })
    }

    const updatedMap = new Map(eventsToUpdate.map((e) => [e.id, e]))
    rawEvents.value = rawEvents.value.map((e) => (updatedMap.has(e.id) ? updatedMap.get(e.id) : e))

    updateView()

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

    const newGroupedEvent = groupedEvents.value.find(
      (ge) =>
        ge.resource === resourceId &&
        ge.clientGroup === clientGroupName &&
        ge.year === newYear &&
        ge.period === newPeriod
    )

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

    const actionLabel = isMove ? 'moved' : 'resized'
    toastMessage.value = `${eventsToUpdate.length} event(s) for ${clientGroupName} have been ${actionLabel}.`
    isToastOpen.value = true
  } else {
    const oldResource = oldEvent.resource
    const newResource = updatedEvent.resource
    const resourceChanged = oldResource !== newResource

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

    if (resourceChanged) {
      let fromName
      let toName
      if (groupBy.value === '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)
      }
      toastMessage.value = `"${updatedEvent.title}" moved from ${fromName} to ${toName}.`
      isToastOpen.value = true
    }
  }
}

function toggleGroupCollapse(groupEvent) {
  groupedEvents.value = groupedEvents.value.map((ge) =>
    ge.id === groupEvent.id ? { ...ge, collapsed: !ge.collapsed } : ge
  )
  displayEvents.value = [...groupedEvents.value]

  // Refresh calendar to recalculate row heights
  setTimeout(() => {
    calendarRef.value?.instance?.refresh()
  }, 200)
}

function getUniqueItems(originalEvents) {
  const uniqueItems = {}

  originalEvents.forEach((ev) => {
    if (groupBy.value === '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: groupBy.value === 'assignee' ? 'type' : 'employee'
  }
}

function getDetailInfo(event) {
  let detailText = ''
  let typeDotColor = undefined
  let avatarUrl = undefined

  if (groupBy.value === '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 }
}

function formatEventDate(date) {
  return formatDate('DD MMM', new Date(date))
}

function getEventMarginLeft(ev, group) {
  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%'
}

function getEventWidth(ev, group) {
  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%'
}

function handleEditEvent(ev) {
  editingEventId.value = ev.id ?? null
  editEventTitle.value = ev.title ?? ''
  editEventDateRange.value = [new Date(ev.start), new Date(ev.end)]
  isDatePickerOpen.value = true
}

function handleEditDateChange(args) {
  const dates = args.value
  const startVal = dates && dates[0]
  const endVal = dates && dates[1]

  if (editingEventId.value === null || !startVal || !endVal) return

  const oldEvent = rawEvents.value.find((e) => e.id === editingEventId.value)
  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 =
    groupByClientQuarter.value && (oldQuarter !== newQuarter || oldYear !== newYear)

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

  if (quarterChanged) {
    const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
    const fromLabel = `${quarterNames[oldQuarter]} ${oldYear}`
    const toLabel = `${quarterNames[newQuarter]} ${newYear}`

    confirmCallback = () => {
      applyUpdate()
      toastMessage.value = `"${oldEvent.title}" moved to ${toLabel}.`
      isToastOpen.value = true
    }
    confirmMessage.value = `"${oldEvent.title}" will move from ${fromLabel} to ${toLabel}. Do you want to continue?`
    isConfirmOpen.value = true
  } else {
    applyUpdate()
    toastMessage.value = `"${oldEvent.title}" dates updated.`
    isToastOpen.value = true
  }
}

function handleConfirmClose(result) {
  isConfirmOpen.value = false
  if (result && confirmCallback) {
    confirmCallback()
    confirmCallback = null
  }
}

// Initialize view on mount
updateView()
</script>

<template>
  <MbscEventcalendar
    ref="calendarRef"
    cssClass="mds-event-group-calendar"
    :data="displayEvents"
    :view="myView"
    :resources="myResources"
    :dragToMove="true"
    :dragToResize="true"
    :dragToCreate="false"
    :clickToCreate="false"
    :dragBetweenResources="!groupByClientQuarter"
    @event-updated="handleEventUpdated"
  >
    <template #header>
      <MbscCalendarNav />
      <div class="mbsc-flex mbsc-flex-1-0 mbsc-justify-content-end mds-event-group-header-controls">
        <MbscCheckbox
          v-model="groupByClientQuarter"
          @change="updateView"
          theme="material"
          label="Group by Client/Quarter"
        />
        <MbscSelect
          inputStyle="box"
          display="anchored"
          :touchUi="false"
          :data="selectData"
          v-model="groupBy"
          @change="updateView"
        />
      </div>
      <MbscSegmentedGroup v-model="zoomLevel">
        <MbscSegmented value="quarter">Quarterly</MbscSegmented>
        <MbscSegmented value="month">Monthly</MbscSegmented>
        <MbscSegmented value="week">Weekly</MbscSegmented>
      </MbscSegmentedGroup>
      <MbscCalendarPrev />
      <MbscCalendarToday />
      <MbscCalendarNext />
    </template>

    <template #resource="resource">
      <div v-if="groupBy === 'assignee'" class="mbsc-flex">
        <img :alt="resource.name" class="mds-event-group-avatar" :src="resource.img" />
        <div class="mds-event-group-cont">
          <div class="mds-event-group-name">{{ resource.name }}</div>
          <div class="mds-event-group-title">{{ resource.title }}</div>
        </div>
      </div>
      <div v-else class="mbsc-flex mds-event-group-type-resource">
        <div class="mds-event-group-type-badge" :style="{ backgroundColor: resource.color }"></div>
        <div class="mds-event-group-type-name">{{ resource.name }}</div>
      </div>
    </template>

    <template #timelineEvent="event">
      <!-- Grouped Event Template -->
      <div
        v-if="groupByClientQuarter"
        class="mbsc-flex mds-event-group-task mds-event-group-task-client"
        :class="{ expanded: !event.original.collapsed }"
        :style="{ borderLeftColor: event.original.color }"
      >
        <div class="mbsc-flex mds-event-group-content">
          <div class="mds-event-group-title-text mds-event-group-text-truncate">
            {{ event.original.clientGroup }}
          </div>
          <div class="mbsc-flex mds-event-group-right">
            <div class="mbsc-flex mds-event-group-meta">
              <div class="mds-event-group-date-range">
                {{ formatEventDate(event.original.start) }} -
                {{ formatEventDate(event.original.end) }}
              </div>
              <div class="mds-event-group-count">
                {{ event.original.count }} task{{ event.original.count > 1 ? 's' : '' }},
                {{ getUniqueItems(event.original.originalEvents).count }}
                {{ getUniqueItems(event.original.originalEvents).label
                }}{{ getUniqueItems(event.original.originalEvents).count > 1 ? 's' : '' }}
              </div>
            </div>
            <div
              class="mbsc-flex mds-event-group-icon mbsc-icon mbsc-font-icon mbsc-icon-material-keyboard-arrow-down"
              @click.stop="toggleGroupCollapse(event.original)"
            ></div>
          </div>
        </div>
        <div class="mds-event-group-events">
          <div class="mds-event-group-events-inner">
            <div
              v-for="ev in event.original.originalEvents"
              v-show="!event.original.collapsed"
              :key="ev.id"
              class="mds-event-group-original-event"
              :title="`${ev.title}, ${formatEventDate(ev.start)} - ${formatEventDate(ev.end)}`"
              :style="{
                marginLeft: getEventMarginLeft(ev, event.original),
                width: getEventWidth(ev, event.original)
              }"
            >
              <div class="mbsc-flex mds-event-group-event-content">
                <div class="mds-event-group-event-title mds-event-group-text-truncate">
                  {{ ev.title }}
                </div>
                <div class="mbsc-flex mds-event-group-event-right">
                  <div class="mds-event-group-event-date mds-event-group-text-truncate">
                    {{ formatEventDate(ev.start) }} - {{ formatEventDate(ev.end) }}
                  </div>
                  <div class="mbsc-flex mds-event-group-event-detail">
                    <img
                      v-if="getDetailInfo(ev).avatarUrl"
                      :src="getDetailInfo(ev).avatarUrl"
                      :alt="getDetailInfo(ev).detailText"
                      class="mds-event-group-event-avatar"
                    />
                    <span
                      v-if="getDetailInfo(ev).typeDotColor"
                      class="mds-event-group-type-dot"
                      :style="{ backgroundColor: getDetailInfo(ev).typeDotColor }"
                    ></span>
                    <div class="mds-event-group-event-info mds-event-group-text-truncate">
                      {{ getDetailInfo(ev).detailText }}
                    </div>
                  </div>
                </div>
                <div
                  class="mds-event-group-edit-btn mbsc-flex mbsc-icon mbsc-font-icon mbsc-icon-pencil"
                  @click.stop="handleEditEvent(ev)"
                ></div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <!-- Simple Event Template -->
      <div v-else class="mbsc-flex mds-event-simple">
        <div class="mds-event-simple-title">
          <div class="mds-event-simple-title-inner mds-event-group-text-truncate">
            {{ event.original.title }}
          </div>
        </div>
        <div class="mbsc-flex mds-event-simple-right">
          <div class="mds-event-simple-date">
            {{ formatEventDate(event.original.start) }} - {{ formatEventDate(event.original.end) }}
          </div>
          <div
            v-if="getDetailInfo(event.original).detailText"
            class="mbsc-flex mds-event-simple-subtitle-wrapper"
          >
            <img
              v-if="getDetailInfo(event.original).avatarUrl"
              :src="getDetailInfo(event.original).avatarUrl"
              :alt="getDetailInfo(event.original).detailText"
              class="mds-event-simple-avatar"
            />
            <span
              v-if="getDetailInfo(event.original).typeDotColor"
              class="mds-event-simple-type-dot"
              :style="{ backgroundColor: getDetailInfo(event.original).typeDotColor }"
            ></span>
            <div
              class="mbsc-flex mds-event-simple-subtitle"
              :style="{ marginLeft: groupBy === 'type' ? '25px' : '' }"
            >
              {{ getDetailInfo(event.original).detailText }}
            </div>
          </div>
        </div>
      </div>
    </template>
  </MbscEventcalendar>
  <MbscToast :message="toastMessage" :isOpen="isToastOpen" @close="isToastOpen = false" />
  <MbscDatepicker
    :controls="['calendar']"
    select="range"
    display="center"
    :touchUi="false"
    :showRangeLabels="false"
    :headerText="editEventTitle"
    :value="editEventDateRange"
    :isOpen="isDatePickerOpen"
    @close="isDatePickerOpen = false"
    @change="handleEditDateChange"
  />
  <MbscConfirm
    :isOpen="isConfirmOpen"
    title="Move to different group"
    :message="confirmMessage"
    okText="Move"
    cancelText="Cancel"
    @close="handleConfirmClose"
  />
</template>

<style>
.mds-event-group-calendar .mbsc-timeline-row {
  height: 140px;
}
.mds-event-group-calendar .mbsc-timeline-resource-col {
  width: 240px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-textfield-wrapper-box {
  margin: 10px 20px;
}
.mds-event-group-calendar .mbsc-timeline-resource-title {
  text-transform: capitalize;
}
.mds-event-group-avatar {
  width: 40px;
  height: 40px;
}
.mds-event-group-cont {
  padding: 0 7px;
}
.mds-event-group-name {
  font-size: 14px;
  line-height: 24px;
}
.mds-event-group-title {
  font-size: 12px;
  font-weight: 400;
  line-height: 16px;
}
.mds-event-group-type-resource {
  align-items: center;
  padding: 8px 0;
}
.mds-event-group-type-badge {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  margin: 0 10px;
}
.mds-event-group-type-name {
  font-size: 14px;
  font-weight: 600;
  line-height: 20px;
}
.mds-event-group-calendar .mbsc-schedule-event {
  min-width: 72px;
}
.mds-event-group-calendar .mbsc-schedule-event:has(.mds-event-group-task-client) {
  min-width: 120px;
}
.mds-event-group-task-client {
  background-color: #ddd;
  border-left: 4px solid;
  border-radius: 0 8px 8px 0;
  box-shadow:
    0 2px 4px rgba(0, 0, 0, 0.12),
    0 1px 2px rgba(0, 0, 0, 0.08);
  flex-direction: column;
  container-type: inline-size;
  min-width: 120px;
}
.mds-event-group-content {
  justify-content: space-between;
  align-items: center;
  padding: 10px 14px;
  height: 42px;
  box-sizing: border-box;
  gap: 8px;
}
.mds-event-group-title-text {
  font-size: 14px;
  font-weight: 600;
  color: #1e293b;
  line-height: 20px;
  min-width: 0;
  position: sticky;
  left: 250px;
}
.mds-event-group-right {
  align-items: center;
  z-index: 1;
  background-color: #ddd;
}
.mds-event-group-meta {
  flex-direction: column;
  align-items: flex-end;
  flex: 1;
  margin: 0 12px;
}
.mds-event-group-date-range {
  color: #575757;
  font-size: 11px;
  margin-bottom: 2px;
}
.mds-event-group-count {
  font-size: 11px;
  white-space: nowrap;
}
.mds-event-group-icon {
  font-size: 20px;
  cursor: pointer;
  transition: transform 0.2s ease;
  width: 24px;
  height: 24px;
  align-items: center;
  justify-content: center;
  transform: rotate(0deg);
}
.mds-event-group-task.expanded .mds-event-group-icon {
  transform: rotate(180deg);
}
.mds-event-group-events {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.2s ease;
}
.mds-event-group-events-inner {
  overflow: hidden;
  padding: 0 14px 4px;
  transition: padding 0.2s ease;
}
.mds-event-group-task.expanded .mds-event-group-events {
  grid-template-rows: 1fr;
}
.mds-event-group-original-event {
  background: #f1f1f1;
  border-radius: 6px;
  margin-bottom: 6px;
  padding: 6px 10px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
  box-sizing: border-box;
  min-height: 38px;
  min-width: 100px;
  overflow: hidden;
  container-type: inline-size;
}
@container (max-width: 180px) {
  .mds-event-group-meta {
    display: none;
  }
}
@container (max-width: 130px) {
  .mds-event-group-event-right {
    display: none;
  }
  .mds-event-simple-right {
    display: none;
  }
}
.mds-event-group-event-content {
  justify-content: space-between;
  align-items: center;
  line-height: 14px;
}
.mds-event-group-event-title {
  font-weight: 600;
  color: #1e293b;
  font-size: 13px;
  line-height: 18px;
  flex: 1;
  min-width: 30px;
  margin-right: 8px;
}
.mds-event-group-text-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.mds-event-group-event-right {
  flex-direction: column;
  align-items: flex-end;
  min-width: 0;
}
.mds-event-group-event-date {
  font-size: 11px;
  color: #94a3b8;
  text-align: right;
  margin-bottom: 2px;
  max-width: 100%;
}
.mds-event-group-event-detail {
  align-items: center;
  justify-content: flex-end;
  overflow: hidden;
  max-width: 100%;
}
.mds-event-group-event-info {
  font-size: 11px;
  font-weight: 700;
  color: #64748b;
  text-transform: capitalize;
}
.mds-event-group-event-avatar {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  margin-right: 6px;
}
.mds-event-group-type-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 6px;
}
.mds-event-group-edit-btn {
  align-items: center;
  justify-content: center;
  font-size: 14px;
  cursor: pointer;
  color: #94a3b8;
  padding: 4px;
  margin-left: 8px;
  border-radius: 4px;
  transition:
    color 0.15s ease,
    background-color 0.15s ease;
  flex-shrink: 0;
}
.mds-event-group-edit-btn:hover {
  color: #1e293b;
  background-color: rgba(0, 0, 0, 0.06);
}
.mds-event-simple {
  padding: 10px 12px;
  border-radius: 6px;
  box-shadow:
    0 2px 4px rgba(0, 0, 0, 0.15),
    0 1px 2px rgba(0, 0, 0, 0.1);
  justify-content: space-between;
  align-items: center;
  height: 42px;
  box-sizing: border-box;
  color: #2c2c2c;
  background-color: #ddd;
  container-type: inline-size;
  gap: 8px;
}
.mds-event-simple-title {
  font-size: 13px;
  font-weight: 600;
  position: sticky;
  left: 250px;
  min-width: 0;
}
.mds-event-simple-title-inner {
  display: inline-block;
  max-width: 100%;
}
.mds-event-simple-right {
  flex-direction: column;
  z-index: 1;
  background-color: #ddd;
}
.mds-event-simple-date {
  font-size: 11px;
  opacity: 0.85;
  white-space: nowrap;
  text-align: right;
  margin-bottom: 2px;
}
.mds-event-simple-subtitle-wrapper {
  position: relative;
  align-items: center;
  justify-content: flex-end;
}
.mds-event-simple-avatar {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  margin-right: 6px;
  position: absolute;
  left: 0;
}
.mds-event-simple-type-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin: 0 6px;
}
.mds-event-simple-subtitle {
  font-size: 11px;
  font-weight: 700;
  text-transform: capitalize;
  white-space: nowrap;
}
.mds-event-group-header-controls {
  align-items: center;
}
.mds-event-group-select .mbsc-textfield {
  width: 210px;
  max-height: 34px;
}
.mds-event-group-select .mbsc-ios.mbsc-select-icon {
  font-size: 13px;
  top: 8px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-segmented-button {
  padding: 1px 12px;
}
</style>
```

### SFC TS

```vue
<script setup lang="ts">
import {
  formatDate,
  MbscCalendarNav,
  MbscCalendarNext,
  MbscCalendarPrev,
  MbscCalendarToday,
  MbscCheckbox,
  MbscConfirm,
  MbscDatepicker,
  MbscEventcalendar,
  MbscSegmented,
  MbscSegmentedGroup,
  MbscSelect,
  MbscToast,
  setOptions
} from '@mobiscroll/vue'
import type {
  MbscCalendarEvent,
  MbscEventcalendarView,
  MbscEventUpdateEvent,
  MbscResource,
  MbscSelectData
} from '@mobiscroll/vue'
import { computed, ref } from 'vue'

setOptions({
  theme: 'ios',
  themeVariant: 'light'
})

const assigneeResources: MbscResource[] = [
  {
    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: '#7fa650',
    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: '#4a9dad',
    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: '#5a9d76',
    img: 'https://img.mobiscroll.com/demos/m3.png'
  }
]

const typeResources: MbscResource[] = [
  {
    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 selectData: MbscSelectData[] = [
  {
    text: 'View by Assignee',
    value: 'assignee'
  },
  {
    text: 'View by Type',
    value: 'type'
  }
]

const defaultEvents: MbscCalendarEvent[] = [
  {
    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 zoomLevel = ref<'month' | 'year' | 'day' | 'week' | 'quarter' | 'hour'>('month')
const myView = computed<MbscEventcalendarView>(() => ({
  timeline: {
    type: 'year',
    resolutionHorizontal: zoomLevel.value,
    eventHeight: 'variable'
  }
}))

// Reactive state
const rawEvents = ref<MbscCalendarEvent[]>([...defaultEvents])
const displayEvents = ref<MbscCalendarEvent[]>([])
const groupedEvents = ref<MbscCalendarEvent[]>([])
const myResources = ref<MbscResource[]>(assigneeResources)
const groupBy = ref<string>('assignee')
const groupByClientQuarter = ref<boolean>(false)
const isToastOpen = ref<boolean>(false)
const toastMessage = ref<string>('')
const calendarRef = ref<typeof MbscEventcalendar>()
const isConfirmOpen = ref<boolean>(false)
const confirmMessage = ref<string>('')
let confirmCallback: (() => void) | null = null
const editingEventId = ref<string | number | null>(null)
const editEventTitle = ref<string>('')
const editEventDateRange = ref<Date[]>([])
const isDatePickerOpen = ref<boolean>(false)

function groupEventsByClientQuarter(events: MbscCalendarEvent[]) {
  const groups = new Map<
    string,
    {
      resource: string | number | undefined
      clientGroup: string
      year: number
      period: number
      events: MbscCalendarEvent[]
    }
  >()
  const result: MbscCalendarEvent[] = []

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

  // Group events
  events.forEach((event: MbscCalendarEvent) => {
    const resourceId = groupBy.value === '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 = groupBy.value === '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
    })
  })

  return result
}

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

function updateView() {
  const currentResources = groupBy.value === 'assignee' ? assigneeResources : typeResources

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

  myResources.value = currentResources
}

function handleEventUpdated(args: MbscEventUpdateEvent) {
  const updatedEvent = args.event
  const oldEvent = args.oldEvent!
  const newStart = new Date(updatedEvent.start as string | number | Date).getTime()
  const newEnd = new Date(updatedEvent.end as string | number | Date).getTime()

  if (groupByClientQuarter.value) {
    const oldStart = new Date(oldEvent.start as string | number | Date).getTime()
    const oldEnd = new Date(oldEvent.end as string | number | Date).getTime()
    const startDelta = newStart - oldStart
    const endDelta = newEnd - oldEnd

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

    const movedGroupedEvent = groupedEvents.value.find((ge) => ge.id === oldEvent.id)
    if (!movedGroupedEvent) return

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

    let eventsToUpdate: MbscCalendarEvent[]

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

      eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent: MbscCalendarEvent) => {
        const evStart = new Date(originalEvent.start as string | Date).getTime()
        const evEnd = new Date(originalEvent.end as string | Date).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)
        return {
          ...originalEvent,
          start: new Date(mappedStart),
          end: new Date(childDurationDays < 1 ? mappedStart + 1000 * 60 * 60 * 24 : mappedEnd)
        }
      })
    }

    const updatedMap = new Map(eventsToUpdate.map((e: MbscCalendarEvent) => [e.id, e]))
    rawEvents.value = rawEvents.value.map((e: MbscCalendarEvent) =>
      updatedMap.has(e.id) ? updatedMap.get(e.id)! : e
    )

    updateView()

    const newYear = new Date(eventsToUpdate[0].start as string | Date).getFullYear()
    const newPeriod = Math.floor(new Date(eventsToUpdate[0].start as string | Date).getMonth() / 3)

    const newGroupedEvent = groupedEvents.value.find(
      (ge: MbscCalendarEvent) =>
        ge.resource === resourceId &&
        ge.clientGroup === clientGroupName &&
        ge.year === newYear &&
        ge.period === newPeriod
    )

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

    const actionLabel = isMove ? 'moved' : 'resized'
    toastMessage.value = `${eventsToUpdate.length} event(s) for ${clientGroupName} have been ${actionLabel}.`
    isToastOpen.value = true
  } else {
    const oldResource = oldEvent.resource
    const newResource = updatedEvent.resource
    const resourceChanged = oldResource !== newResource

    rawEvents.value = rawEvents.value.map((e: MbscCalendarEvent) => {
      if (e.id !== updatedEvent.id) return e
      const update: Partial<MbscCalendarEvent> = {
        start: updatedEvent.start,
        end: updatedEvent.end
      }
      if (resourceChanged) {
        if (groupBy.value === 'assignee') update.resource = newResource
        else update.type = String(newResource)
      }
      return { ...e, ...update }
    })

    if (resourceChanged) {
      let fromName: string | undefined
      let toName: string | undefined
      if (groupBy.value === '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)
      }
      toastMessage.value = `"${updatedEvent.title}" moved from ${fromName} to ${toName}.`
      isToastOpen.value = true
    }
  }
}

function toggleGroupCollapse(groupEvent: MbscCalendarEvent) {
  groupedEvents.value = groupedEvents.value.map((ge) =>
    ge.id === groupEvent.id ? { ...ge, collapsed: !ge.collapsed } : ge
  )
  displayEvents.value = [...groupedEvents.value]

  // Refresh calendar to recalculate row heights
  setTimeout(() => {
    calendarRef.value?.instance?.refresh()
  }, 200)
}

function getUniqueItems(originalEvents: MbscCalendarEvent[]) {
  const uniqueItems: Record<string, MbscResource> = {}

  originalEvents.forEach((ev: MbscCalendarEvent) => {
    if (groupBy.value === '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: groupBy.value === 'assignee' ? 'type' : 'employee'
  }
}

function getDetailInfo(event: MbscCalendarEvent) {
  let detailText: string | undefined = ''
  let typeDotColor: string | undefined = undefined
  let avatarUrl: string | undefined = undefined

  if (groupBy.value === '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 }
}

function formatEventDate(date: string | number | Date) {
  return formatDate('DD MMM', new Date(date))
}

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

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

function handleEditEvent(ev: MbscCalendarEvent) {
  editingEventId.value = ev.id ?? null
  editEventTitle.value = ev.title ?? ''
  editEventDateRange.value = [
    new Date(ev.start as string | Date),
    new Date(ev.end as string | Date)
  ]
  isDatePickerOpen.value = true
}

function handleEditDateChange(args: { value: Date[] }) {
  const dates = args.value
  const startVal = dates && dates[0]
  const endVal = dates && dates[1]

  if (editingEventId.value === null || !startVal || !endVal) return

  const oldEvent = rawEvents.value.find((e) => e.id === editingEventId.value)
  if (!oldEvent) return

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

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

  if (quarterChanged) {
    const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
    const fromLabel = `${quarterNames[oldQuarter]} ${oldYear}`
    const toLabel = `${quarterNames[newQuarter]} ${newYear}`

    confirmCallback = () => {
      applyUpdate()
      toastMessage.value = `"${oldEvent.title}" moved to ${toLabel}.`
      isToastOpen.value = true
    }
    confirmMessage.value = `"${oldEvent.title}" will move from ${fromLabel} to ${toLabel}. Do you want to continue?`
    isConfirmOpen.value = true
  } else {
    applyUpdate()
    toastMessage.value = `"${oldEvent.title}" dates updated.`
    isToastOpen.value = true
  }
}

function handleConfirmClose(result: boolean) {
  isConfirmOpen.value = false
  if (result && confirmCallback) {
    confirmCallback()
    confirmCallback = null
  }
}

// Initialize view on mount
updateView()
</script>

<template>
  <MbscEventcalendar
    ref="calendarRef"
    cssClass="mds-event-group-calendar"
    :data="displayEvents"
    :view="myView"
    :resources="myResources"
    :dragToMove="true"
    :dragToResize="true"
    :dragToCreate="false"
    :clickToCreate="false"
    :dragBetweenResources="!groupByClientQuarter"
    @event-updated="handleEventUpdated"
  >
    <template #header>
      <MbscCalendarNav />
      <div class="mbsc-flex mbsc-flex-1-0 mbsc-justify-content-end mds-event-group-header-controls">
        <MbscCheckbox
          v-model="groupByClientQuarter"
          @change="updateView"
          theme="material"
          label="Group by Client/Quarter"
        />
        <MbscSelect
          inputStyle="box"
          display="anchored"
          :touchUi="false"
          :data="selectData"
          v-model="groupBy"
          @change="updateView"
        />
      </div>
      <MbscSegmentedGroup v-model="zoomLevel">
        <MbscSegmented value="quarter">Quarterly</MbscSegmented>
        <MbscSegmented value="month">Monthly</MbscSegmented>
        <MbscSegmented value="week">Weekly</MbscSegmented>
      </MbscSegmentedGroup>
      <MbscCalendarPrev />
      <MbscCalendarToday />
      <MbscCalendarNext />
    </template>

    <template #resource="resource">
      <div v-if="groupBy === 'assignee'" class="mbsc-flex">
        <img :alt="resource.name" class="mds-event-group-avatar" :src="resource.img" />
        <div class="mds-event-group-cont">
          <div class="mds-event-group-name">{{ resource.name }}</div>
          <div class="mds-event-group-title">{{ resource.title }}</div>
        </div>
      </div>
      <div v-else class="mbsc-flex mds-event-group-type-resource">
        <div class="mds-event-group-type-badge" :style="{ backgroundColor: resource.color }"></div>
        <div class="mds-event-group-type-name">{{ resource.name }}</div>
      </div>
    </template>

    <template #timelineEvent="event">
      <!-- Grouped Event Template -->
      <div
        v-if="groupByClientQuarter"
        class="mbsc-flex mds-event-group-task mds-event-group-task-client"
        :class="{ expanded: !event.original.collapsed }"
        :style="{ borderLeftColor: event.original.color }"
      >
        <div class="mbsc-flex mds-event-group-content">
          <div class="mds-event-group-title-text mds-event-group-text-truncate">
            {{ event.original.clientGroup }}
          </div>
          <div class="mbsc-flex mds-event-group-right">
            <div class="mbsc-flex mds-event-group-meta">
              <div class="mds-event-group-date-range">
                {{ formatEventDate(event.original.start) }} -
                {{ formatEventDate(event.original.end) }}
              </div>
              <div class="mds-event-group-count">
                {{ event.original.count }} task{{ event.original.count > 1 ? 's' : '' }},
                {{ getUniqueItems(event.original.originalEvents).count }}
                {{ getUniqueItems(event.original.originalEvents).label
                }}{{ getUniqueItems(event.original.originalEvents).count > 1 ? 's' : '' }}
              </div>
            </div>
            <div
              class="mbsc-flex mds-event-group-icon mbsc-icon mbsc-font-icon mbsc-icon-material-keyboard-arrow-down"
              @click.stop="toggleGroupCollapse(event.original)"
            ></div>
          </div>
        </div>
        <div class="mds-event-group-events">
          <div class="mds-event-group-events-inner">
            <div
              v-for="ev in event.original.originalEvents"
              v-show="!event.original.collapsed"
              :key="ev.id"
              class="mds-event-group-original-event"
              :title="`${ev.title}, ${formatEventDate(ev.start)} - ${formatEventDate(ev.end)}`"
              :style="{
                marginLeft: getEventMarginLeft(ev, event.original),
                width: getEventWidth(ev, event.original)
              }"
            >
              <div class="mbsc-flex mds-event-group-event-content">
                <div class="mds-event-group-event-title mds-event-group-text-truncate">
                  {{ ev.title }}
                </div>
                <div class="mbsc-flex mds-event-group-event-right">
                  <div class="mds-event-group-event-date mds-event-group-text-truncate">
                    {{ formatEventDate(ev.start) }} - {{ formatEventDate(ev.end) }}
                  </div>
                  <div class="mbsc-flex mds-event-group-event-detail">
                    <img
                      v-if="getDetailInfo(ev).avatarUrl"
                      :src="getDetailInfo(ev).avatarUrl"
                      :alt="getDetailInfo(ev).detailText"
                      class="mds-event-group-event-avatar"
                    />
                    <span
                      v-if="getDetailInfo(ev).typeDotColor"
                      class="mds-event-group-type-dot"
                      :style="{ backgroundColor: getDetailInfo(ev).typeDotColor }"
                    ></span>
                    <div class="mds-event-group-event-info mds-event-group-text-truncate">
                      {{ getDetailInfo(ev).detailText }}
                    </div>
                  </div>
                </div>
                <div
                  class="mds-event-group-edit-btn mbsc-flex mbsc-icon mbsc-font-icon mbsc-icon-pencil"
                  @click.stop="handleEditEvent(ev)"
                ></div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <!-- Simple Event Template -->
      <div v-else class="mbsc-flex mds-event-simple">
        <div class="mds-event-simple-title">
          <div class="mds-event-simple-title-inner mds-event-group-text-truncate">
            {{ event.original.title }}
          </div>
        </div>
        <div class="mbsc-flex mds-event-simple-right">
          <div class="mds-event-simple-date">
            {{ formatEventDate(event.original.start) }} - {{ formatEventDate(event.original.end) }}
          </div>
          <div
            v-if="getDetailInfo(event.original).detailText"
            class="mbsc-flex mds-event-simple-subtitle-wrapper"
          >
            <img
              v-if="getDetailInfo(event.original).avatarUrl"
              :src="getDetailInfo(event.original).avatarUrl"
              :alt="getDetailInfo(event.original).detailText"
              class="mds-event-simple-avatar"
            />
            <span
              v-if="getDetailInfo(event.original).typeDotColor"
              class="mds-event-simple-type-dot"
              :style="{ backgroundColor: getDetailInfo(event.original).typeDotColor }"
            ></span>
            <div
              class="mbsc-flex mds-event-simple-subtitle"
              :style="{ marginLeft: groupBy === 'type' ? '25px' : '' }"
            >
              {{ getDetailInfo(event.original).detailText }}
            </div>
          </div>
        </div>
      </div>
    </template>
  </MbscEventcalendar>
  <MbscToast :message="toastMessage" :isOpen="isToastOpen" @close="isToastOpen = false" />
  <MbscDatepicker
    :controls="['calendar']"
    select="range"
    display="center"
    :touchUi="false"
    :showRangeLabels="false"
    :headerText="editEventTitle"
    :value="editEventDateRange"
    :isOpen="isDatePickerOpen"
    @close="isDatePickerOpen = false"
    @change="handleEditDateChange"
  />
  <MbscConfirm
    :isOpen="isConfirmOpen"
    title="Move to different group"
    :message="confirmMessage"
    okText="Move"
    cancelText="Cancel"
    @close="handleConfirmClose"
  />
</template>

<style>
.mds-event-group-calendar .mbsc-timeline-row {
  height: 140px;
}
.mds-event-group-calendar .mbsc-timeline-resource-col {
  width: 240px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-textfield-wrapper-box {
  margin: 10px 20px;
}
.mds-event-group-calendar .mbsc-timeline-resource-title {
  text-transform: capitalize;
}
.mds-event-group-avatar {
  width: 40px;
  height: 40px;
}
.mds-event-group-cont {
  padding: 0 7px;
}
.mds-event-group-name {
  font-size: 14px;
  line-height: 24px;
}
.mds-event-group-title {
  font-size: 12px;
  font-weight: 400;
  line-height: 16px;
}
.mds-event-group-type-resource {
  align-items: center;
  padding: 8px 0;
}
.mds-event-group-type-badge {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  margin: 0 10px;
}
.mds-event-group-type-name {
  font-size: 14px;
  font-weight: 600;
  line-height: 20px;
}
.mds-event-group-calendar .mbsc-schedule-event {
  min-width: 72px;
}
.mds-event-group-calendar .mbsc-schedule-event:has(.mds-event-group-task-client) {
  min-width: 120px;
}
.mds-event-group-task-client {
  background-color: #ddd;
  border-left: 4px solid;
  border-radius: 0 8px 8px 0;
  box-shadow:
    0 2px 4px rgba(0, 0, 0, 0.12),
    0 1px 2px rgba(0, 0, 0, 0.08);
  flex-direction: column;
  container-type: inline-size;
  min-width: 120px;
}
.mds-event-group-content {
  justify-content: space-between;
  align-items: center;
  padding: 10px 14px;
  height: 42px;
  box-sizing: border-box;
  gap: 8px;
}
.mds-event-group-title-text {
  font-size: 14px;
  font-weight: 600;
  color: #1e293b;
  line-height: 20px;
  min-width: 0;
  position: sticky;
  left: 250px;
}
.mds-event-group-right {
  align-items: center;
  z-index: 1;
  background-color: #ddd;
}
.mds-event-group-meta {
  flex-direction: column;
  align-items: flex-end;
  flex: 1;
  margin: 0 12px;
}
.mds-event-group-date-range {
  color: #575757;
  font-size: 11px;
  margin-bottom: 2px;
}
.mds-event-group-count {
  font-size: 11px;
  white-space: nowrap;
}
.mds-event-group-icon {
  font-size: 20px;
  cursor: pointer;
  transition: transform 0.2s ease;
  width: 24px;
  height: 24px;
  align-items: center;
  justify-content: center;
  transform: rotate(0deg);
}
.mds-event-group-task.expanded .mds-event-group-icon {
  transform: rotate(180deg);
}
.mds-event-group-events {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.2s ease;
}
.mds-event-group-events-inner {
  overflow: hidden;
  padding: 0 14px 4px;
  transition: padding 0.2s ease;
}
.mds-event-group-task.expanded .mds-event-group-events {
  grid-template-rows: 1fr;
}
.mds-event-group-original-event {
  background: #f1f1f1;
  border-radius: 6px;
  margin-bottom: 6px;
  padding: 6px 10px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
  box-sizing: border-box;
  min-height: 38px;
  min-width: 100px;
  overflow: hidden;
  container-type: inline-size;
}
@container (max-width: 180px) {
  .mds-event-group-meta {
    display: none;
  }
}
@container (max-width: 130px) {
  .mds-event-group-event-right {
    display: none;
  }
  .mds-event-simple-right {
    display: none;
  }
}
.mds-event-group-event-content {
  justify-content: space-between;
  align-items: center;
  line-height: 14px;
}
.mds-event-group-event-title {
  font-weight: 600;
  color: #1e293b;
  font-size: 13px;
  line-height: 18px;
  flex: 1;
  min-width: 30px;
  margin-right: 8px;
}
.mds-event-group-text-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.mds-event-group-event-right {
  flex-direction: column;
  align-items: flex-end;
  min-width: 0;
}
.mds-event-group-event-date {
  font-size: 11px;
  color: #94a3b8;
  text-align: right;
  margin-bottom: 2px;
  max-width: 100%;
}
.mds-event-group-event-detail {
  align-items: center;
  justify-content: flex-end;
  overflow: hidden;
  max-width: 100%;
}
.mds-event-group-event-info {
  font-size: 11px;
  font-weight: 700;
  color: #64748b;
  text-transform: capitalize;
}
.mds-event-group-event-avatar {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  margin-right: 6px;
}
.mds-event-group-type-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 6px;
}
.mds-event-group-edit-btn {
  align-items: center;
  justify-content: center;
  font-size: 14px;
  cursor: pointer;
  color: #94a3b8;
  padding: 4px;
  margin-left: 8px;
  border-radius: 4px;
  transition:
    color 0.15s ease,
    background-color 0.15s ease;
  flex-shrink: 0;
}
.mds-event-group-edit-btn:hover {
  color: #1e293b;
  background-color: rgba(0, 0, 0, 0.06);
}
.mds-event-simple {
  padding: 10px 12px;
  border-radius: 6px;
  box-shadow:
    0 2px 4px rgba(0, 0, 0, 0.15),
    0 1px 2px rgba(0, 0, 0, 0.1);
  justify-content: space-between;
  align-items: center;
  height: 42px;
  box-sizing: border-box;
  color: #2c2c2c;
  background-color: #ddd;
  container-type: inline-size;
  gap: 8px;
}
.mds-event-simple-title {
  font-size: 13px;
  font-weight: 600;
  position: sticky;
  left: 250px;
  min-width: 0;
}
.mds-event-simple-title-inner {
  display: inline-block;
  max-width: 100%;
}
.mds-event-simple-right {
  flex-direction: column;
  z-index: 1;
  background-color: #ddd;
}
.mds-event-simple-date {
  font-size: 11px;
  opacity: 0.85;
  white-space: nowrap;
  text-align: right;
  margin-bottom: 2px;
}
.mds-event-simple-subtitle-wrapper {
  position: relative;
  align-items: center;
  justify-content: flex-end;
}
.mds-event-simple-avatar {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  margin-right: 6px;
  position: absolute;
  left: 0;
}
.mds-event-simple-type-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin: 0 6px;
}
.mds-event-simple-subtitle {
  font-size: 11px;
  font-weight: 700;
  text-transform: capitalize;
  white-space: nowrap;
}
.mds-event-group-header-controls {
  align-items: center;
}
.mds-event-group-select .mbsc-textfield {
  width: 210px;
  max-height: 34px;
}
.mds-event-group-select .mbsc-ios.mbsc-select-icon {
  font-size: 13px;
  top: 8px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-segmented-button {
  padding: 1px 12px;
}
</style>
```

## Implementation notes

- Use `type: 'year'` with `resolutionHorizontal` driven by a `zoomLevel` state (values: `'quarter'`, `'month'`, `'week'`) and `eventHeight: 'variable'` to accommodate collapsible grouped event blocks.
- Define two separate resource arrays: `assigneeResources` (employees with `name`, `title`, `color`, `img`) and `typeResources` (work categories with `name`, `color`). Keep a `groupBy` state (`'assignee'` or `'type'`) and pass the matching array to `resources`. When `groupBy` is `'type'`, the event's `type` field is used as the `resource` key; when `'assignee'`, the `resource` field is used.
- Keep a `groupByClientQuarter` boolean toggle state. When disabled, map each event to a display-ready object with the correct resource key for the current `groupBy` mode. When enabled, compute grouped events instead.
- To compute grouped events, group the flat event array by `(resourceId, clientGroup, year, quarter)`. For each group, create a synthetic event object with a `title` (client name), a `start` (earliest start), an `end` (latest end), a `count`, an `originalEvents` array, and a `collapsed` flag read from a `collapsedMap` state (defaulting to `true`).
- Use `renderTimelineEvent` (Angular: `timelineEventTemplate`, Vue: `#timelineEvent` slot) for event rendering. When `groupByClientQuarter` is on, render the grouped block; when off, render a simple individual event with the event title, date range, and either a type color dot or an assignee avatar.
- The grouped event block shows the client group name, a formatted date range, a task count and unique type/employee count, and a collapse/expand arrow button. When expanded, render each original event as a proportional mini bar positioned by its relative offset within the group's time span using computed `marginLeft` and `width` percentages.
- On the collapse arrow click, call `stopPropagation`, toggle the group's key in `collapsedMap`, and call `calendarRef.current.refresh()` after a short timeout to force Mobiscroll to recalculate event heights.
- Each expanded original event has a pencil icon button. On click, call `stopPropagation` and open a `Datepicker` in `select: 'range'` and `controls: ['calendar']` mode centered on screen, pre-populated with the event's current dates.
- In the `Datepicker` `onChange` handler, if the new dates move the event to a different quarter and grouping is on, show a `Confirm` dialog before applying. Store the apply callback in a ref so the `onOk` handler can execute it after the user confirms.
- In `onEventUpdate`, distinguish drag-move from drag-resize by comparing start and end deltas. For a grouped event move, shift all `originalEvents` by the same time delta. For a resize, proportionally remap each child's start and end within the new group span. After applying, update the `collapsedMap` with a new key reflecting the event's new quarter. In non-grouped mode, update the single event's dates and, if the resource row changed, update the event's `resource` or `type` field accordingly.
- Set `dragBetweenResources` to `true` in non-grouped mode and `false` in grouped mode (a single expression: `!groupByClientQuarter`). Keep `dragToMove: true` and `dragToResize: true`; disable `clickToCreate` and `dragToCreate`.
- Use `renderResource` (Angular: `resourceTemplate`, Vue: `#resource` slot) to show a photo, name, and title for assignee resources, and a colored badge with name for type resources.
- Use `renderHeader` (Angular: `headerTemplate`, Vue: `#header` slot) to render `CalendarNav`, a `Checkbox` for the group toggle, a `Select` for switching between assignee and type views, a `SegmentedGroup` with three `Segmented` controls for zoom level, and `CalendarPrev`/`CalendarToday`/`CalendarNext`.

## Run it in your project

1. If not signed up yet, or don't own a license, you can [start a free trial](https://mobiscroll.com/starttrial).

2. Clone the repository:

   ```
   git clone https://github.com/acidb/mobiscroll-demos-vue.git
   ```

3. If not installed yet, install the Mobiscroll CLI from NPM:

   ```
   npm install -g @mobiscroll/cli
   ```

4. Config Mobiscroll - open a terminal window in the root directory and run:

   ```
   mobiscroll config vue
   ```

5. Install dependencies - open a terminal window in the root directory and run:

   ```
   npm install
   ```

6. Run the project:

   ```
   npm run dev
   ```

On a trial, add `--trial` to the config command: `mobiscroll config vue --trial`.

Running a demo locally requires a Mobiscroll trial or a license; setup is per framework at https://mobiscroll.com/docs/vue/getting-started/installation

## Related demos

- Previous: [External Resource Drag & Drop](https://demo.mobiscroll.com/vue/timeline/resource-management-with-dynamic-team-creation.md) - Drag external resources onto a Vue Timeline to add team members, or drag them off to remove them — build crew scheduling and team management.
- Next: [36-Hour Rolling Window Schedule](https://demo.mobiscroll.com/vue/timeline/36-hour-rolling-window-aircraft-view.md) - Build a fleet scheduling view on the Vue Timeline — a 36-hour rolling window that auto-advances from the current hour.
