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 + + · {{ reportConfig.selectedTeams.length }} teams + + +
+ +
+ +
+ +
+ filter_list_off +

No activities match the current report configuration.

+ +
+ +
+ +
+

Overview

+ + + + + + + + + + + + + + + + + +
LevelTotal ActivitiesCompletedCompletion
{{ row.level }}{{ row.totalActivities }}{{ row.completedCount }} + + + + {{ row.completionPercent }}% +
+ + + '—' - No progress | '◐' - Partly Implemented | '✓' - Fully Implemented + +
+ +
+

{{ dimension.name }}

+ +
+

{{ subDimension.name }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelActivityDescription{{ 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; + } +}