MOBILE-3413 h5pactivity: Implement viewing own attempts

main
Dani Palou 2020-05-29 12:01:21 +02:00
parent 857f3da70e
commit 22caea43ba
12 changed files with 544 additions and 2 deletions

View File

@ -1,6 +1,7 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons end>
<core-context-menu>
<core-context-menu-item *ngIf="h5pActivity && h5pActivity.enabletracking" [priority]="1000" [content]="'addon.mod_h5pactivity.review_my_attempts' | translate" (action)="viewMyAttempts()" [iconAction]="'stats'"></core-context-menu-item>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'fa-newspaper-o'" (action)="gotoBlog($event)"></core-context-menu-item>

View File

@ -319,6 +319,13 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
AddonModH5PActivity.instance.logView(this.h5pActivity.id, this.h5pActivity.name, this.siteId);
}
/**
* Go to view user events.
*/
viewMyAttempts(): void {
this.navCtrl.push('AddonModH5PActivityUserAttemptsPage', {courseId: this.courseId, h5pActivityId: this.h5pActivity.id});
}
/**
* Component destroyed.
*/

View File

@ -1,8 +1,22 @@
{
"all_attempts": "All user attempts",
"attempt_completion_no": "This attempt is not marked as completed",
"attempt_completion_yes": "This attempt is completed",
"attempts_none": "This user has no attempts to display.",
"attempt_success_fail": "Fail",
"attempt_success_pass": "Pass",
"attempt_success_unknown": "Not reported",
"completion": "Completion",
"downloadh5pfile": "Download H5P file",
"duration": "Duration",
"errorgetactivity": "Error getting H5P activity data.",
"filestatenotdownloaded": "The H5P package is not downloaded. You need to download it to be able to use it.",
"filestateoutdated": "The H5P package has been modified since the last download. You need to download it again to be able to use it.",
"maxscore": "Max score",
"modulenameplural": "H5P",
"offlinedisabledwarning": "You will need to be online to view the H5P package."
"myattempts": "My attempts",
"offlinedisabledwarning": "You will need to be online to view the H5P package.",
"review_my_attempts": "View my attempts",
"score": "Score",
"viewattempt": "View attempt {{$a}}"
}

View File

