diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 7e6ec6ff..f891077f 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -83,6 +83,7 @@ import { EnvironmentDetailComponent } from './configuration/environments/environ
import { VmTemplateDetailComponent } from './configuration/vmtemplates/vmtemplate-detail/vmtemplate-detail.component';
import { NgChartsModule } from 'ng2-charts';
import { SessionStatisticsComponent } from './session-statistics/session-statistics.component';
+import { SessionTimeStatisticsComponent } from './session-statistics/session-time-statistics/session-time-statistics.component';
import { VMTemplateServiceFormComponent } from './configuration/vmtemplates/edit-vmtemplate/vmtemplate-service-form/vmtemplate-service-form.component';
import { FilterScenariosComponent } from './filter-scenarios/filter-scenarios.component';
import { MDEditorComponent } from './scenario/md-editor/md-editor.component';
@@ -211,6 +212,7 @@ export function jwtOptionsFactory(): JwtConfig {
AppComponent,
HomeComponent,
SessionStatisticsComponent,
+ SessionTimeStatisticsComponent,
HeaderComponent,
EventComponent,
LoginComponent,
diff --git a/src/app/session-statistics/session-statistics.component.html b/src/app/session-statistics/session-statistics.component.html
index b4e9e687..772ecc8c 100644
--- a/src/app/session-statistics/session-statistics.component.html
+++ b/src/app/session-statistics/session-statistics.component.html
@@ -23,15 +23,17 @@
Session Statistics
name="scenarios"
clrMulti="true"
>
-
- {{ scenarioSelected }}
+
+ {{ getScenarioName(scenario) }}
- {{ scenario }}
+ {{ scenario.value }}
@@ -165,10 +167,20 @@ Started Sessions (List)
- {{ item.key }}
+ {{ getScenarioName(item.key) }}
{{ item.value }}
+ Scenario Statistics
+
diff --git a/src/app/session-statistics/session-statistics.component.ts b/src/app/session-statistics/session-statistics.component.ts
index 9a777281..7fd1e033 100644
--- a/src/app/session-statistics/session-statistics.component.ts
+++ b/src/app/session-statistics/session-statistics.component.ts
@@ -1,11 +1,4 @@
-import {
- Component,
- Input,
- OnChanges,
- OnInit,
- SimpleChanges,
- ViewChild,
-} from '@angular/core';
+import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
import { ChartConfiguration, ChartData, ChartEvent, ChartType } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';
import DataLabelsPlugin, { Context } from 'chartjs-plugin-datalabels';
@@ -39,11 +32,15 @@ const ONE_DAY = 1000 * 60 * 60 * 24;
styleUrls: ['./session-statistics.component.scss'],
})
export class SessionStatisticsComponent implements OnInit, OnChanges {
+ // If no scheduledEvent is given, we display statistics about all progresses for a given time range
+ // If a scheduledEvent is given, we display statistics about all progresses from this scheduledEvent
@Input()
public scheduledEvent: ScheduledEvent;
public currentScheduledEvent: ScheduledEvent;
+ public progressesCache: Progress[];
+
public startView: 'minute' | 'day' | 'month' | 'year' = 'day';
public minView: 'minute' | 'day' | 'month' | 'year' = 'day';
public options: Intl.DateTimeFormatOptions = {
@@ -108,7 +105,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
},
},
];
- public scenariosWithSession: string[] = [];
+ public scenariosWithSessionMap: Map = new Map(); // Maps the id to the name
public totalSessionsPerScenario: Map = new Map();
public descSort = ClrDatagridSortOrder.DESC;
@@ -123,7 +120,14 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.currentScheduledEvent
) {
this.currentScheduledEvent = this.scheduledEvent;
- this.setDatesToScheduledEvent(this.scheduledEvent);
+ this.progressesCache = null; // Reset cache so data from the changed SE can be retreived
+ this.scenariosWithSessionMap = new Map();
+ this.totalSessionsPerScenario = new Map();
+ this.chartDetails.controls.scenarios.setValue(['*']);
+ this.setDatesToScheduledEvent(
+ this.scheduledEvent,
+ this.chartDetails.controls.observationPeriod.value
+ );
this.updateData(this.chartDetails.controls.observationPeriod.value);
}
}
@@ -174,7 +178,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
);
if (this.scheduledEvent) {
- this.setDatesToScheduledEvent(this.scheduledEvent);
+ this.setDatesToScheduledEvent(this.scheduledEvent, 'daily');
}
this.updateLabels('daily');
@@ -214,7 +218,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
}
public clearScenarios(): void {
- this.chartDetails.controls.scenarios.reset();
+ this.chartDetails.controls.scenarios.setValue(['*']);
}
private validateStartDate(): ValidatorFn {
@@ -248,7 +252,10 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
};
}
- private setDatesToScheduledEvent(se: ScheduledEvent) {
+ private setDatesToScheduledEvent(
+ se: ScheduledEvent,
+ observationPeriod: 'daily' | 'weekly' | 'monthly'
+ ) {
const currentDate = new Date();
// Set default start date to beginning of the scheduledEvent
@@ -261,12 +268,13 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.endDate = currentDate;
}
- if (this.chartDetails) {
- this.chartDetails.controls.startDate.setValue(
- this.startDate.toDateString()
- );
- this.chartDetails.controls.endDate.setValue(this.endDate.toDateString());
- }
+ this.chartDetails.controls.endDate.setValue(
+ this.endDate.toLocaleDateString('en-US', this.options)
+ );
+ this.chartDetails.controls.startDate.setValue(
+ this.startDate.toLocaleDateString('en-US', this.options)
+ );
+ this.updateLabels(observationPeriod);
}
private updateData(observationPeriod: 'daily' | 'weekly' | 'monthly') {
@@ -281,6 +289,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.progressService
.listByRange(this.startDate, this.endDate)
.subscribe((progresses: Progress[]) => {
+ this.progressesCache = progresses;
this.processData(progresses, observationPeriod);
});
}
@@ -288,13 +297,16 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
private updateDataByScheduledEvent(
observationPeriod: 'daily' | 'weekly' | 'monthly'
) {
- this.progressService
- .listByScheduledEvent(this.scheduledEvent.id, true)
- .subscribe((progresses: Progress[]) => {
- // TODO: Filter by range
- progresses.forEach((p) => {});
- this.processData(progresses, observationPeriod);
- });
+ if (this.progressesCache) {
+ this.processData(this.progressesCache, observationPeriod);
+ } else {
+ this.progressService
+ .listByScheduledEvent(this.scheduledEvent.id, true)
+ .subscribe((progresses: Progress[]) => {
+ this.progressesCache = progresses;
+ this.processData(this.progressesCache, observationPeriod);
+ });
+ }
}
private processData(
@@ -362,8 +374,8 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
}
}
- public setStartDate(d: DlDateTimePickerChange) {
- this.startDate = d.value;
+ private updateStartDate(d: Date) {
+ this.startDate = d;
this.chartDetails.controls.startDate.setValue(
this.startDate.toLocaleDateString('en-US', this.options)
);
@@ -371,16 +383,22 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.chartDetails.controls.observationPeriod.value;
this.updateLabels(observationPeriod);
this.updateData(observationPeriod);
- this.startDateSignpost.close();
}
- public setEndDate(d: DlDateTimePickerChange) {
+ public setStartDate(d: DlDateTimePickerChange) {
+ this.updateStartDate(d.value);
+ if (this.startDateSignpost) {
+ this.startDateSignpost.close();
+ }
+ }
+
+ private updateEndDate(d: Date) {
const observationPeriod: 'daily' | 'weekly' | 'monthly' =
this.chartDetails.controls.observationPeriod.value;
if (observationPeriod != 'monthly') {
- this.endDate = d.value;
+ this.endDate = d;
} else {
- this.endDate = new Date(d.value.getFullYear(), d.value.getMonth() + 1, 0);
+ this.endDate = new Date(d.getFullYear(), d.getMonth() + 1, 0);
}
this.endDate.setHours(23, 59, 59, 999);
this.updateLabels(observationPeriod);
@@ -388,7 +406,13 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.chartDetails.controls.endDate.setValue(
this.endDate.toLocaleDateString('en-US', this.options)
);
- this.endDateSignpost.close();
+ }
+
+ public setEndDate(d: DlDateTimePickerChange) {
+ this.updateEndDate(d.value);
+ if (this.endDateSignpost) {
+ this.endDateSignpost.close();
+ }
}
// events
@@ -413,29 +437,30 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
}
private setupScenariosWithSessions(progressData: Progress[]) {
- this.scenariosWithSession = [];
+ this.scenariosWithSessionMap = new Map();
progressData.forEach((prog: Progress) => {
- if (!this.scenariosWithSession.includes(prog.scenario_name)) {
- this.scenariosWithSession.push(prog.scenario_name);
- }
+ this.scenariosWithSessionMap.set(prog.scenario, prog.scenario_name);
});
}
private allScenariosSelected(): boolean {
const selectedScenarios = this.chartDetails.controls.scenarios.value;
- return selectedScenarios.length === 1 && selectedScenarios[0] === '*';
+ return (
+ !selectedScenarios ||
+ (selectedScenarios.length === 1 && selectedScenarios[0] === '*')
+ );
}
private prepareBarchartDatasets() {
this.barChartData.datasets.length = 0;
const selectedScenarios = this.chartDetails.controls.scenarios.value;
if (this.allScenariosSelected()) {
- this.scenariosWithSession.forEach((sWithSession: string) => {
+ this.scenariosWithSessionMap.forEach((sWithSession: string) => {
this.barChartData.datasets.push({
data: Array.from({
length: this.barChartData.labels.length,
}).fill(0),
- label: sWithSession,
+ label: this.getScenarioName(sWithSession),
stack: 'a',
});
});
@@ -445,7 +470,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
data: Array.from({
length: this.barChartData.labels.length,
}).fill(0),
- label: sWithSession,
+ label: this.getScenarioName(sWithSession),
stack: 'a',
});
});
@@ -482,16 +507,18 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
return;
}
let evaluatedProgressData: Progress[] = progressData;
- let selectedScenarios: string[] = this.scenariosWithSession;
+ let selectedScenarios: string[] = Array.from(
+ this.scenariosWithSessionMap.keys()
+ );
if (!this.allScenariosSelected()) {
selectedScenarios = this.chartDetails.controls.scenarios.value;
evaluatedProgressData = progressData.filter((progress: Progress) =>
- selectedScenarios.includes(progress.scenario_name)
+ selectedScenarios.includes(progress.scenario)
);
}
evaluatedProgressData.forEach((prog: Progress) => {
const index = getIndex(prog);
- (this.barChartData.datasets[selectedScenarios.indexOf(prog.scenario_name)]
+ (this.barChartData.datasets[selectedScenarios.indexOf(prog.scenario)]
.data[index] as number) += 1;
});
}
@@ -500,8 +527,8 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.totalSessionsPerScenario.clear();
this.totalSessionsPerScenario = progressData.reduce(
(totalSessions, progress) => {
- const partialSum = totalSessions.get(progress.scenario_name) ?? 0;
- totalSessions.set(progress.scenario_name, partialSum + 1);
+ const partialSum = totalSessions.get(progress.scenario) ?? 0;
+ totalSessions.set(progress.scenario, partialSum + 1);
return totalSessions;
},
new Map()
@@ -559,4 +586,8 @@ export class SessionStatisticsComponent implements OnInit, OnChanges {
this.barChartData.labels.push(labelDateString);
}
}
+
+ public getScenarioName(scenarioId: string) {
+ return this.scenariosWithSessionMap.get(scenarioId) ?? scenarioId;
+ }
}
diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.html b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.html
new file mode 100644
index 00000000..6a9052d6
--- /dev/null
+++ b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Step
+ Average Duration
+ Sessions
+
+ Step {{ i + 1 }}
+ {{ formatDuration(duration) }}
+ {{ getNumberOfSessionsForStep(i) }}
+
+
+ Total
+ {{ formatDuration(totalDuration) }}
+ {{ getNumberOfSessionsForStep(0) }}
+
+
+
+
diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.scss b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts
new file mode 100644
index 00000000..d3d2750b
--- /dev/null
+++ b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts
@@ -0,0 +1,265 @@
+import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { ChartConfiguration, ChartData, ChartEvent, ChartType } from 'chart.js';
+import { BaseChartDirective } from 'ng2-charts';
+import DataLabelsPlugin, { Context } from 'chartjs-plugin-datalabels';
+import { Progress } from '../../data/progress';
+import { ProgressService } from '../../data/progress.service';
+import { durationFormatter } from '../../utils';
+import {
+ FormControl,
+ FormGroup,
+ NonNullableFormBuilder,
+ Validators,
+} from '@angular/forms';
+import { deepCopy } from 'src/app/deepcopy';
+
+type ChartDetailsFormGroup = FormGroup<{
+ scenario: FormControl;
+}>;
+
+@Component({
+ selector: 'app-session-time-statistics',
+ templateUrl: './session-time-statistics.component.html',
+ styleUrls: ['./session-time-statistics.component.scss'],
+})
+export class SessionTimeStatisticsComponent implements OnInit {
+ // If no scheduledEvent is given, we display statistics about all progresses for a given time range
+ // If a scheduledEvent is given, we display statistics about all progresses from this scheduledEvent
+ @Input()
+ public progresses: Progress[];
+
+ @Input()
+ public scenariosWithSessionMap: Map; //Maps the scenario id to the name
+
+ public selectedScenario: string;
+
+ public stepDurations: number[];
+ public stepCounts: number[];
+ public avgDuration: number[];
+ public totalDuration: number;
+
+ public chartDetails: ChartDetailsFormGroup;
+ public barChartData: ChartData<'bar'> = {
+ labels: [],
+ datasets: [],
+ };
+ public barChartOptions: ChartConfiguration['options'] = {
+ responsive: true,
+ // We use these empty structures as placeholders for dynamic theming.
+ scales: {
+ x: {},
+ y: {
+ min: 0,
+ ticks: {
+ precision: 0,
+ stepSize: 60,
+ maxTicksLimit: 10,
+ callback: function (value: number, index, ticks) {
+ // Format y-axis labels to time string
+ return durationFormatter(value);
+ },
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top',
+ },
+ datalabels: {
+ // only display datalabels if != 0
+ display: (context: Context) => {
+ return context.dataset.data[context.dataIndex] != 0;
+ },
+ anchor: 'end',
+ align: 'end',
+ formatter: function (duration: number, context) {
+ return durationFormatter(duration);
+ },
+ },
+ },
+ };
+ public barChartType: ChartType = 'bar';
+ public barChartPlugins = [
+ DataLabelsPlugin,
+ {
+ id: 'legendMargin',
+ // chart is of type Chart<'bar'>. However, we are forced to use chart.js version 3.4.0 because it is used by ng2-charts as peer dependency.
+ // And the "legend" property is not defined in the type definitions of this version.
+
+ // We can not upgrade ng2-charts (and so its peer dependency) yet because it requires Angular 14.
+ beforeInit: function (chart: any) {
+ // Get the reference to the original fit function
+ const originalFit = (chart.legend as any).fit;
+
+ // Override the fit function
+ (chart.legend as any).fit = function fit() {
+ // Call original function and bind scope in order to use `this` correctly inside it
+ originalFit.bind(chart.legend)();
+ // Change the height
+ this.height += 20;
+ };
+ },
+ },
+ ];
+
+ constructor(
+ public progressService: ProgressService,
+ private _fb: NonNullableFormBuilder
+ ) {}
+
+ ngOnInit(): void {
+ const firstScenario =
+ this.scenariosWithSessionMap?.keys().next().value ?? '';
+ this.chartDetails = this._fb.group({
+ scenario: this._fb.control(firstScenario, [
+ Validators.required,
+ Validators.minLength(1),
+ ]),
+ });
+
+ this.selectedScenario = firstScenario;
+ this.updateData();
+
+ this.chartDetails.controls.scenario.valueChanges.subscribe(
+ (scenario: string) => {
+ this.chartDetails.controls.scenario.setValue(scenario, {
+ emitEvent: false,
+ });
+ this.selectedScenario = scenario;
+ this.updateData();
+ }
+ );
+ }
+
+ @ViewChild(BaseChartDirective) chart: BaseChartDirective | undefined;
+
+ public clearScenarios(): void {
+ this.chartDetails.controls.scenario.reset();
+ }
+
+ private updateData() {
+ console.log(this.progresses);
+ const evaluatedProgressData = this.progresses.filter(
+ (progress: Progress) => this.selectedScenario == progress.scenario
+ );
+
+ // TODO calculate data
+ let stepTime = [];
+ let stepProgressCount = [];
+
+ evaluatedProgressData.forEach((p) => {
+ // Increase the count of progresses that have been to this step
+ for (let i = 0; i < p.max_step; i++) {
+ if (stepProgressCount[i]) {
+ stepProgressCount[i]++;
+ } else {
+ stepProgressCount[i] = 1;
+ }
+ }
+
+ for (let i = 1; i < p.steps.length; i++) {
+ const step = p.steps[i].step;
+ let step_end_time;
+ if (i + 1 == p.steps.length) {
+ // This is the last step entry, we take the last update time to calculate the duration
+ step_end_time = p.last_update;
+ } else {
+ // Normal case is to take the next step entry
+ step_end_time = p.steps[i + 1].timestamp;
+ }
+
+ let duration =
+ new Date(step_end_time).getTime() -
+ new Date(p.steps[i].timestamp).getTime();
+
+ if (stepTime[step - 1]) {
+ stepTime[step - 1] += duration;
+ } else {
+ stepTime[step - 1] = duration;
+ }
+ }
+ });
+
+ this.stepCounts = stepProgressCount;
+ this.stepDurations = stepTime;
+ this.avgDuration = [];
+ this.totalDuration = 0;
+
+ for (let i = 0; i < this.stepDurations.length; i++) {
+ const avgStepDurationInSeconds = Math.round(
+ this.stepDurations[i] / this.stepCounts[i] / 1000
+ );
+ this.avgDuration.push(avgStepDurationInSeconds);
+ this.totalDuration += avgStepDurationInSeconds;
+ }
+
+ this.processData();
+ }
+
+ private processData() {
+ this.prepareBarchartDatasets();
+ this.updateBarchartData();
+ this.chart?.update();
+ }
+
+ // events
+ public chartClicked({
+ event,
+ active,
+ }: {
+ event?: ChartEvent;
+ active?: {}[];
+ }): void {
+ // console.log(event, active);
+ }
+
+ public chartHovered({
+ event,
+ active,
+ }: {
+ event?: ChartEvent;
+ active?: {}[];
+ }): void {
+ // console.log(event, active);
+ }
+
+ private prepareBarchartDatasets() {
+ this.barChartData.datasets.length = 0;
+ this.barChartData.datasets.push({
+ data: Array.from({
+ length: this.barChartData.labels.length,
+ }).fill(0),
+ label: this.scenariosWithSessionMap.get(this.selectedScenario),
+ stack: 'a',
+ });
+ }
+
+ private updateBarchartData() {
+ if (this.barChartData.datasets.length === 0) {
+ // there are no scenarios selected and there is nothing to add ... so return!
+ return;
+ }
+
+ this.barChartData.datasets[0].data = this.avgDuration;
+
+ this.updateLabels();
+ }
+
+ // Info: Labels are not getting updated before calling this.chart.update()
+ private updateLabels() {
+ // We need to keep the same reference for our label array, otherwise label data is not updated correctly -> hence set length to 0
+ this.barChartData.labels.length = 0;
+ for (let i = 0; i < this.avgDuration.length; i++) {
+ this.barChartData.labels.push('Step ' + (i + 1));
+ }
+ }
+
+ public formatDuration(duration: number) {
+ return durationFormatter(duration);
+ }
+
+ public getNumberOfSessionsForStep(stepIndex: number) {
+ return this.stepCounts[stepIndex] ?? '-';
+ }
+}
diff --git a/src/app/utils.ts b/src/app/utils.ts
index 5df5f155..a9d6e7c4 100644
--- a/src/app/utils.ts
+++ b/src/app/utils.ts
@@ -1,29 +1,56 @@
-export function timeSince(date: Date, end: Date = new Date(), series: number = 2,) {
- var seconds: number = Math.floor((end.getTime() - date.getTime()) / 1000);
- var intervals = [31536000, 2592000, 86400, 3600, 60, 1];
- var intervalCounts = [0,0,0,0,0,0];
- var intervalStrings = ["year", "month", "day", "hour", "minute", "second"];
- var text = "";
+export function timeSince(
+ date: Date,
+ end: Date = new Date(),
+ series: number = 2
+) {
+ var seconds: number = Math.floor((end.getTime() - date.getTime()) / 1000);
+ var intervals = [31536000, 2592000, 86400, 3600, 60, 1];
+ var intervalCounts = [0, 0, 0, 0, 0, 0];
+ var intervalStrings = ['year', 'month', 'day', 'hour', 'minute', 'second'];
+ var text = '';
- for(let i = 0; i < intervals.length; i++){
- let interval = seconds / intervals[i];
- if (interval > 1) {
- let count = Math.floor(interval)
- intervalCounts[i] = count;
- seconds -= count * intervals[i];
- continue;
- }
+ for (let i = 0; i < intervals.length; i++) {
+ let interval = seconds / intervals[i];
+ if (interval > 1) {
+ let count = Math.floor(interval);
+ intervalCounts[i] = count;
+ seconds -= count * intervals[i];
+ continue;
}
+ }
- for(let i = 0; i < intervalCounts.length && series > 0; i++){
- if(intervalCounts[i] > 0 || i + 1 == intervalCounts.length){
- text += intervalCounts[i] + " " + intervalStrings[i] + (intervalCounts[i] != 1 ? "s" : "");
- series -= 1;
- if(series > 0 && i + 1 != intervalCounts.length){
- text += ", ";
- }
+ for (let i = 0; i < intervalCounts.length && series > 0; i++) {
+ if (intervalCounts[i] > 0 || i + 1 == intervalCounts.length) {
+ text +=
+ intervalCounts[i] +
+ ' ' +
+ intervalStrings[i] +
+ (intervalCounts[i] != 1 ? 's' : '');
+ series -= 1;
+ if (series > 0 && i + 1 != intervalCounts.length) {
+ text += ', ';
}
}
+ }
+
+ return text;
+}
+
+// Converts duration from a number to a text string
+export function durationFormatter(duration: number) {
+ const hours = Math.floor(duration / 3600);
+ const minutes = Math.floor((duration % 3600) / 60);
+ const seconds = duration % 60;
- return text;
-}
\ No newline at end of file
+ let result = '';
+ if (hours > 0) {
+ result += `${hours}h`;
+ }
+ if (minutes > 0 || (hours > 0 && seconds > 0)) {
+ result += `${minutes}m`;
+ }
+ if (seconds > 0) {
+ result += `${seconds}s`;
+ }
+ return result || '0s';
+}