diff --git a/src/addons/block/block.module.ts b/src/addons/block/block.module.ts
index bc2bbb55a..0d8ff2d47 100644
--- a/src/addons/block/block.module.ts
+++ b/src/addons/block/block.module.ts
@@ -38,6 +38,7 @@ import { AddonBlockStarredCoursesModule } from './starredcourses/starredcourses.
import { AddonBlockTagsModule } from './tags/tags.module';
import { AddonBlockActivityModulesModule } from './activitymodules/activitymodules.module';
import { AddonBlockRecentlyAccessedItemsModule } from './recentlyaccesseditems/recentlyaccesseditems.module';
+import { AddonBlockTimelineModule } from './timeline/timeline.module';
@NgModule({
declarations: [],
@@ -66,6 +67,7 @@ import { AddonBlockRecentlyAccessedItemsModule } from './recentlyaccesseditems/r
AddonBlockTagsModule,
AddonBlockActivityModulesModule,
AddonBlockRecentlyAccessedItemsModule,
+ AddonBlockTimelineModule,
],
providers: [],
exports: [],
diff --git a/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html b/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html
index acf647cf3..f9576466d 100644
--- a/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html
+++ b/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html
@@ -20,15 +20,15 @@
(onClosed)="switchFilterClosed()">
+ (action)="switchSort('fullname')" [iconAction]="sort == 'fullname' ? 'far-dot-circle' : 'far-circle'">
+ (action)="switchSort('shortname')" [iconAction]="sort == 'shortname' ? 'far-dot-circle' : 'far-circle'">
+ (action)="switchSort('lastaccess')" [iconAction]="sort == 'lastaccess' ? 'far-dot-circle' : 'far-circle'">
diff --git a/src/addons/block/timeline/components/components.module.ts b/src/addons/block/timeline/components/components.module.ts
new file mode 100644
index 000000000..d2f4620cd
--- /dev/null
+++ b/src/addons/block/timeline/components/components.module.ts
@@ -0,0 +1,51 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { FormsModule } from '@angular/forms';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { CoreCoursesComponentsModule } from '@features/courses/components/components.module';
+import { CoreCourseComponentsModule } from '@features/course/components/components.module';
+
+import { AddonBlockTimelineComponent } from './timeline/timeline';
+import { AddonBlockTimelineEventsComponent } from './events/events';
+
+@NgModule({
+ declarations: [
+ AddonBlockTimelineComponent,
+ AddonBlockTimelineEventsComponent,
+ ],
+ imports: [
+ CommonModule,
+ IonicModule,
+ FormsModule,
+ TranslateModule.forChild(),
+ CoreSharedModule,
+ CoreCoursesComponentsModule,
+ CoreCourseComponentsModule,
+ ],
+ exports: [
+ AddonBlockTimelineComponent,
+ AddonBlockTimelineEventsComponent,
+ ],
+ entryComponents: [
+ AddonBlockTimelineComponent,
+ AddonBlockTimelineEventsComponent,
+ ],
+})
+export class AddonBlockTimelineComponentsModule {}
diff --git a/src/addons/block/timeline/components/events/addon-block-timeline-events.html b/src/addons/block/timeline/components/events/addon-block-timeline-events.html
new file mode 100644
index 000000000..67db5d061
--- /dev/null
+++ b/src/addons/block/timeline/components/events/addon-block-timeline-events.html
@@ -0,0 +1,55 @@
+
+
+ {{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedayshort" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{event.action.name}}
+ {{event.action.itemcount}}
+
+
+
+
+
+
+ {{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}
+
+
+
+ {{event.action.name}}
+ {{event.action.itemcount}}
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'core.loadmore' | translate }}
+
+
+
+
+
diff --git a/src/addons/block/timeline/components/events/events.ts b/src/addons/block/timeline/components/events/events.ts
new file mode 100644
index 000000000..0f5a3f97e
--- /dev/null
+++ b/src/addons/block/timeline/components/events/events.ts
@@ -0,0 +1,154 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, Input, Output, OnChanges, EventEmitter, SimpleChange } from '@angular/core';
+import { CoreSites } from '@services/sites';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreCourse } from '@features/course/services/course';
+import moment from 'moment';
+import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
+import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
+
+/**
+ * Directive to render a list of events in course overview.
+ */
+@Component({
+ selector: 'addon-block-timeline-events',
+ templateUrl: 'addon-block-timeline-events.html',
+})
+export class AddonBlockTimelineEventsComponent implements OnChanges {
+
+ @Input() events: AddonBlockTimelineEvent[] = []; // The events to render.
+ @Input() showCourse?: boolean | string; // Whether to show the course name.
+ @Input() from = 0; // Number of days from today to offset the events.
+ @Input() to?: number; // Number of days from today to limit the events to. If not defined, no limit.
+ @Input() canLoadMore?: boolean; // Whether more events can be loaded.
+ @Output() loadMore: EventEmitter; // Notify that more events should be loaded.
+
+ empty = true;
+ loadingMore = false;
+ filteredEvents: AddonBlockTimelineEventFilteredEvent[] = [];
+
+ constructor() {
+ this.loadMore = new EventEmitter();
+ }
+
+ /**
+ * Detect changes on input properties.
+ */
+ ngOnChanges(changes: {[name: string]: SimpleChange}): void {
+ this.showCourse = CoreUtils.instance.isTrueOrOne(this.showCourse);
+
+ if (changes.events || changes.from || changes.to) {
+ if (this.events && this.events.length > 0) {
+ const filteredEvents = this.filterEventsByTime(this.from, this.to);
+ this.empty = !filteredEvents || filteredEvents.length <= 0;
+
+ const eventsByDay: Record = {};
+ filteredEvents.forEach((event) => {
+ const dayTimestamp = CoreTimeUtils.instance.getMidnightForTimestamp(event.timesort);
+ if (eventsByDay[dayTimestamp]) {
+ eventsByDay[dayTimestamp].push(event);
+ } else {
+ eventsByDay[dayTimestamp] = [event];
+ }
+ });
+
+ const todaysMidnight = CoreTimeUtils.instance.getMidnightForTimestamp();
+ this.filteredEvents = [];
+ Object.keys(eventsByDay).forEach((key) => {
+ const dayTimestamp = parseInt(key);
+ this.filteredEvents.push({
+ color: dayTimestamp < todaysMidnight ? 'danger' : 'light',
+ dayTimestamp,
+ events: eventsByDay[dayTimestamp],
+ });
+ });
+ } else {
+ this.empty = true;
+ }
+ }
+ }
+
+ /**
+ * Filter the events by time.
+ *
+ * @param start Number of days to start getting events from today. E.g. -1 will get events from yesterday.
+ * @param end Number of days after the start.
+ * @return Filtered events.
+ */
+ protected filterEventsByTime(start: number, end?: number): AddonBlockTimelineEvent[] {
+ start = moment().add(start, 'days').startOf('day').unix();
+ end = typeof end != 'undefined' ? moment().add(end, 'days').startOf('day').unix() : end;
+
+ return this.events.filter((event) => {
+ if (end) {
+ return start <= event.timesort && event.timesort < end;
+ }
+
+ return start <= event.timesort;
+ }).map((event) => {
+ event.iconUrl = CoreCourse.instance.getModuleIconSrc(event.icon.component);
+
+ return event;
+ });
+ }
+
+ /**
+ * Load more events clicked.
+ */
+ loadMoreEvents(): void {
+ this.loadingMore = true;
+ this.loadMore.emit();
+ }
+
+ /**
+ * Action clicked.
+ *
+ * @param e Click event.
+ * @param url Url of the action.
+ */
+ async action(e: Event, url: string): Promise {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Fix URL format.
+ url = CoreTextUtils.instance.decodeHTMLEntities(url);
+
+ const modal = await CoreDomUtils.instance.showModalLoading();
+
+ try {
+ const treated = await CoreContentLinksHelper.instance.handleLink(url);
+ if (!treated) {
+ return CoreSites.instance.getCurrentSite()?.openInBrowserWithAutoLoginIfSameSite(url);
+ }
+ } finally {
+ modal.dismiss();
+ }
+ }
+
+}
+
+type AddonBlockTimelineEvent = AddonCalendarEvent & {
+ iconUrl?: string;
+};
+
+type AddonBlockTimelineEventFilteredEvent = {
+ events: AddonBlockTimelineEvent[];
+ dayTimestamp: number;
+ color: string;
+};
diff --git a/src/addons/block/timeline/components/timeline/addon-block-timeline.html b/src/addons/block/timeline/components/timeline/addon-block-timeline.html
new file mode 100644
index 000000000..188b94023
--- /dev/null
+++ b/src/addons/block/timeline/components/timeline/addon-block-timeline.html
@@ -0,0 +1,44 @@
+
+ {{ 'addon.block_timeline.pluginname' | translate }}
+
+
+
+
+
+
+
+
+
+
+ {{ 'core.all' | translate }}
+ {{ 'addon.block_timeline.overdue' | translate }}
+ {{ 'addon.block_timeline.duedate' | translate }}
+ {{ 'addon.block_timeline.next7days' | translate }}
+ {{ 'addon.block_timeline.next30days' | translate }}
+ {{ 'addon.block_timeline.next3months' | translate }}
+ {{ 'addon.block_timeline.next6months' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/block/timeline/components/timeline/timeline.ts b/src/addons/block/timeline/components/timeline/timeline.ts
new file mode 100644
index 000000000..4f25fcfea
--- /dev/null
+++ b/src/addons/block/timeline/components/timeline/timeline.ts
@@ -0,0 +1,240 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, OnInit } from '@angular/core';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreSites } from '@services/sites';
+import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
+import { AddonBlockTimeline } from '../../services/timeline';
+import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
+import { CoreSite } from '@classes/site';
+import { CoreCourses } from '@features/courses/services/courses';
+import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
+
+/**
+ * Component to render a timeline block.
+ */
+@Component({
+ selector: 'addon-block-timeline',
+ templateUrl: 'addon-block-timeline.html',
+})
+export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit {
+
+ sort = 'sortbydates';
+ filter = 'next30days';
+ currentSite?: CoreSite;
+ timeline: {
+ events: AddonCalendarEvent[];
+ loaded: boolean;
+ canLoadMore?: number;
+ } = {
+ events: [],
+ loaded: false,
+ };
+
+ timelineCourses: {
+ courses: AddonBlockTimelineCourse[];
+ loaded: boolean;
+ canLoadMore?: number;
+ } = {
+ courses: [],
+ loaded: false,
+ };
+
+ dataFrom?: number;
+ dataTo?: number;
+
+ protected courseIds: number[] = [];
+ protected fetchContentDefaultError = 'Error getting timeline data.';
+
+ constructor() {
+ super('AddonBlockTimelineComponent');
+ }
+
+ /**
+ * Component being initialized.
+ */
+ async ngOnInit(): Promise {
+ this.currentSite = CoreSites.instance.getCurrentSite();
+
+ this.filter = await this.currentSite!.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
+ this.switchFilter();
+
+ this.sort = await this.currentSite!.getLocalSiteConfig('AddonBlockTimelineSort', this.sort);
+
+ super.ngOnInit();
+ }
+
+ /**
+ * Perform the invalidate content function.
+ *
+ * @return Resolved when done.
+ */
+ protected invalidateContent(): Promise {
+ const promises: Promise[] = [];
+
+ promises.push(AddonBlockTimeline.instance.invalidateActionEventsByTimesort());
+ promises.push(AddonBlockTimeline.instance.invalidateActionEventsByCourses());
+ promises.push(CoreCourses.instance.invalidateUserCourses());
+ promises.push(CoreCourseOptionsDelegate.instance.clearAndInvalidateCoursesOptions());
+ if (this.courseIds.length > 0) {
+ promises.push(CoreCourses.instance.invalidateCoursesByField('ids', this.courseIds.join(',')));
+ }
+
+ return CoreUtils.instance.allPromises(promises);
+ }
+
+ /**
+ * Fetch the courses for my overview.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async fetchContent(): Promise {
+ if (this.sort == 'sortbydates') {
+ return this.fetchMyOverviewTimeline().finally(() => {
+ this.timeline.loaded = true;
+ });
+ }
+
+ if (this.sort == 'sortbycourses') {
+ return this.fetchMyOverviewTimelineByCourses().finally(() => {
+ this.timelineCourses.loaded = true;
+ });
+ }
+ }
+
+ /**
+ * Load more events.
+ */
+ async loadMoreTimeline(): Promise {
+ try {
+ await this.fetchMyOverviewTimeline(this.timeline.canLoadMore);
+ } catch (error) {
+ CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError);
+ }
+ }
+
+ /**
+ * Load more events.
+ *
+ * @param course Course.
+ * @return Promise resolved when done.
+ */
+ async loadMoreCourse(course: AddonBlockTimelineCourse): Promise {
+ try {
+ const courseEvents = await AddonBlockTimeline.instance.getActionEventsByCourse(course.id, course.canLoadMore);
+ course.events = course.events?.concat(courseEvents.events);
+ course.canLoadMore = courseEvents.canLoadMore;
+ } catch (error) {
+ CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError);
+ }
+ }
+
+ /**
+ * Fetch the timeline.
+ *
+ * @param afterEventId The last event id.
+ * @return Promise resolved when done.
+ */
+ protected async fetchMyOverviewTimeline(afterEventId?: number): Promise {
+ const events = await AddonBlockTimeline.instance.getActionEventsByTimesort(afterEventId);
+
+ this.timeline.events = events.events;
+ this.timeline.canLoadMore = events.canLoadMore;
+ }
+
+ /**
+ * Fetch the timeline by courses.
+ *
+ * @return Promise resolved when done.
+ */
+ protected async fetchMyOverviewTimelineByCourses(): Promise {
+ const courses = await CoreCoursesHelper.instance.getUserCoursesWithOptions();
+ const today = CoreTimeUtils.instance.timestamp();
+
+ this.timelineCourses.courses = courses.filter((course) =>
+ (course.startdate || 0) <= today && (!course.enddate || course.enddate >= today));
+
+ if (this.timelineCourses.courses.length > 0) {
+ this.courseIds = this.timelineCourses.courses.map((course) => course.id);
+
+ const courseEvents = await AddonBlockTimeline.instance.getActionEventsByCourses(this.courseIds);
+
+ this.timelineCourses.courses.forEach((course) => {
+ course.events = courseEvents[course.id].events;
+ course.canLoadMore = courseEvents[course.id].canLoadMore;
+ });
+ }
+ }
+
+ /**
+ * Change timeline filter being viewed.
+ */
+ switchFilter(): void {
+ this.currentSite?.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
+
+ switch (this.filter) {
+ case 'overdue':
+ this.dataFrom = -14;
+ this.dataTo = 0;
+ break;
+ case 'next7days':
+ this.dataFrom = 0;
+ this.dataTo = 7;
+ break;
+ case 'next30days':
+ this.dataFrom = 0;
+ this.dataTo = 30;
+ break;
+ case 'next3months':
+ this.dataFrom = 0;
+ this.dataTo = 90;
+ break;
+ case 'next6months':
+ this.dataFrom = 0;
+ this.dataTo = 180;
+ break;
+ default:
+ case 'all':
+ this.dataFrom = -14;
+ this.dataTo = undefined;
+ break;
+ }
+ }
+
+ /**
+ * Change timeline sort being viewed.
+ *
+ * @param sort New sorting.
+ */
+ switchSort(sort: string): void {
+ this.sort = sort;
+ this.currentSite?.setLocalSiteConfig('AddonBlockTimelineSort', this.sort);
+
+ if (!this.timeline.loaded && this.sort == 'sortbydates') {
+ this.fetchContent();
+ } else if (!this.timelineCourses.loaded && this.sort == 'sortbycourses') {
+ this.fetchContent();
+ }
+ }
+
+}
+
+type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & {
+ events?: AddonCalendarEvent[];
+ canLoadMore?: number;
+};
diff --git a/src/addons/block/timeline/lang.json b/src/addons/block/timeline/lang.json
new file mode 100644
index 000000000..fbd482f47
--- /dev/null
+++ b/src/addons/block/timeline/lang.json
@@ -0,0 +1,13 @@
+{
+ "duedate": "Due date",
+ "next30days": "Next 30 days",
+ "next3months": "Next 3 months",
+ "next6months": "Next 6 months",
+ "next7days": "Next 7 days",
+ "nocoursesinprogress": "No in-progress courses",
+ "noevents": "No upcoming activities due",
+ "overdue": "Overdue",
+ "pluginname": "Timeline",
+ "sortbycourses": "Sort by courses",
+ "sortbydates": "Sort by dates"
+}
\ No newline at end of file
diff --git a/src/addons/block/timeline/services/block-handler.ts b/src/addons/block/timeline/services/block-handler.ts
new file mode 100644
index 000000000..277fd6dc7
--- /dev/null
+++ b/src/addons/block/timeline/services/block-handler.ts
@@ -0,0 +1,62 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreSites } from '@services/sites';
+import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
+import { CoreCourses } from '@features/courses/services/courses';
+import { AddonBlockTimelineComponent } from '@addons/block/timeline/components/timeline/timeline';
+import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
+import { makeSingleton } from '@singletons';
+import { AddonBlockTimeline } from './timeline';
+
+/**
+ * Block handler.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonBlockTimelineHandlerService extends CoreBlockBaseHandler {
+
+ name = 'AddonBlockTimeline';
+ blockName = 'timeline';
+
+ /**
+ * Check if the handler is enabled on a site level.
+ *
+ * @return Whether or not the handler is enabled on a site level.
+ */
+ async isEnabled(): Promise {
+ const enabled = await AddonBlockTimeline.instance.isAvailable();
+ const currentSite = CoreSites.instance.getCurrentSite();
+
+ return enabled && ((currentSite && currentSite.isVersionGreaterEqualThan('3.6')) ||
+ !CoreCourses.instance.isMyCoursesDisabledInSite());
+ }
+
+ /**
+ * Returns the data needed to render the block.
+ *
+ * @return Data or promise resolved with the data.
+ */
+ getDisplayData(): CoreBlockHandlerData {
+
+ return {
+ title: 'addon.block_timeline.pluginname',
+ class: 'addon-block-timeline',
+ component: AddonBlockTimelineComponent,
+ };
+ }
+
+}
+
+export class AddonBlockTimelineHandler extends makeSingleton(AddonBlockTimelineHandlerService) {}
diff --git a/src/addons/block/timeline/services/timeline.ts b/src/addons/block/timeline/services/timeline.ts
new file mode 100644
index 000000000..3848dbd1a
--- /dev/null
+++ b/src/addons/block/timeline/services/timeline.ts
@@ -0,0 +1,290 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreSites } from '@services/sites';
+import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
+import {
+ AddonCalendarEvents,
+ AddonCalendarEventsGroupedByCourse,
+ AddonCalendarEvent,
+ AddonCalendarGetActionEventsByCourseWSParams,
+ AddonCalendarGetActionEventsByTimesortWSParams,
+ AddonCalendarGetActionEventsByCoursesWSParams,
+} from '@addons/calendar/services/calendar';
+import moment from 'moment';
+import { makeSingleton } from '@singletons';
+import { CoreSiteWSPreSets } from '@classes/site';
+import { CoreError } from '@classes/errors/error';
+
+// Cache key was maintained from block myoverview when blocks were splitted.
+const ROOT_CACHE_KEY = 'myoverview:';
+
+/**
+ * Service that provides some features regarding course overview.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonBlockTimelineProvider {
+
+ static readonly EVENTS_LIMIT = 20;
+ static readonly EVENTS_LIMIT_PER_COURSE = 10;
+
+ /**
+ * Get calendar action events for the given course.
+ *
+ * @param courseId Only events in this course.
+ * @param afterEventId The last seen event id.
+ * @param siteId Site ID. If not defined, use current site.
+ * @return Promise resolved when the info is retrieved.
+ */
+ async getActionEventsByCourse(
+ courseId: number,
+ afterEventId?: number,
+ siteId?: string,
+ ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const time = moment().subtract(14, 'days').unix(); // Check two weeks ago.
+
+ const data: AddonCalendarGetActionEventsByCourseWSParams = {
+ timesortfrom: time,
+ courseid: courseId,
+ limitnum: AddonBlockTimelineProvider.EVENTS_LIMIT_PER_COURSE,
+ };
+ if (afterEventId) {
+ data.aftereventid = afterEventId;
+ }
+
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getActionEventsByCourseCacheKey(courseId),
+ };
+
+ const courseEvents = await site.read(
+ 'core_calendar_get_action_events_by_course',
+ data,
+ preSets,
+ );
+
+ if (courseEvents && courseEvents.events) {
+ return this.treatCourseEvents(courseEvents, time);
+ }
+
+ throw new CoreError('No events returned on core_calendar_get_action_events_by_course.');
+ }
+
+ /**
+ * Get cache key for get calendar action events for the given course value WS call.
+ *
+ * @param courseId Only events in this course.
+ * @return Cache key.
+ */
+ protected getActionEventsByCourseCacheKey(courseId: number): string {
+ return this.getActionEventsByCoursesCacheKey() + ':' + courseId;
+ }
+
+ /**
+ * Get calendar action events for a given list of courses.
+ *
+ * @param courseIds Course IDs.
+ * @param siteId Site ID. If not defined, use current site.
+ * @return Promise resolved when the info is retrieved.
+ */
+ async getActionEventsByCourses(
+ courseIds: number[],
+ siteId?: string,
+ ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore: number } }> {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const time = moment().subtract(14, 'days').unix(); // Check two weeks ago.
+
+ const data: AddonCalendarGetActionEventsByCoursesWSParams = {
+ timesortfrom: time,
+ courseids: courseIds,
+ limitnum: AddonBlockTimelineProvider.EVENTS_LIMIT_PER_COURSE,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getActionEventsByCoursesCacheKey(),
+ };
+
+ const events = await site.read(
+ 'core_calendar_get_action_events_by_courses',
+ data,
+ preSets,
+ );
+
+ if (events && events.groupedbycourse) {
+ const courseEvents = {};
+
+ events.groupedbycourse.forEach((course) => {
+ courseEvents[course.courseid] = this.treatCourseEvents(course, time);
+ });
+
+ return courseEvents;
+ }
+
+ throw new CoreError('No events returned on core_calendar_get_action_events_by_courses.');
+ }
+
+ /**
+ * Get cache key for get calendar action events for a given list of courses value WS call.
+ *
+ * @return Cache key.
+ */
+ protected getActionEventsByCoursesCacheKey(): string {
+ return ROOT_CACHE_KEY + 'bycourse';
+ }
+
+ /**
+ * Get calendar action events based on the timesort value.
+ *
+ * @param afterEventId The last seen event id.
+ * @param siteId Site ID. If not defined, use current site.
+ * @return Promise resolved when the info is retrieved.
+ */
+ async getActionEventsByTimesort(
+ afterEventId?: number,
+ siteId?: string,
+ ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const timesortfrom = moment().subtract(14, 'days').unix(); // Check two weeks ago.
+ const limitnum = AddonBlockTimelineProvider.EVENTS_LIMIT;
+
+ const data: AddonCalendarGetActionEventsByTimesortWSParams = {
+ timesortfrom,
+ limitnum,
+ };
+ if (afterEventId) {
+ data.aftereventid = afterEventId;
+ }
+
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getActionEventsByTimesortCacheKey(afterEventId, limitnum),
+ getCacheUsingCacheKey: true,
+ uniqueCacheKey: true,
+ };
+
+ const result = await site.read(
+ 'core_calendar_get_action_events_by_timesort',
+ data,
+ preSets,
+ );
+
+ if (result && result.events) {
+ const canLoadMore = result.events.length >= limitnum ? result.lastid : undefined;
+
+ // Filter events by time in case it uses cache.
+ const events = result.events.filter((element) => element.timesort >= timesortfrom);
+
+ return {
+ events,
+ canLoadMore,
+ };
+ }
+
+ throw new CoreError('No events returned on core_calendar_get_action_events_by_timesort.');
+ }
+
+ /**
+ * Get prefix cache key for calendar action events based on the timesort value WS calls.
+ *
+ * @return Cache key.
+ */
+ protected getActionEventsByTimesortPrefixCacheKey(): string {
+ return ROOT_CACHE_KEY + 'bytimesort:';
+ }
+
+ /**
+ * Get cache key for get calendar action events based on the timesort value WS call.
+ *
+ * @param afterEventId The last seen event id.
+ * @param limit Limit num of the call.
+ * @return Cache key.
+ */
+ protected getActionEventsByTimesortCacheKey(afterEventId?: number, limit?: number): string {
+ afterEventId = afterEventId || 0;
+ limit = limit || 0;
+
+ return this.getActionEventsByTimesortPrefixCacheKey() + afterEventId + ':' + limit;
+ }
+
+ /**
+ * Invalidates get calendar action events for a given list of courses WS call.
+ *
+ * @param siteId Site ID to invalidate. If not defined, use current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateActionEventsByCourses(siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByCoursesCacheKey());
+ }
+
+ /**
+ * Invalidates get calendar action events based on the timesort value WS call.
+ *
+ * @param siteId Site ID to invalidate. If not defined, use current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateActionEventsByTimesort(siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByTimesortPrefixCacheKey());
+ }
+
+ /**
+ * Returns whether or not My Overview is available for a certain site.
+ *
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if available, resolved with false or rejected otherwise.
+ */
+ async isAvailable(siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ // First check if dashboard is disabled.
+ if (CoreCoursesDashboard.instance.isDisabledInSite(site)) {
+ return false;
+ }
+
+ return site.wsAvailable('core_calendar_get_action_events_by_courses') &&
+ site.wsAvailable('core_calendar_get_action_events_by_timesort');
+ }
+
+ /**
+ * Handles course events, filtering and treating if more can be loaded.
+ *
+ * @param course Object containing response course events info.
+ * @param timeFrom Current time to filter events from.
+ * @return Object with course events and last loaded event id if more can be loaded.
+ */
+ protected treatCourseEvents(
+ course: AddonCalendarEvents,
+ timeFrom: number,
+ ): { events: AddonCalendarEvent[]; canLoadMore?: number } {
+
+ const canLoadMore: number | undefined =
+ course.events.length >= AddonBlockTimelineProvider.EVENTS_LIMIT_PER_COURSE ? course.lastid : undefined;
+
+ // Filter events by time in case it uses cache.
+ course.events = course.events.filter((element) => element.timesort >= timeFrom);
+
+ return {
+ events: course.events,
+ canLoadMore,
+ };
+ }
+
+}
+
+export class AddonBlockTimeline extends makeSingleton(AddonBlockTimelineProvider) {}
diff --git a/src/addons/block/timeline/timeline.module.ts b/src/addons/block/timeline/timeline.module.ts
new file mode 100644
index 000000000..3c024d20c
--- /dev/null
+++ b/src/addons/block/timeline/timeline.module.ts
@@ -0,0 +1,38 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { APP_INITIALIZER, NgModule } from '@angular/core';
+import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreBlockDelegate } from '@features/block/services/block-delegate';
+import { AddonBlockTimelineComponentsModule } from './components/components.module';
+import { AddonBlockTimelineHandler } from './services/block-handler';
+
+@NgModule({
+ imports: [
+ IonicModule,
+ AddonBlockTimelineComponentsModule,
+ TranslateModule.forChild(),
+ ],
+ providers: [
+ {
+ provide: APP_INITIALIZER,
+ multi: true,
+ useValue: () => {
+ CoreBlockDelegate.instance.registerHandler(AddonBlockTimelineHandler.instance);
+ },
+ },
+ ],
+})
+export class AddonBlockTimelineModule {}
diff --git a/src/addons/calendar/services/calendar.ts b/src/addons/calendar/services/calendar.ts
index 4644dded1..fe7e1d6a1 100644
--- a/src/addons/calendar/services/calendar.ts
+++ b/src/addons/calendar/services/calendar.ts
@@ -1749,6 +1749,7 @@ export class AddonCalendar extends makeSingleton(AddonCalendarProvider) {}
/**
* Data returned by calendar's events_exporter.
+ * Data returned by core_calendar_get_action_events_by_course and core_calendar_get_action_events_by_timesort WS.
*/
export type AddonCalendarEvents = {
events: AddonCalendarEvent[]; // Events.
@@ -1756,13 +1757,47 @@ export type AddonCalendarEvents = {
lastid: number; // Lastid.
};
+/**
+ * Params of core_calendar_get_action_events_by_courses WS.
+ */
+export type AddonCalendarGetActionEventsByCoursesWSParams = {
+ courseids: number[];
+ timesortfrom?: number; // Time sort from.
+ timesortto?: number; // Time sort to.
+ limitnum?: number; // Limit number.
+};
+
/**
* Data returned by calendar's events_grouped_by_course_exporter.
+ * Data returned by core_calendar_get_action_events_by_courses WS.
*/
export type AddonCalendarEventsGroupedByCourse = {
groupedbycourse: AddonCalendarEventsSameCourse[]; // Groupped by course.
};
+/**
+ * Params of core_calendar_get_action_events_by_course WS.
+ */
+export type AddonCalendarGetActionEventsByCourseWSParams = {
+ courseid: number; // Course id.
+ timesortfrom?: number; // Time sort from.
+ timesortto?: number; // Time sort to.
+ aftereventid?: number; // The last seen event id.
+ limitnum?: number; // Limit number.
+};
+
+/**
+ * Params of core_calendar_get_action_events_by_timesort WS.
+ */
+export type AddonCalendarGetActionEventsByTimesortWSParams = {
+ timesortfrom?: number; // Time sort from.
+ timesortto?: number; // Time sort to.
+ aftereventid?: number; // The last seen event id.
+ limitnum?: number; // Limit number.
+ limittononsuspendedevents?: boolean; // Limit the events to courses the user is not suspended in.
+ userid?: number; // The user id.
+};
+
/**
* Data returned by calendar's events_same_course_exporter.
*/
diff --git a/src/assets/img/icons/activities.svg b/src/assets/img/icons/activities.svg
new file mode 100644
index 000000000..56243a53c
--- /dev/null
+++ b/src/assets/img/icons/activities.svg
@@ -0,0 +1,178 @@
+
+