@ -0,0 +1,80 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title><core-format-text *ngIf="h5pActivity" [text]="h5pActivity.name" contextLevel="module" [contextInstanceId]="h5pActivity.coursemodule" [courseId]="courseId"></core-format-text></ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<!-- User viewed. -->
<ion-list *ngIf="user">
<ion-item text-wrap>
<ion-avatar core-user-avatar [user]="user" item-start></ion-avatar>
<h2 *ngIf="!isCurrentUser">{{ user.fullname }}</h2>
<h2 *ngIf="isCurrentUser">{{ 'addon.mod_h5pactivity.myattempts' | translate }}</h2>
</ion-item>
</ion-list>
<ion-list *ngIf="attemptsData">
<!-- Attempts used to calculate the score. -->
<ng-container *ngIf="attemptsData.scored">
<ion-item-divider>
<h2>{{ attemptsData.scored.title }}</h2>
</ion-item-divider>
<ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.scored.attempts}"></ng-container>
</ng-container>
<!-- All attempts. -->
<ng-container *ngIf="attemptsData.attempts && attemptsData.attempts.length">
<ion-item-divider>
<h2>{{ 'addon.mod_h5pactivity.all_attempts' | translate }}</h2>
</ion-item-divider>
<ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.attempts}"></ng-container>
</ng-container>
</ion-list>
<!-- No attempts. -->
<core-empty-box *ngIf="attemptsData && (!attemptsData.attempts || !attemptsData.attempts.length)" icon="stats" [message]="'addon.mod_h5pactivity.attempts_none' | translate">
</core-empty-box>
</core-loading>
</ion-content>
<!-- Template to render a list of conversations. -->
<ng-template #attemptsTemplate let-attempts="attempts">
<!-- "Header" of the table -->
<ion-item text-wrap class="addon-mod_h5pactivity-table-header" detail-push>
<ion-row align-items-center>
<ion-col text-center>#</ion-col>
<ion-col text-center col-5 col-md-2>{{ 'core.date' | translate }}</ion-col>
<ion-col text-center>{{ 'addon.mod_h5pactivity.score' | translate }}</ion-col>
<ion-col text-center class="hidden-phone">{{ 'addon.mod_h5pactivity.maxscore' | translate }}</ion-col>
<ion-col text-center class="hidden-phone">{{ 'addon.mod_h5pactivity.duration' | translate }}</ion-col>
<ion-col text-center class="hidden-phone">{{ 'addon.mod_h5pactivity.completion' | translate }}</ion-col>
<ion-col text-center>{{ 'core.success' | translate }}</ion-col>
</ion-row>
</ion-item>
<!-- List of attempts. -->
<a ion-item text-wrap *ngFor="let attempt of attempts" [attr.aria-label]="'addon.mod_h5pactivity.viewattempt' | translate:{$a: attempt.attempt}" class="addon-mod_h5pactivity-table-row">
<ion-row align-items-center>
<ion-col text-center>{{ attempt.attempt }}</ion-col>
<ion-col text-center col-5 col-md-2>{{ attempt.timemodified | coreFormatDate:'strftimedatetimeshort' }}</ion-col>
<ion-col text-center>
{{ attempt.rawscore }}<span class="hidden-tablet"> / {{ attempt.maxscore }}</span>
</ion-col>
<ion-col text-center class="hidden-phone">{{ attempt.maxscore }}</ion-col>
<ion-col text-center class="hidden-phone">{{ attempt.durationReadable }}</ion-col>
<ion-col text-center class="hidden-phone">
<img *ngIf="attempt.completion" src="assets/img/completion/completion-auto-y.svg" [alt]="'addon.mod_h5pactivity.attempt_completion_yes' | translate">
<img *ngIf="!attempt.completion" src="assets/img/completion/completion-auto-n.svg" [alt]="'addon.mod_h5pactivity.attempt_completion_no' | translate">
</ion-col>
<ion-col text-center class="addon-mod_h5pactivity-table-success-col">
<core-icon *ngIf="attempt.success !== null && attempt.success" name="fa-check-circle" [label]="'addon.mod_h5pactivity.attempt_success_pass' | translate"></core-icon>
<core-icon *ngIf="attempt.success !== null && !attempt.success" name="fa-circle-o" [label]="'addon.mod_h5pactivity.attempt_success_fail' | translate"></core-icon>
<img *ngIf="attempt.success === null" src="assets/img/icons/empty.svg" [alt]="'addon.mod_h5pactivity.attempt_success_unknown' | translate">
</ion-col>
</ion-row>
</a>
</ng-template>

View File

@ -0,0 +1,35 @@
// (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 { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { AddonModH5PActivityUserAttemptsPage } from './user-attempts';
@NgModule({
declarations: [
AddonModH5PActivityUserAttemptsPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
IonicPageModule.forChild(AddonModH5PActivityUserAttemptsPage),
TranslateModule.forChild(),
],
})
export class AddonModH5PActivityUserAttemptsPageModule {}

View File

@ -0,0 +1,37 @@
ion-app.app-root page-addon-mod-h5pactivity-user-attempts {
.item.addon-mod_h5pactivity-table-header[detail-push] .item-inner {
background-image: none;
}
.item.addon-mod_h5pactivity-table-header .item-inner {
font-size: 0.9em;
font-weight: bold;
.col[text-center] {
@include padding-horizontal(0);
}
}
.addon-mod_h5pactivity-table-header, .addon-mod_h5pactivity-table-row {
.item-inner ion-label {
@include margin(null, 0, null, null);
}
.item {
@include padding(null, null, null, 0);
}
.label {
margin-top: 0;
margin-bottom: 0;
}
}
.addon-mod_h5pactivity-table-row {
.addon-mod_h5pactivity-table-success-col {
font-size: 1.4em;
}
}
}

View File

