Merge pull request #5347 from GabrielInTheWorld/statistics

Create statistics of closed list of speakers
This commit is contained in:
Sean 2020-05-11 11:07:53 +02:00 committed by GitHub
commit 7a31cff612
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 430 additions and 27 deletions

View File

@ -62,6 +62,17 @@ const ListOfSpeakersNestedModelDescriptors: NestedModelDescriptors = {
]
};
/**
* An object, that contains information about structure-level,
* speaking-time and finished-speakers.
* Helpful to get a relation between speakers and their structure-level.
*/
export interface SpeakingTimeStructureLevelObject {
structureLevel: string;
finishedSpeakers: ViewSpeaker[];
speakingTime: number;
}
/**
* Repository service for lists of speakers
*
@ -228,6 +239,95 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
return !this.getViewModelList().some(list => list.hasSpeakerSpoken(speaker));
}
/**
* List every speaker only once, who has spoken
*
* @returns A list with all different speakers.
*/
public getAllFirstContributions(): ViewSpeaker[] {
const speakers: ViewSpeaker[] = this.getViewModelList().flatMap(
(los: ViewListOfSpeakers) => los.finishedSpeakers
);
const firstContributions: ViewSpeaker[] = [];
for (const speaker of speakers) {
if (!firstContributions.find(s => s.user_id === speaker.user_id)) {
firstContributions.push(speaker);
}
}
return firstContributions;
}
/**
* Maps structure-level to speaker.
*
* @returns A list, which entries are `SpeakingTimeStructureLevelObject`.
*/
public getSpeakingTimeStructureLevelRelation(): SpeakingTimeStructureLevelObject[] {
let listSpeakingTimeStructureLevel: SpeakingTimeStructureLevelObject[] = [];
for (const los of this.getViewModelList()) {
for (const speaker of los.finishedSpeakers) {
const nextEntry = this.getSpeakingTimeStructureLevelObject(speaker);
listSpeakingTimeStructureLevel = this.getSpeakingTimeStructureLevelList(
nextEntry,
listSpeakingTimeStructureLevel
);
}
}
return listSpeakingTimeStructureLevel;
}
/**
* Helper-function to create a `SpeakingTimeStructureLevelObject` by a given speaker.
*
* @param speaker, with whom structure-level and speaking-time is calculated.
*
* @returns The created `SpeakingTimeStructureLevelObject`.
*/
private getSpeakingTimeStructureLevelObject(speaker: ViewSpeaker): SpeakingTimeStructureLevelObject {
return {
structureLevel:
!speaker.user || (speaker.user && !speaker.user.structure_level)
? 'No structure level'
: speaker.user.structure_level,
finishedSpeakers: [speaker],
speakingTime: this.getSpeakingTimeAsNumber(speaker)
};
}
/**
* Helper-function to update entries in a given list, if already existing, or create entries otherwise.
*
* @param object A `SpeakingTimeStructureLevelObject`, that contains information about speaking-time
* and structure-level.
* @param list A list, at which speaking-time, structure-level and finished_speakers are set.
*
* @returns The updated map.
*/
private getSpeakingTimeStructureLevelList(
object: SpeakingTimeStructureLevelObject,
list: SpeakingTimeStructureLevelObject[]
): SpeakingTimeStructureLevelObject[] {
const index = list.findIndex(entry => entry.structureLevel === object.structureLevel);
if (index >= 0) {
list[index].speakingTime += object.speakingTime;
list[index].finishedSpeakers.push(...object.finishedSpeakers);
} else {
list.push(object);
}
return list;
}
/**
* This function calculates speaking-time as number for a given speaker.
*
* @param speaker The speaker, whose speaking-time should be calculated.
*
* @returns A number, that represents the speaking-time.
*/
private getSpeakingTimeAsNumber(speaker: ViewSpeaker): number {
return Math.floor((new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000);
}
/**
* Helper function get the url to the speaker rest address
*

View File

@ -62,6 +62,24 @@ export class DurationService {
return time;
}
/**
* Calculates a given time to a readable string, that contains hours, minutes and seconds.
*
* @param duration The time as number (in seconds).
*
* @returns A readable time-string.
*/
public durationToStringWithHours(duration: number): string {
const hours = Math.floor(duration / 3600);
const minutes = `0${Math.floor((duration % 3600) / 60)}`.slice(-2);
const seconds = `0${Math.floor(duration % 60)}`.slice(-2);
if (!isNaN(+minutes) && !isNaN(+seconds)) {
return `${hours}:${minutes}:${seconds} h`;
} else {
return '';
}
}
/**
* Converts a duration number (given in minutes or seconds)
*

View File

@ -484,7 +484,7 @@ export class ListViewTableComponent<V extends BaseViewModel | BaseViewModelWithC
}
public isElementProjected = (context: PblNgridRowContext<V>) => {
if (this.projectorService.isProjected(this.getProjectable(context.$implicit as V))) {
if (this.allowProjector && this.projectorService.isProjected(this.getProjectable(context.$implicit as V))) {
return 'projected';
}
};

View File

@ -1,25 +1,23 @@
<mat-card class="os-card" *osPerms="'users.can_manage'">
<button type="button" mat-button (click)="countUsers()" *ngIf="!this.token">
<span>{{ 'Count active users' | translate }}</span>
</button>
<button type="button" mat-button (click)="stopCounting()" *ngIf="this.token">
<span>{{ 'Stop counting' | translate }}</span>
</button>
<div *ngIf="stats">
<p>
{{ userIds().length }} <span>{{ 'active users' | translate }}</span> ({{ stats.activeUserHandles }}
<span>{{ 'connections' | translate }}</span
<button type="button" mat-button (click)="countUsers()" *ngIf="!this.token">
<span>{{ 'Count active users' | translate }}</span>
</button>
<button type="button" mat-button (click)="stopCounting()" *ngIf="this.token">
<span>{{ 'Stop counting' | translate }}</span>
</button>
<div *ngIf="stats">
<p>
{{ userIds().length }} <span>{{ 'active users' | translate }}</span> ({{ stats.activeUserHandles }}
<span>{{ 'connections' | translate }}</span
>)
</p>
<h3>{{ 'Groups' | translate }}</h3>
<ul>
<li *ngFor="let groupId of groupIds()">
<strong>{{ stats.groups[groupId].name }}</strong>
<span> : {{ userInGroupIds(groupId).length }} </span>
<span>{{ 'active users' | translate }}</span>
(<span>{{ stats.groups[groupId].userHandleCount }}</span> <span>{{ 'connections' | translate }}</span
>)
</p>
<h3>{{ 'Groups' | translate }}</h3>
<ul>
<li *ngFor="let groupId of groupIds()">
<strong>{{ stats.groups[groupId].name }}</strong>
<span> : {{ userInGroupIds(groupId).length }} </span>
<span>{{ 'active users' | translate }}</span>
(<span>{{ stats.groups[groupId].userHandleCount }}</span> <span>{{ 'connections' | translate }}</span
>)
</li>
</ul>
</div>
</mat-card>
</li>
</ul>
</div>

View File

@ -34,4 +34,14 @@
</div>
</mat-card>
<os-count-users></os-count-users>
<mat-card class="os-card" *osPerms="'users.can_manage'">
<h3>{{ 'Statistics' | translate }}</h3>
<div>
<h4>{{ 'Count active users' | translate }}</h4>
<os-count-users></os-count-users>
</div>
<div>
<h4>{{ 'Count active word requests' | translate }}</h4>
<os-user-statistics></os-user-statistics>
</div>
</mat-card>

View File

@ -0,0 +1,48 @@
<button mat-button *ngIf="!openedStatistics" (click)="changeViewOfStatistics()">
{{ 'Count active word requests' | translate }}
</button>
<button mat-button *ngIf="openedStatistics" (click)="changeViewOfStatistics()">
{{ 'Stop counting' | translate }}
</button>
<ng-container *ngIf="openedStatistics">
<table class="user-statistics-table">
<tr>
<td>{{ 'Total number of word requests' | translate }}</td>
<td>{{ numberOfWordRequests }}</td>
</tr>
<tr>
<td>{{ 'Unique speakers' | translate }}</td>
<td>{{ numberOfUniqueSpeakers }}</td>
</tr>
<tr>
<td>{{ 'Total duration of word requests' | translate }}</td>
<td>{{ totalDuration }}</td>
</tr>
</table>
<os-list-view-table
[listObservable]="statisticsByStructureLevelObservable"
[columns]="columnDefinition"
[filterProps]="filterProps"
[vScrollFixed]="45"
[allowProjector]="false"
[fullScreen]="false"
[showListOfSpeakers]="false"
[cssClasses]="{ 'user-statistics-table--virtual-scroll': true }"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'structureLevel'; row as object">
{{ object.structureLevel | translate }}
</div>
<div *pblNgridCellDef="'durationOfWordRequests'; row as object">
{{ parseDuration(object.speakingTime) }}
</div>
<div *pblNgridCellDef="'numberOfWordRequests'; row as object">
{{ object.finishedSpeakers.length }}
</div>
</os-list-view-table>
</ng-container>

View File

@ -0,0 +1,25 @@
.user-statistics-table {
width: 100%;
margin: 16px auto;
&,
td,
th {
border-collapse: collapse;
border-bottom-width: 1px;
border-bottom-style: solid;
}
td,
th {
padding: 8px 8px 8px 24px;
text-align: left;
}
}
.user-statistics-table--virtual-scroll {
height: 500px;
.pbl-ngrid-header-cell:not(:first-child):before {
display: none;
}
}

View File

@ -0,0 +1,19 @@
@import '~@angular/material/theming';
@mixin os-user-statistics-style($theme) {
$foreground: map-get($theme, foreground);
$text: mat-color($foreground, secondary-text);
.user-statistics-table {
th {
font-weight: 400;
color: $text;
}
&,
td,
th {
border-color: mat-color($foreground, divider);
}
}
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { UserStatisticsComponent } from './user-statistics.component';
describe('UserStatisticsComponent', () => {
let component: UserStatisticsComponent;
let fixture: ComponentFixture<UserStatisticsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UserStatisticsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,149 @@
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid/lib/grid';
import { BehaviorSubject, Observable } from 'rxjs';
import {
ListOfSpeakersRepositoryService,
SpeakingTimeStructureLevelObject
} from 'app/core/repositories/agenda/list-of-speakers-repository.service';
import { DurationService } from 'app/core/ui-services/duration.service';
import { ViewSpeaker } from 'app/site/agenda/models/view-speaker';
import { BaseViewComponent } from 'app/site/base/base-view';
@Component({
selector: 'os-user-statistics',
templateUrl: './user-statistics.component.html',
styleUrls: ['./user-statistics.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class UserStatisticsComponent extends BaseViewComponent {
/**
* Returns the total duration for a whole assembly.
*/
public get totalDuration(): string {
return this.parseDuration(this.speakingTimeAsNumber, true);
}
public get numberOfUniqueSpeakers(): number {
return this.uniqueSpeakers.length;
}
public get numberOfWordRequests(): number {
return this._numberOfWordRequests;
}
/**
* Returns an observable containing a list. This list contains objects separated by the structure-level of speakers.
*
* Those objects hold information about number of word requests and duration of word requests for a given
* structure-level.
*/
public get statisticsByStructureLevelObservable(): Observable<SpeakingTimeStructureLevelObject[]> {
return this.relationSpeakingTimeStructureLevelSubject.asObservable();
}
/**
* Dedicated, if statistics are opened.
*/
public get openedStatistics(): boolean {
return this.statisticIsOpen;
}
public readonly columnDefinition: PblColumnDefinition[] = [
{
prop: 'structureLevel',
width: 'auto',
label: 'Structure level'
},
{
prop: 'durationOfWordRequests',
width: 'auto',
label: 'Duration of word requests'
},
{
prop: 'numberOfWordRequests',
width: 'auto',
label: 'Number of word requests'
}
];
public readonly filterProps: string[] = ['structureLevel'];
/**
* Holds information about hours, minutes and seconds for the total duration of word requests.
*/
private speakingTimeAsNumber = 0;
/**
* List of unique speakers.
*/
private uniqueSpeakers: ViewSpeaker[] = [];
private _numberOfWordRequests = 0;
private statisticIsOpen = false;
private relationSpeakingTimeStructureLevelSubject = new BehaviorSubject<SpeakingTimeStructureLevelObject[]>([]);
public constructor(
title: Title,
protected translate: TranslateService,
matSnackBar: MatSnackBar,
private losRepo: ListOfSpeakersRepositoryService,
private durationService: DurationService
) {
super(title, translate, matSnackBar);
}
/**
* Opens or closes statistics.
*/
public changeViewOfStatistics(): void {
this.statisticIsOpen = !this.statisticIsOpen;
if (this.statisticIsOpen) {
this.startSubscription();
} else {
this.cleanSubjects();
}
}
/**
* This iterates over a list of list-of-speakers. For each speaker it calculates the duration the speaker
* has spoken.
*/
private pushNextState(): void {
const list = this.losRepo.getSpeakingTimeStructureLevelRelation();
list.sort((a, b) => b.finishedSpeakers.length - a.finishedSpeakers.length);
for (const entry of list) {
this.speakingTimeAsNumber += entry.speakingTime;
this._numberOfWordRequests += entry.finishedSpeakers.length;
}
this.relationSpeakingTimeStructureLevelSubject.next(list);
this.uniqueSpeakers = this.losRepo.getAllFirstContributions();
}
/**
* Creates a string from a given `TimeObject`.
*/
public parseDuration(time: number, withHours: boolean = false): string {
return !withHours
? this.durationService.durationToString(time, 'm')
: this.durationService.durationToStringWithHours(time);
}
private startSubscription(): void {
this.subscriptions.push(
this.losRepo.getViewModelListObservable().subscribe(() => {
this.reset();
this.pushNextState();
})
);
}
private reset(): void {
this._numberOfWordRequests = 0;
this.speakingTimeAsNumber = 0;
}
}

View File

@ -8,9 +8,17 @@ import { LegalNoticeComponent } from './components/legal-notice/legal-notice.com
import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component';
import { SharedModule } from '../../shared/shared.module';
import { StartComponent } from './components/start/start.component';
import { UserStatisticsComponent } from './components/user-statistics/user-statistics.component';
@NgModule({
imports: [CommonModule, CommonRoutingModule, SharedModule],
declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent, CountUsersComponent, ErrorComponent]
declarations: [
PrivacyPolicyComponent,
StartComponent,
LegalNoticeComponent,
CountUsersComponent,
ErrorComponent,
UserStatisticsComponent
]
})
export class OsCommonModule {}

View File

@ -34,6 +34,7 @@
@import './app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss';
@import './app/shared/components/jitsi/jitsi.component.scss-theme.scss';
@import './app/shared/components/list-view-table/list-view-table.component.scss-theme.scss';
@import './app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss';
/** fonts */
@import './assets/styles/fonts.scss';
@ -68,6 +69,7 @@ $narrow-spacing: (
@include os-progress-snack-bar-style($theme);
@include os-jitsi-theme($theme);
@include os-list-view-table-theme($theme);
@include os-user-statistics-style($theme);
}
/** Load projector specific SCSS values */