diff --git a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts index d61f89776..bbcd64bfb 100644 --- a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts +++ b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts @@ -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 * diff --git a/client/src/app/core/ui-services/duration.service.ts b/client/src/app/core/ui-services/duration.service.ts index a6ef5b2f6..3647f2e1e 100644 --- a/client/src/app/core/ui-services/duration.service.ts +++ b/client/src/app/core/ui-services/duration.service.ts @@ -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) * diff --git a/client/src/app/shared/components/list-view-table/list-view-table.component.ts b/client/src/app/shared/components/list-view-table/list-view-table.component.ts index 452be3421..d2741ec68 100644 --- a/client/src/app/shared/components/list-view-table/list-view-table.component.ts +++ b/client/src/app/shared/components/list-view-table/list-view-table.component.ts @@ -484,7 +484,7 @@ export class ListViewTableComponent) => { - if (this.projectorService.isProjected(this.getProjectable(context.$implicit as V))) { + if (this.allowProjector && this.projectorService.isProjected(this.getProjectable(context.$implicit as V))) { return 'projected'; } }; diff --git a/client/src/app/site/common/components/count-users/count-users.component.html b/client/src/app/site/common/components/count-users/count-users.component.html index 3502d760f..8d5b0529d 100644 --- a/client/src/app/site/common/components/count-users/count-users.component.html +++ b/client/src/app/site/common/components/count-users/count-users.component.html @@ -1,25 +1,23 @@ - - - -
-

- {{ userIds().length }} {{ 'active users' | translate }} ({{ stats.activeUserHandles }} - {{ 'connections' | translate }} + {{ 'Count active users' | translate }} + + +

+

+ {{ userIds().length }} {{ 'active users' | translate }} ({{ stats.activeUserHandles }} + {{ 'connections' | translate }}) +

+

{{ 'Groups' | translate }}

+
    +
  • + {{ stats.groups[groupId].name }} + : {{ userInGroupIds(groupId).length }} + {{ 'active users' | translate }} + ({{ stats.groups[groupId].userHandleCount }} {{ 'connections' | translate }}) -

    -

    {{ 'Groups' | translate }}

    -
      -
    • - {{ stats.groups[groupId].name }} - : {{ userInGroupIds(groupId).length }} - {{ 'active users' | translate }} - ({{ stats.groups[groupId].userHandleCount }} {{ 'connections' | translate }}) -
    • -
    -
- + + +
diff --git a/client/src/app/site/common/components/legal-notice/legal-notice.component.html b/client/src/app/site/common/components/legal-notice/legal-notice.component.html index e69ea55c7..496237a76 100644 --- a/client/src/app/site/common/components/legal-notice/legal-notice.component.html +++ b/client/src/app/site/common/components/legal-notice/legal-notice.component.html @@ -34,4 +34,14 @@
- + +

{{ 'Statistics' | translate }}

+
+

{{ 'Count active users' | translate }}

+ +
+
+

{{ 'Count active word requests' | translate }}

+ +
+
diff --git a/client/src/app/site/common/components/user-statistics/user-statistics.component.html b/client/src/app/site/common/components/user-statistics/user-statistics.component.html new file mode 100644 index 000000000..fd22dea56 --- /dev/null +++ b/client/src/app/site/common/components/user-statistics/user-statistics.component.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + +
{{ 'Total number of word requests' | translate }}{{ numberOfWordRequests }}
{{ 'Unique speakers' | translate }}{{ numberOfUniqueSpeakers }}
{{ 'Total duration of word requests' | translate }}{{ totalDuration }}
+ + +
+ {{ col.label | translate }} +
+ + +
+ {{ object.structureLevel | translate }} +
+
+ {{ parseDuration(object.speakingTime) }} +
+
+ {{ object.finishedSpeakers.length }} +
+
+
diff --git a/client/src/app/site/common/components/user-statistics/user-statistics.component.scss b/client/src/app/site/common/components/user-statistics/user-statistics.component.scss new file mode 100644 index 000000000..66eb4f9f5 --- /dev/null +++ b/client/src/app/site/common/components/user-statistics/user-statistics.component.scss @@ -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; + } +} diff --git a/client/src/app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss b/client/src/app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss new file mode 100644 index 000000000..e8f58a2cd --- /dev/null +++ b/client/src/app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss @@ -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); + } + } +} diff --git a/client/src/app/site/common/components/user-statistics/user-statistics.component.spec.ts b/client/src/app/site/common/components/user-statistics/user-statistics.component.spec.ts new file mode 100644 index 000000000..859147cdd --- /dev/null +++ b/client/src/app/site/common/components/user-statistics/user-statistics.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserStatisticsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/components/user-statistics/user-statistics.component.ts b/client/src/app/site/common/components/user-statistics/user-statistics.component.ts new file mode 100644 index 000000000..aceecf94d --- /dev/null +++ b/client/src/app/site/common/components/user-statistics/user-statistics.component.ts @@ -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 { + 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([]); + + 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; + } +} diff --git a/client/src/app/site/common/os-common.module.ts b/client/src/app/site/common/os-common.module.ts index 1df31d094..a6f108bab 100644 --- a/client/src/app/site/common/os-common.module.ts +++ b/client/src/app/site/common/os-common.module.ts @@ -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 {} diff --git a/client/src/styles.scss b/client/src/styles.scss index 777d52d16..88e3415b5 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -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 */