@ -0,0 +1,134 @@
// (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 { IonicPage, NavParams } from 'ionic-angular';
import { CoreSites } from '@providers/sites';
import { CoreDomUtils } from '@providers/utils/dom';
import { CoreUser } from '@core/user/providers/user';
import {
AddonModH5PActivity, AddonModH5PActivityData, AddonModH5PActivityUserAttempts
} from '../../providers/h5pactivity';
/**
* Page that displays user attempts of a certain user.
*/
@IonicPage({ segment: 'addon-mod-h5pactivity-user-attempts' })
@Component({
selector: 'page-addon-mod-h5pactivity-user-attempts',
templateUrl: 'user-attempts.html',
})
export class AddonModH5PActivityUserAttemptsPage implements OnInit {
loaded: boolean;
h5pActivity: AddonModH5PActivityData;
attemptsData: AddonModH5PActivityUserAttempts;
user: any;
isCurrentUser: boolean;
protected courseId: number;
protected h5pActivityId: number;
protected userId: number;
constructor(navParams: NavParams) {
this.courseId = navParams.get('courseId');
this.h5pActivityId = navParams.get('h5pActivityId');
this.userId = navParams.get('userId') || CoreSites.instance.getCurrentSiteUserId();
this.isCurrentUser = this.userId == CoreSites.instance.getCurrentSiteUserId();
}
/**
* Component being initialized.
*
* @return Promise resolved when done.
*/
async ngOnInit(): Promise<void> {
try {
await this.fetchData();
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading attempts.');
} finally {
this.loaded = true;
}
}
/**
* Refresh the data.
*
* @param refresher Refresher.
*/
doRefresh(refresher: any): void {
this.refreshData().finally(() => {
refresher.complete();
});
}
/**
* Get quiz data and attempt data.
*
* @return Promise resolved when done.
*/
protected async fetchData(): Promise<void> {
await Promise.all([
this.fetchActivity(),
this.fetchAttempts(),
this.fetchUserProfile(),
]);
}
/**
* Get activity data.
*
* @return Promise resolved when done.
*/
protected async fetchActivity(): Promise<void> {
this.h5pActivity = await AddonModH5PActivity.instance.getH5PActivityById(this.courseId, this.h5pActivityId);
}
/**
* Get attempts.
*
* @return Promise resolved when done.
*/
protected async fetchAttempts(): Promise<void> {
this.attemptsData = await AddonModH5PActivity.instance.getUserAttempts(this.h5pActivityId, { userId: this.userId });
}
/**
* Get user profile.
*
* @return Promise resolved when done.
*/
protected async fetchUserProfile(): Promise<void> {
this.user = await CoreUser.instance.getProfile(this.userId, this.courseId, true);
}
/**
* Refresh the data.
*
* @return Promise resolved when done.
*/
protected async refreshData(): Promise<void> {
try {
await Promise.all([
AddonModH5PActivity.instance.invalidateActivityData(this.courseId),
AddonModH5PActivity.instance.invalidateUserAttempts(this.h5pActivityId, this.userId),
]);
} catch (error) {
// Ignore errors.
}
await this.fetchData();
}
}

View File

