diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index 1248cee8..fe90fbf0 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -10,6 +10,7 @@ import { UsageComponent } from './pages/usage/usage.component';
import { TeamsComponent } from './pages/teams/teams.component';
import { RoadmapComponent } from './pages/roadmap/roadmap.component';
import { SettingsComponent } from './pages/settings/settings.component';
+import { ReportComponent } from './pages/report/report.component';
const routes: Routes = [
{ path: '', component: CircularHeatmapComponent },
@@ -24,6 +25,7 @@ const routes: Routes = [
{ path: 'userday', component: UserdayComponent },
{ path: 'roadmap', component: RoadmapComponent },
{ path: 'settings', component: SettingsComponent },
+ { path: 'report', component: ReportComponent },
];
@NgModule({
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 435bd2d2..12330878 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { MatToolbarModule } from '@angular/material/toolbar';
+import { MatMenuModule } from '@angular/material/menu';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
@@ -31,6 +32,8 @@ import { ProgressSliderComponent } from './component/progress-slider/progress-sl
import { KpiComponent } from './component/kpi/kpi.component';
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TeamsGroupsEditorModule } from './component/teams-groups-editor/teams-groups-editor.module';
+import { ReportComponent } from './pages/report/report.component';
+import { ReportConfigModalComponent } from './component/report-config-modal/report-config-modal.component';
@NgModule({
declarations: [
@@ -55,6 +58,8 @@ import { TeamsGroupsEditorModule } from './component/teams-groups-editor/teams-g
ProgressSliderComponent,
KpiComponent,
SettingsComponent,
+ ReportComponent,
+ ReportConfigModalComponent,
],
imports: [
BrowserModule,
@@ -64,6 +69,7 @@ import { TeamsGroupsEditorModule } from './component/teams-groups-editor/teams-g
MatDialogModule,
ReactiveFormsModule,
MatToolbarModule,
+ MatMenuModule,
FormsModule,
HttpClientModule,
TeamsGroupsEditorModule,
diff --git a/src/app/component/report-config-modal/report-config-modal.component.css b/src/app/component/report-config-modal/report-config-modal.component.css
new file mode 100644
index 00000000..97ed3b65
--- /dev/null
+++ b/src/app/component/report-config-modal/report-config-modal.component.css
@@ -0,0 +1,119 @@
+.config-content {
+ max-height: 70vh;
+ overflow-y: auto;
+ padding: 0 40px;
+ background-color: var(--background-primary);
+}
+
+mat-dialog-title{
+ font-size:20px;
+}
+
+.config-section {
+ padding: 16px 0;
+}
+
+.config-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+ margin-top: 12px;
+}
+
+.config-row-label {
+ font-size: 0.95em;
+ white-space: nowrap;
+}
+
+.slider-row {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
+.word-cap-slider {
+ width: 100%;
+}
+
+.config-section h3 {
+ margin: 0 0 4px 0;
+ font-size: 1.1em;
+ font-weight: 500;
+}
+
+.config-hint {
+ margin: 0 0 12px 0;
+ font-size: 0.85em;
+ color: var(--text-secondary);
+}
+
+.select-all-actions {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.checkbox-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 8px;
+}
+
+.search-field {
+ width: 80%;
+ margin-bottom: 8px;
+}
+
+.activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 300px;
+ overflow-y: auto;
+ padding: 4px 0;
+ border: 1px solid var(--text-tertiary);
+ border-radius: 4px;
+ padding: 8px;
+}
+
+.activity-checkbox-label {
+ display: flex;
+ flex-direction: column;
+}
+
+.activity-name {
+ font-weight: 500;
+}
+
+.activity-meta {
+ font-size: 0.8em;
+ color: var(--text-secondary);
+}
+
+mat-divider {
+ margin: 4px 0;
+}
+
+.column-toggle {
+ border-radius: 999px;
+ padding: 2px;
+ border: 1px solid var(--text-tertiary);
+ background: var(--background-secondary);
+}
+
+/* buttons */
+.column-toggle .mat-button-toggle {
+ border-radius: 999px;
+ border: none;
+ padding: 0 16px;
+ color: var(--text-secondary);
+ background: transparent;
+}
+
+/* selected */
+.column-toggle .mat-button-toggle-checked {
+ background: var(--primary-color);
+ color: var(--text-on-primary);
+ box-shadow: 0 2px 6px rgba(0,0,0,0.25);
+}
\ No newline at end of file
diff --git a/src/app/component/report-config-modal/report-config-modal.component.html b/src/app/component/report-config-modal/report-config-modal.component.html
new file mode 100644
index 00000000..98f18c81
--- /dev/null
+++ b/src/app/component/report-config-modal/report-config-modal.component.html
@@ -0,0 +1,138 @@
+
Report Configuration
+
+
+
+
+
Display Configuration
+
+
+ Column Grouping:
+
+ By Progress Stage
+ By Team
+
+
+
+
+ Show Description
+
+
+
+ Description Word Cap: {{ config.descriptionWordCap }}
+
+
+
+
+
+
+
+
+
+
Teams
+
Select which teams to include in the report.
+
+
+
+
+
+
+
+
+
+
+ {{ team }}
+
+
+
+
+
+
+
+
+
Dimensions
+
Uncheck dimensions to exclude all their activities.
+
+
+ {{ dim }}
+
+
+
+
+
+
+
+
+
Subdimensions
+
Uncheck subdimensions to exclude their activities.
+
+
+ {{ subdim }}
+
+
+
+
+
+
+
+
+
Individual Activities
+
Search and uncheck individual activities to exclude them.
+
+ Search activities or dimensions
+
+ search
+
+
+
+
+ {{ activity.name }}
+ {{ activity.dimension }} · Level {{ activity.level }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/component/report-config-modal/report-config-modal.component.ts b/src/app/component/report-config-modal/report-config-modal.component.ts
new file mode 100644
index 00000000..2b5b704c
--- /dev/null
+++ b/src/app/component/report-config-modal/report-config-modal.component.ts
@@ -0,0 +1,154 @@
+import { Component, Inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { ReportConfig, ColumnGrouping, MAX_DESCRIPTION_WORD_CAP } from '../../model/report-config';
+import { Activity } from '../../model/activity-store';
+import { ProgressTitle, TeamGroups } from '../../model/types';
+
+export interface ReportConfigModalData {
+ config: ReportConfig;
+ allActivities: Activity[];
+ allTeams: string[];
+ allDimensions: string[];
+ allSubdimensions: string[];
+ allProgressTitles: ProgressTitle[];
+ teamGroups: TeamGroups;
+}
+
+@Component({
+ selector: 'app-report-config-modal',
+ templateUrl: './report-config-modal.component.html',
+ styleUrls: ['./report-config-modal.component.css'],
+})
+export class ReportConfigModalComponent {
+ config: ReportConfig;
+ allActivities: Activity[];
+ allTeams: string[];
+ allDimensions: string[];
+ allSubdimensions: string[];
+ allProgressTitles: ProgressTitle[];
+ teamGroups: TeamGroups;
+ activitySearchQuery: string = '';
+ maxWordCap: number = MAX_DESCRIPTION_WORD_CAP;
+
+ constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: ReportConfigModalData
+ ) {
+ // Deep copy config to avoid mutating the original until save
+ this.config = JSON.parse(JSON.stringify(data.config));
+ this.allActivities = data.allActivities;
+ this.allTeams = data.allTeams;
+ this.allDimensions = data.allDimensions;
+ this.allSubdimensions = data.allSubdimensions;
+ this.allProgressTitles = data.allProgressTitles || [];
+ this.teamGroups = data.teamGroups || {};
+ }
+
+ setColumnGrouping(grouping: ColumnGrouping): void {
+ this.config.columnGrouping = grouping;
+ }
+
+ wordCapLabel(value: number): string {
+ return `${value}`;
+ }
+
+ onWordCapChange(event: any): void {
+ if (event.value != null) {
+ this.config.descriptionWordCap = event.value;
+ }
+ }
+
+ // --- Team toggling ---
+ isTeamSelected(team: string): boolean {
+ return this.config.selectedTeams.includes(team);
+ }
+
+ toggleTeam(team: string): void {
+ const idx = this.config.selectedTeams.indexOf(team);
+ if (idx >= 0) {
+ this.config.selectedTeams.splice(idx, 1);
+ } else {
+ this.config.selectedTeams.push(team);
+ }
+ }
+
+ selectAllTeams(): void {
+ this.config.selectedTeams = [...this.allTeams];
+ }
+
+ deselectAllTeams(): void {
+ this.config.selectedTeams = [];
+ }
+
+ get groupNames(): string[] {
+ return Object.keys(this.teamGroups);
+ }
+
+ selectGroup(group: string): void {
+ this.config.selectedTeams = [...(this.teamGroups[group] || [])];
+ }
+
+ // --- Dimension toggling ---
+ isDimensionExcluded(dim: string): boolean {
+ return this.config.excludedDimensions.includes(dim);
+ }
+
+ toggleDimension(dim: string): void {
+ const idx = this.config.excludedDimensions.indexOf(dim);
+ if (idx >= 0) {
+ this.config.excludedDimensions.splice(idx, 1);
+ } else {
+ this.config.excludedDimensions.push(dim);
+ }
+ }
+
+ // --- Subdimension toggling ---
+ isSubdimensionExcluded(subdim: string): boolean {
+ return this.config.excludedSubdimensions.includes(subdim);
+ }
+
+ toggleSubdimension(subdim: string): void {
+ const idx = this.config.excludedSubdimensions.indexOf(subdim);
+ if (idx >= 0) {
+ this.config.excludedSubdimensions.splice(idx, 1);
+ } else {
+ this.config.excludedSubdimensions.push(subdim);
+ }
+ }
+
+ // --- Activity toggling ---
+ isActivityExcluded(uuid: string): boolean {
+ return this.config.excludedActivities.includes(uuid);
+ }
+
+ toggleActivity(uuid: string): void {
+ const idx = this.config.excludedActivities.indexOf(uuid);
+ if (idx >= 0) {
+ this.config.excludedActivities.splice(idx, 1);
+ } else {
+ this.config.excludedActivities.push(uuid);
+ }
+ }
+
+ get filteredActivities(): Activity[] {
+ if (!this.activitySearchQuery.trim()) {
+ return this.allActivities;
+ }
+ const query = this.activitySearchQuery.toLowerCase();
+ return this.allActivities.filter(
+ a => a.name.toLowerCase().includes(query) || a.dimension.toLowerCase().includes(query)
+ );
+ }
+ toggleAttribute(key: 'showDescription'): void {
+ this.config[key] = !this.config[key];
+ }
+
+ // --- Actions ---
+ onSave(): void {
+ this.dialogRef.close(this.config);
+ }
+
+ onCancel(): void {
+ this.dialogRef.close(null);
+ }
+}
diff --git a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts
index b53ad3ec..64aaae23 100644
--- a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts
+++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts
@@ -15,6 +15,7 @@ export class SidenavButtonsComponent implements OnInit {
'Matrix',
'Mappings',
'Teams',
+ 'Report',
'Settings',
'Usage',
'Roadmap',
@@ -26,6 +27,7 @@ export class SidenavButtonsComponent implements OnInit {
'table_chart',
'timeline',
'people',
+ 'summarize',
'list',
'description',
'landscape',
@@ -37,6 +39,7 @@ export class SidenavButtonsComponent implements OnInit {
'/matrix',
'/mapping',
'/teams',
+ '/report',
'/settings',
'/usage',
'/roadmap',
diff --git a/src/app/model/report-config.ts b/src/app/model/report-config.ts
new file mode 100644
index 00000000..ff03d88c
--- /dev/null
+++ b/src/app/model/report-config.ts
@@ -0,0 +1,58 @@
+export type ColumnGrouping = 'byProgress' | 'byTeam';
+
+export interface ReportConfig {
+ columnGrouping: ColumnGrouping;
+ descriptionWordCap: number;
+ selectedTeams: string[];
+ excludedDimensions: string[];
+ excludedSubdimensions: string[];
+ excludedActivities: string[];
+ showDescription: boolean;
+}
+
+const STORAGE_KEY = 'ReportConfig';
+const DEFAULT_DESCRIPTION_WORD_CAP = 25;
+export const MAX_DESCRIPTION_WORD_CAP = 600;
+
+export function getDefaultReportConfig(): ReportConfig {
+ return {
+ columnGrouping: 'byProgress',
+ descriptionWordCap: DEFAULT_DESCRIPTION_WORD_CAP,
+ selectedTeams: [],
+ excludedDimensions: [],
+ excludedSubdimensions: [],
+ excludedActivities: [],
+ showDescription: true,
+ };
+}
+
+export function getReportConfig(): ReportConfig {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Partial;
+ // Merge with defaults to ensure all keys exist
+ const defaults = getDefaultReportConfig();
+ return {
+ columnGrouping: parsed.columnGrouping ?? defaults.columnGrouping,
+ descriptionWordCap: parsed.descriptionWordCap ?? defaults.descriptionWordCap,
+ selectedTeams: parsed.selectedTeams ?? defaults.selectedTeams,
+ excludedDimensions: parsed.excludedDimensions ?? defaults.excludedDimensions,
+ excludedSubdimensions: parsed.excludedSubdimensions ?? defaults.excludedSubdimensions,
+ excludedActivities: parsed.excludedActivities ?? defaults.excludedActivities,
+ showDescription: parsed.showDescription ?? defaults.showDescription,
+ };
+ }
+ } catch (e) {
+ console.error('Error reading ReportConfig from localStorage:', e);
+ }
+ return getDefaultReportConfig();
+}
+
+export function saveReportConfig(config: ReportConfig): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
+ } catch (e) {
+ console.error('Error saving ReportConfig to localStorage:', e);
+ }
+}
diff --git a/src/app/pages/report/report.component.css b/src/app/pages/report/report.component.css
new file mode 100644
index 00000000..f4fc66f3
--- /dev/null
+++ b/src/app/pages/report/report.component.css
@@ -0,0 +1,272 @@
+.report-container {
+ padding: 20px;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.legend {
+ display: block;
+ margin-top: 8px;
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+/* Toolbar */
+.report-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+}
+
+.report-toolbar button mat-icon {
+ margin-right: 4px;
+}
+
+.activity-count {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+.loading-container {
+ display: flex;
+ justify-content: center;
+ padding: 60px;
+}
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 60px 20px;
+ color: var(--text-secondary);
+}
+
+.empty-state mat-icon {
+ font-size: 48px;
+ width: 48px;
+ height: 48px;
+}
+
+.section-title {
+ font-size: 1.3em;
+ font-weight: 600;
+ margin: 24px 0 8px 0;
+ padding-bottom: 4px;
+ border-bottom: 2px solid var(--text-tertiary);
+ color: var(--primary-color);
+}
+
+.dimension-section {
+ margin-bottom: 32px;
+}
+
+.subdimension-group {
+ margin-bottom: 16px;
+}
+
+.subdimension-title {
+ font-size: 1.05em;
+ font-weight: 500;
+ margin: 16px 0 8px 0;
+ padding-left: 0;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--text-tertiary);
+}
+
+.report-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85em;
+ margin-bottom: 8px;
+}
+
+.report-table th,
+.report-table td {
+ border: 1px solid var(--text-tertiary);
+ padding: 4px 8px;
+ text-align: left;
+ vertical-align: top;
+ color: var(--text-primary);
+}
+
+.report-table thead th {
+ background-color: var(--background-tertiary);
+ font-weight: 600;
+ font-size: 0.9em;
+ white-space: nowrap;
+}
+
+/* Column widths */
+.col-level {
+ width: 50px;
+ text-align: center;
+}
+
+.col-activity {
+ min-width: 180px;
+}
+
+
+.col-description {
+ min-width: 220px;
+}
+
+.col-progress {
+ width: 100px;
+ font-size: 0.85em;
+}
+
+.col-team {
+ width: 70px;
+ text-align: center;
+ font-size: 0.85em;
+}
+
+/* Cell styles */
+.cell-level {
+ text-align: center;
+ font-weight: 500;
+}
+
+.cell-activity {
+ font-weight: 400;
+}
+
+.cell-subdimension {
+ color: var(--text-secondary);
+ font-size: 0.9em;
+}
+
+.cell-description {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+::ng-deep .description-content p:first-child {
+ margin-top: 0;
+ padding-top: 0;
+}
+
+::ng-deep .description-content p:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+::ng-deep .description-content img {
+ max-width: 100%;
+}
+
+.cell-progress {
+ font-size: 0.85em;
+}
+
+.cell-team {
+ text-align: center;
+ font-size: 1.1em;
+}
+
+.cell-center {
+ text-align: center;
+}
+
+/* Overview */
+.overview-section {
+ margin-bottom: 32px;
+}
+
+.overview-table {
+ max-width: 500px;
+}
+
+.completion-bar {
+ display: inline-block;
+ width: 60px;
+ height: 8px;
+ background: var(--text-tertiary);
+ border-radius: 4px;
+ vertical-align: middle;
+ margin-right: 6px;
+ overflow: hidden;
+}
+
+.completion-fill {
+ display: block;
+ height: 100%;
+ background: var(--primary-color); /* green anyways */
+ border-radius: 4px;
+}
+
+/* ============ PRINT STYLES ============ */
+@media print {
+
+ .no-print,
+ app-top-header,
+ .report-toolbar {
+ display: none !important;
+ }
+
+ .report-container {
+ padding: 0;
+ max-width: 100%;
+ margin: 0;
+ }
+
+ .legend {
+ font-size: 0.7em;
+ }
+
+ .section-title {
+ font-size: 14pt;
+ margin: 12pt 0 4pt 0;
+ page-break-after: avoid;
+ color: #1a5276;
+ }
+
+ .subdimension-title {
+ font-size: 12pt;
+ margin: 8pt 0 4pt 0;
+ color: #666;
+ border-bottom: 1px solid #ccc;
+ }
+
+ .report-table {
+ font-size: 9pt;
+ page-break-inside: auto;
+ }
+
+ .report-table tr {
+ page-break-inside: avoid;
+ }
+
+ .report-table th,
+ .report-table td {
+ padding: 2px 4px;
+ border: 1px solid #999;
+ color: #000;
+ }
+
+ .report-table thead th {
+ background-color: #f5f6fa;
+ }
+
+ .dimension-section {
+ page-break-before: auto;
+ margin-bottom: 12pt;
+ }
+
+ .subdimension-group {
+ page-break-inside: avoid;
+ margin-bottom: 12pt;
+ }
+
+ .overview-section {
+ page-break-after: avoid;
+ }
+
+ .completion-bar {
+ display: none;
+ }
+}
\ No newline at end of file
diff --git a/src/app/pages/report/report.component.html b/src/app/pages/report/report.component.html
new file mode 100644
index 00000000..7fc7f833
--- /dev/null
+++ b/src/app/pages/report/report.component.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+ {{ totalFilteredActivities }} activities
+ 0">
+ · {{ reportConfig.selectedTeams.length }} teams
+
+
+
+
+
+
+
+
+
+
filter_list_off
+
No activities match the current report configuration.
+
+
+
+
0" class="report-body">
+
+
+ Overview
+
+
+
+ | Level |
+ Total Activities |
+ Completed |
+ Completion |
+
+
+
+
+ | {{ row.level }} |
+ {{ row.totalActivities }} |
+ {{ row.completedCount }} |
+
+
+
+
+ {{ row.completionPercent }}%
+ |
+
+
+
+
+
+ '—' - No progress | '◐' - Partly Implemented | '✓' - Fully Implemented
+
+
+
+
+ {{ dimension.name }}
+
+
+
{{ subDimension.name }}
+
+
+
+ | Level |
+ Activity |
+ Description |
+
+
+
+ {{ title }} |
+
+
+
+
+ {{ team }} |
+
+
+
+
+
+ | {{ activity.level }} |
+ {{ activity.name }} |
+
+
+ |
+
+
+
+
+ {{ getTeamsForProgress(activity, title) }}
+ |
+
+
+
+
+
+ {{ getTeamProgressIcon(activity, team) }}
+ |
+
+
+
+
+
+
+
+
diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts
new file mode 100644
index 00000000..b094a5fa
--- /dev/null
+++ b/src/app/pages/report/report.component.ts
@@ -0,0 +1,321 @@
+import { Component, OnInit } from '@angular/core';
+import { MatDialog } from '@angular/material/dialog';
+import { LoaderService } from '../../service/loader/data-loader.service';
+import { SettingsService } from '../../service/settings/settings.service';
+import { Activity } from '../../model/activity-store';
+import { MarkdownText } from '../../model/markdown-text';
+import { DataStore } from '../../model/data-store';
+import { ProgressStore } from '../../model/progress-store';
+import { ReportConfig, getReportConfig, saveReportConfig } from '../../model/report-config';
+import {
+ ReportConfigModalComponent,
+ ReportConfigModalData,
+} from '../../component/report-config-modal/report-config-modal.component';
+import { ProgressTitle } from '../../model/types';
+
+export interface ReportSubDimension {
+ name: string;
+ activities: Activity[];
+}
+
+export interface ReportDimension {
+ name: string;
+ subDimensions: ReportSubDimension[];
+}
+
+export interface LevelOverview {
+ level: number;
+ totalActivities: number;
+ completedCount: number;
+ completionPercent: number;
+}
+
+@Component({
+ selector: 'app-report',
+ templateUrl: './report.component.html',
+ styleUrls: ['./report.component.css'],
+})
+export class ReportComponent implements OnInit {
+ reportConfig: ReportConfig;
+ allActivities: Activity[] = [];
+ filteredDimensions: ReportDimension[] = [];
+ levelOverview: LevelOverview[] = [];
+ isLoading: boolean = true;
+
+ // For the config modal
+ allDimensionNames: string[] = [];
+ allSubdimensionNames: string[] = [];
+ allTeams: string[] = [];
+
+ allProgressTitles: ProgressTitle[] = [];
+
+ // Max level from settings
+ maxLevel: number = 0;
+
+ constructor(
+ private loader: LoaderService,
+ private settings: SettingsService,
+ private dialog: MatDialog
+ ) {
+ this.reportConfig = getReportConfig();
+ }
+
+ ngOnInit(): void {
+ this.loadActivities();
+ }
+
+ get progressStore(): ProgressStore | undefined {
+ return this.loader.datastore?.progressStore ?? undefined;
+ }
+
+ loadActivities(): void {
+ this.isLoading = true;
+ this.loader
+ .load()
+ .then((dataStore: DataStore) => {
+ if (!dataStore.activityStore) {
+ this.isLoading = false;
+ return;
+ }
+
+ this.maxLevel = this.settings.getMaxLevel() || dataStore.getMaxLevel();
+ this.allActivities = dataStore.activityStore.getAllActivitiesUpToLevel(this.maxLevel);
+
+ const dimensionSet = new Set();
+ const subdimensionSet = new Set();
+
+ for (const activity of this.allActivities) {
+ dimensionSet.add(activity.category);
+ subdimensionSet.add(activity.dimension);
+ }
+
+ this.allDimensionNames = Array.from(dimensionSet).sort();
+ this.allSubdimensionNames = Array.from(subdimensionSet).sort();
+ this.allTeams = dataStore?.meta?.teams || [];
+
+ // Collect progress titles
+ if (dataStore.progressStore) {
+ const inProgress = dataStore.progressStore.getInProgressTitles();
+ const completed = dataStore.progressStore.getCompletedProgressTitle();
+ this.allProgressTitles = [...inProgress, completed].filter(t => !!t);
+ }
+
+ // Auto-select all teams if none selected yet
+ if (this.reportConfig.selectedTeams.length === 0 && this.allTeams.length > 0) {
+ this.reportConfig.selectedTeams = [...this.allTeams];
+ }
+
+ this.applyFilters();
+ this.isLoading = false;
+ })
+ .catch(err => {
+ console.error('Error loading activities for report:', err);
+ this.isLoading = false;
+ });
+ }
+
+ applyFilters(): void {
+ const config = this.reportConfig;
+
+ // Filter activities using hierarchical exclusion
+ const filtered = this.allActivities.filter(activity => {
+ // 1. Check dimension (category)
+ if (config.excludedDimensions.includes(activity.category)) return false;
+ // 2. Check subdimension (dimension)
+ if (config.excludedSubdimensions.includes(activity.dimension)) return false;
+ // 4. Check individual activity
+ if (config.excludedActivities.includes(activity.uuid)) return false;
+ return true;
+ });
+
+ // Group by dimension (category) → subdimension (dimension)
+ const dimensionMap = new Map>();
+
+ for (const activity of filtered) {
+ if (!dimensionMap.has(activity.category)) {
+ dimensionMap.set(activity.category, new Map());
+ }
+ const subMap = dimensionMap.get(activity.category)!;
+ if (!subMap.has(activity.dimension)) {
+ subMap.set(activity.dimension, []);
+ }
+ subMap.get(activity.dimension)!.push(activity);
+ }
+
+ this.filteredDimensions = [];
+ const sortedDimensions = Array.from(dimensionMap.keys()).sort();
+ for (const dimName of sortedDimensions) {
+ const subMap = dimensionMap.get(dimName)!;
+ const subDimensions: ReportSubDimension[] = [];
+ const sortedSubDimensions = Array.from(subMap.keys()).sort();
+
+ for (const subDimName of sortedSubDimensions) {
+ const activities = subMap.get(subDimName)!;
+ activities.sort((a, b) => {
+ if (a.level !== b.level) return a.level - b.level;
+ return a.name.localeCompare(b.name);
+ });
+ subDimensions.push({ name: subDimName, activities });
+ }
+ this.filteredDimensions.push({ name: dimName, subDimensions });
+ }
+
+ this.buildLevelOverview(filtered);
+ }
+
+ buildLevelOverview(activities: Activity[]): void {
+ const levelMap = new Map();
+
+ for (const activity of activities) {
+ if (!levelMap.has(activity.level)) {
+ levelMap.set(activity.level, { total: 0, completed: 0 });
+ }
+ const entry = levelMap.get(activity.level)!;
+ entry.total++;
+
+ if (this.reportConfig.selectedTeams.length > 0) {
+ const allCompleted = this.reportConfig.selectedTeams.every(team =>
+ this.isActivityCompletedByTeam(activity, team)
+ );
+ if (allCompleted) {
+ entry.completed++;
+ }
+ }
+ }
+
+ this.levelOverview = Array.from(levelMap.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([level, data]) => ({
+ level,
+ totalActivities: data.total,
+ completedCount: data.completed,
+ completionPercent: data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0,
+ }));
+ }
+
+ // --- Progress helpers ---
+
+ isActivityCompletedByTeam(activity: Activity, teamName: string): boolean {
+ if (!this.progressStore || !activity.uuid) return false;
+ const completedTitle = this.progressStore.getCompletedProgressTitle();
+ if (!completedTitle) return false;
+ const teamTitle = this.progressStore.getTeamProgressTitle(activity.uuid, teamName);
+ return teamTitle === completedTitle;
+ }
+
+ getTeamProgressIcon(activity: Activity, teamName: string): string {
+ if (!this.progressStore || !activity.uuid) return '—';
+ const progressValue = this.progressStore.getTeamActivityProgressValue(activity.uuid, teamName);
+ if (progressValue >= 1) return '✓';
+ if (progressValue > 0) return '◐';
+ return '—';
+ }
+
+ getTeamsForProgress(activity: Activity, progressTitle: ProgressTitle): string {
+ if (!this.progressStore || !activity.uuid) return '';
+ const teams: string[] = [];
+ for (const team of this.reportConfig.selectedTeams) {
+ const teamTitle = this.progressStore.getTeamProgressTitle(activity.uuid, team);
+ if (teamTitle === progressTitle) {
+ teams.push(team);
+ }
+ }
+ return teams.join(', ') || '—';
+ }
+
+ truncateWords(text: any, max: number): string {
+ if (!text) return '';
+ const str = String(text);
+ const words = str.split(/\s+/);
+ if (words.length <= max) return str;
+ return words.slice(0, max).join(' ') + '...';
+ }
+
+ renderCappedDescription(description: any, wordCap: number): string {
+ if (!description) return '';
+ // First, render the full markdown
+ const rendered = new MarkdownText(String(description)).render();
+ // Then, apply the word cap on the rendered HTML
+ const container = document.createElement('div');
+ container.innerHTML = rendered;
+ const textContent = (container.textContent || '').trim();
+ const words = textContent.split(/\s+/).filter(w => w.length > 0);
+
+ if (words.length <= wordCap) {
+ return rendered;
+ }
+
+ // Truncate text nodes in the DOM, preserving HTML structure
+ let remaining = wordCap;
+ const truncateNode = (node: Node): boolean => {
+ if (remaining <= 0) {
+ node.parentNode?.removeChild(node);
+ return true;
+ }
+ if (node.nodeType === Node.TEXT_NODE) {
+ const nodeWords = (node.textContent || '').split(/\s+/).filter(w => w.length > 0);
+ if (nodeWords.length <= remaining) {
+ remaining -= nodeWords.length;
+ return false;
+ }
+ node.textContent = nodeWords.slice(0, remaining).join(' ') + '…';
+ remaining = 0;
+ return false;
+ }
+ // Element node — walk children
+ const children = Array.from(node.childNodes);
+ for (const child of children) {
+ truncateNode(child);
+ }
+ return false;
+ };
+ truncateNode(container);
+
+ return container.innerHTML;
+ }
+
+ openConfigModal(): void {
+ const modalData: ReportConfigModalData = {
+ config: this.reportConfig,
+ allActivities: this.allActivities,
+ allTeams: this.allTeams,
+ allDimensions: this.allDimensionNames,
+ allSubdimensions: this.allSubdimensionNames,
+ allProgressTitles: this.allProgressTitles,
+ teamGroups: this.loader.datastore?.meta?.teamGroups || {},
+ };
+
+ const dialogRef = this.dialog.open(ReportConfigModalComponent, {
+ width: '700px',
+ maxHeight: '90vh',
+ data: modalData,
+ });
+
+ dialogRef.afterClosed().subscribe((result: ReportConfig | null) => {
+ if (result) {
+ this.reportConfig = result;
+ saveReportConfig(result);
+ this.applyFilters();
+ }
+ });
+ }
+
+ printReport(): void {
+ alert(`For best results, please ensure the following before printing:
+- Close the app Menu.
+- Enable Light Mode.
+- In the browser print settings, set margins to "None" and deselect Header and Footer.
+ `);
+ window.print();
+ }
+
+ get totalFilteredActivities(): number {
+ let count = 0;
+ for (const dim of this.filteredDimensions) {
+ for (const sub of dim.subDimensions) {
+ count += sub.activities.length;
+ }
+ }
+ return count;
+ }
+}