@ -16,6 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreSites } from '@providers/sites';
import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws';
import { CoreTimeUtils } from '@providers/utils/time';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreCourseLogHelper } from '@core/course/providers/log-helper';
import { CoreH5P } from '@core/h5p/providers/h5p';
@ -32,6 +33,43 @@ export class AddonModH5PActivityProvider {
protected ROOT_CACHE_KEY = 'mmaModH5PActivity:';
/**
* Format an attempt's data.
*
* @param attempt Attempt to format.
*/
protected formatAttempt(attempt: AddonModH5PActivityWSAttempt): AddonModH5PActivityAttempt {
const formattedAttempt: AddonModH5PActivityAttempt = attempt;
formattedAttempt.timemodified = attempt.timemodified * 1000; // Convert to milliseconds.
formattedAttempt.durationReadable = CoreTimeUtils.instance.formatTime(attempt.duration);
formattedAttempt.durationCompact = CoreTimeUtils.instance.formatDurationShort(attempt.duration);
return formattedAttempt;
}
/**
* Format the attempts of a user.
*
* @param data Data to format.
* @return Formatted data.
*/
protected formatUserAttempts(data: AddonModH5PActivityWSUserAttempts): AddonModH5PActivityUserAttempts {
const formatted: AddonModH5PActivityUserAttempts = data;
for (const i in formatted.attempts) {
formatted.attempts[i] = this.formatAttempt(formatted.attempts[i]);
}
if (formatted.scored) {
for (const i in formatted.scored.attempts) {
formatted.scored.attempts[i] = this.formatAttempt(formatted.scored.attempts[i]);
}
}
return formatted;
}
/**
* Get cache key for access information WS calls.
*
@ -172,6 +210,59 @@ export class AddonModH5PActivityProvider {
return this.getH5PActivityByField(courseId, 'id', id, forceCache, siteId);
}
/**
* Get cache key for attemps WS calls.
*
* @param id Instance ID.
* @param userIds User IDs.
* @return Cache key.
*/
protected getUserAttemptsCacheKey(id: number, userIds: number[]): string {
return this.getUserAttemptsCommonCacheKey(id) + ':' + JSON.stringify(userIds);
}
/**
* Get common cache key for attempts WS calls.
*
* @param id Instance ID.
* @return Cache key.
*/
protected getUserAttemptsCommonCacheKey(id: number): string {
return this.ROOT_CACHE_KEY + 'attempts:' + id;
}
/**
* Get attempts of a certain user.
*
* @param id Activity ID.
* @param options Other options.
* @return Promise resolved with the attempts of the user.
*/
async getUserAttempts(id: number, options?: AddonModH5PActivityGetAttemptsOptions): Promise<AddonModH5PActivityUserAttempts> {
options = options || {};
const site = await CoreSites.instance.getSite(options.siteId);
const params = {
h5pactivityid: id,
userids: [options.userId || site.getUserId()],
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getUserAttemptsCacheKey(id, params.userids),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
};
if (options.ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
const response: AddonModH5PActivityGetAttemptsResult = await site.read('mod_h5pactivity_get_attempts', params, preSets);
return this.formatUserAttempts(response.usersattempts[0]);
}
/**
* Invalidates access information.
*
@ -199,6 +290,35 @@ export class AddonModH5PActivityProvider {
await site.invalidateWsCacheForKey(this.getH5PActivityDataCacheKey(courseId));
}
/**
* Invalidates all users attempts for H5P activity.
*
* @param id Activity ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data is invalidated.
*/
async invalidateAllUserAttempts(id: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
await site.invalidateWsCacheForKey(this.getUserAttemptsCommonCacheKey(id));
}
/**
* Invalidates attempts of a certain user for H5P activity.
*
* @param id Activity ID.
* @param userId User ID. If not defined, current user in the site.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data is invalidated.
*/
async invalidateUserAttempts(id: number, userId?: number, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
await site.invalidateWsCacheForKey(this.getUserAttemptsCacheKey(id, [userId]));
}
/**
* Delete launcher.
*
@ -286,6 +406,67 @@ export type AddonModH5PActivityAccessInfo = {
canreviewattempts?: boolean; // Whether the user has the capability mod/h5pactivity:reviewattempts allowed.
};
/**
* Result of WS mod_h5pactivity_get_attempts.
*/
export type AddonModH5PActivityGetAttemptsResult = {
activityid: number; // Activity course module ID.
usersattempts: AddonModH5PActivityWSUserAttempts[]; // The complete users attempts list.
warnings?: CoreWSExternalWarning[];
};
/**
* Attempts data for a user as returned by the WS.
*/
export type AddonModH5PActivityWSUserAttempts = {
userid: number; // The user id.
attempts: AddonModH5PActivityWSAttempt[]; // The complete attempts list.
scored?: { // Attempts used to grade the activity.
title: string; // Scored attempts title.
grademethod: string; // Scored attempts title.
attempts: AddonModH5PActivityWSAttempt[]; // List of the grading attempts.
};
};
/**
* Attempt data as returned by the WS.
*/
export type AddonModH5PActivityWSAttempt = {
id: number; // ID of the context.
h5pactivityid: number; // ID of the H5P activity.
userid: number; // ID of the user.
timecreated: number; // Attempt creation.
timemodified: number; // Attempt modified.
attempt: number; // Attempt number.
rawscore: number; // Attempt score value.
maxscore: number; // Attempt max score.
duration: number; // Attempt duration in seconds.
completion?: number; // Attempt completion.
success?: number; // Attempt success.
scaled: number; // Attempt scaled.
};
/**
* Attempts data with some calculated data.
*/
export type AddonModH5PActivityUserAttempts = {
userid: number; // The user id.
attempts: AddonModH5PActivityAttempt[]; // The complete attempts list.
scored?: { // Attempts used to grade the activity.
title: string; // Scored attempts title.
grademethod: string; // Scored attempts title.
attempts: AddonModH5PActivityAttempt[]; // List of the grading attempts.
};
};
/**
* Attempt with some calculated data.
*/
export type AddonModH5PActivityAttempt = AddonModH5PActivityWSAttempt & {
durationReadable?: string; // Duration in a human readable format.
durationCompact?: string; // Duration in a "short" human readable format.
};
/**
* Options to pass to getDeployedFile function.
*/
@ -294,3 +475,12 @@ export type AddonModH5PActivityGetDeployedFileOptions = {
ignoreCache?: boolean; // Whether to ignore cache. Will fail if offline or server down.
siteId?: string; // Site ID. If not defined, current site.
};
/**
* Options to pass to getAttempts function.
*/
export type AddonModH5PActivityGetAttemptsOptions = {
ignoreCache?: boolean; // Whether to ignore cache. Will fail if offline or server down.
siteId?: string; // Site ID. If not defined, current site.
userId?: number; // User ID. If not defined, user of the site.
};

View File

@ -0,0 +1,3 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -659,12 +659,26 @@
"addon.mod_glossary.noentriesfound": "No entries were found.",
"addon.mod_glossary.searchquery": "Search query",
"addon.mod_glossary.tagarea_glossary_entries": "Glossary entries",
"addon.mod_h5pactivity.all_attempts": "All user attempts",
"addon.mod_h5pactivity.attempt_completion_no": "This attempt is not marked as completed",
"addon.mod_h5pactivity.attempt_completion_yes": "This attempt is completed",
"addon.mod_h5pactivity.attempt_success_fail": "Fail",
"addon.mod_h5pactivity.attempt_success_pass": "Pass",
"addon.mod_h5pactivity.attempt_success_unknown": "Not reported",
"addon.mod_h5pactivity.attempts_none": "This user has no attempts to display.",
"addon.mod_h5pactivity.completion": "Completion",
"addon.mod_h5pactivity.downloadh5pfile": "Download H5P file",
"addon.mod_h5pactivity.duration": "Duration",
"addon.mod_h5pactivity.errorgetactivity": "Error getting H5P activity data.",
"addon.mod_h5pactivity.filestatenotdownloaded": "The H5P package is not downloaded. You need to download it to be able to use it.",
"addon.mod_h5pactivity.filestateoutdated": "The H5P package has been modified since the last download. You need to download it again to be able to use it.",
"addon.mod_h5pactivity.maxscore": "Max score",
"addon.mod_h5pactivity.modulenameplural": "H5P",
"addon.mod_h5pactivity.myattempts": "My attempts",
"addon.mod_h5pactivity.offlinedisabledwarning": "You will need to be online to view the H5P package.",
"addon.mod_h5pactivity.review_my_attempts": "View my attempts",
"addon.mod_h5pactivity.score": "Score",
"addon.mod_h5pactivity.viewattempt": "View attempt {{$a}}",
"addon.mod_imscp.deploymenterror": "Content package error!",
"addon.mod_imscp.modulenameplural": "IMS content packages",
"addon.mod_imscp.showmoduledescription": "Show description",

View File

@ -22,6 +22,8 @@ import { CoreAppProvider } from '@providers/app';
import { CoreUserOfflineProvider } from './offline';
import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications';
import { makeSingleton } from '@singletons/core.singletons';
/**
* Service to provide user functionalities.
*/
@ -733,6 +735,8 @@ export class CoreUserProvider {
}
}
export class CoreUser extends makeSingleton(CoreUserProvider) {}
/**
* Data returned by user_summary_exporter.
*/

View File

@ -220,7 +220,7 @@ export class CoreTimeUtilsProvider {
* Returns hours, minutes and seconds in a human readable format.
*
* @param duration Duration in seconds
* @param precision Number of elements to have in precission. 0 or undefined to full precission.
* @param precision Number of elements to have in precision. 0 or undefined to full precission.
* @return Duration in a human readable format.
*/
formatDuration(duration: number, precision?: number): string {
@ -253,6 +253,29 @@ export class CoreTimeUtilsProvider {
return durationString.trim();
}
/**
* Returns duration in a short human readable format: minutes and seconds, in fromat: 3' 27''.
*
* @param duration Duration in seconds
* @return Duration in a short human readable format.
*/
formatDurationShort(duration: number): string {
const minutes = Math.floor(duration / 60);
const seconds = duration - minutes * 60;
const durations = [];
if (minutes > 0) {
durations.push(minutes + '\'');
}
if (seconds > 0 || minutes === 0) {
durations.push(seconds + '\'\'');
}
return durations.join(' ');
}
/**
* Return the current timestamp in a "readable" format: YYYYMMDDHHmmSS.
*