diff --git a/src/app/component/activity-description/activity-description.component.html b/src/app/component/activity-description/activity-description.component.html index 96d370e73..1c4f614b1 100644 --- a/src/app/component/activity-description/activity-description.component.html +++ b/src/app/component/activity-description/activity-description.component.html @@ -329,7 +329,10 @@

- No teams have started this activity yet + No {{ settings.getTeamLabelPlural() | lowercase }} have started this activity + yet @@ -347,7 +350,10 @@

-

No teams have started implementing this activity yet.

+

+ No {{ settings.getTeamLabelPlural() | lowercase }} have started implementing this activity + yet. +

diff --git a/src/app/component/activity-description/activity-description.component.ts b/src/app/component/activity-description/activity-description.component.ts index 2a583a7b7..77a1aa9bc 100644 --- a/src/app/component/activity-description/activity-description.component.ts +++ b/src/app/component/activity-description/activity-description.component.ts @@ -14,6 +14,7 @@ import { MatAccordion } from '@angular/material/expansion'; import { Activity } from '../../model/activity-store'; import { LoaderService } from '../../service/loader/data-loader.service'; import { TeamName, ProgressTitle } from '../../model/types'; +import { SettingsService } from 'src/app/service/settings/settings.service'; @Component({ selector: 'app-activity-description', @@ -43,7 +44,7 @@ export class ActivityDescriptionComponent implements OnInit, OnChanges { @ViewChildren(MatAccordion) accordion!: QueryList; - constructor(private loader: LoaderService) {} + constructor(private loader: LoaderService, public settings: SettingsService) {} ngOnInit() { // Set activity data if provided 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 index 257044e0e..7d5413ab3 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.html +++ b/src/app/component/report-config-modal/report-config-modal.component.html @@ -12,7 +12,7 @@

Display Configuration

[value]="config.columnGrouping" (change)="setColumnGrouping($event.value)"> By Progress Stage - By Team + By {{ settings.getTeamLabel() }} 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 index 43a85f308..fa63d11d3 100644 --- a/src/app/component/report-config-modal/report-config-modal.component.ts +++ b/src/app/component/report-config-modal/report-config-modal.component.ts @@ -8,6 +8,7 @@ import { } from '../../model/report-config'; import { Activity } from '../../model/activity-store'; import { ProgressTitle, TeamGroups } from '../../model/types'; +import { SettingsService } from 'src/app/service/settings/settings.service'; export interface ReportConfigModalData { config: ReportConfig; @@ -37,7 +38,8 @@ export class ReportConfigModalComponent { constructor( public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: ReportConfigModalData + @Inject(MAT_DIALOG_DATA) public data: ReportConfigModalData, + public settings: SettingsService ) { // Deep copy config to avoid mutating the original until save this.config = JSON.parse(JSON.stringify(data.config)); diff --git a/src/app/component/team-selector/team-selector.component.html b/src/app/component/team-selector/team-selector.component.html index df61e56b1..d46210a01 100644 --- a/src/app/component/team-selector/team-selector.component.html +++ b/src/app/component/team-selector/team-selector.component.html @@ -1,16 +1,17 @@
-

Teams

+

{{ settings.getTeamLabelPlural() }}

- Select which teams to include in the {{ type === 'report-config' ? 'report' : 'evidence' }}. + Select which {{ settings.getTeamLabelPlural() | lowercase }} to include in the + {{ type === 'report-config' ? 'report' : 'evidence' }}.

- Group: + {{ settings.getGroupLabel() }}: diff --git a/src/app/component/team-selector/team-selector.component.ts b/src/app/component/team-selector/team-selector.component.ts index 55f5f0fc2..495bc0b9a 100644 --- a/src/app/component/team-selector/team-selector.component.ts +++ b/src/app/component/team-selector/team-selector.component.ts @@ -1,5 +1,6 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; import { TeamGroups } from '../../model/types'; +import { SettingsService } from 'src/app/service/settings/settings.service'; @Component({ selector: 'app-team-selector', @@ -16,6 +17,8 @@ export class TeamSelectorComponent { selectedGroupName: string = ''; + constructor(public settings: SettingsService) {} + isTeamSelected(team: string): boolean { return this.selectedTeams.includes(team); } diff --git a/src/app/component/teams-groups-editor/teams-groups-editor.component.html b/src/app/component/teams-groups-editor/teams-groups-editor.component.html index cc1614ed3..1e6df9c0c 100644 --- a/src/app/component/teams-groups-editor/teams-groups-editor.component.html +++ b/src/app/component/teams-groups-editor/teams-groups-editor.component.html @@ -2,7 +2,7 @@
= {}; localCopyTeamGroups: TeamGroups = {}; + constructor(public settings: SettingsService) {} + ngOnChanges() { this.makeLocalCopy(); @@ -159,7 +162,7 @@ export class TeamsGroupsEditorComponent implements OnChanges { } onAddTeam() { - let newName: string = this.findNextName(this.localCopyTeams, 'Team'); + let newName: string = this.findNextName(this.localCopyTeams, this.settings.getTeamLabel()); this.localCopyTeams.push(newName); this.onTeamSelected(newName); } @@ -194,7 +197,10 @@ export class TeamsGroupsEditorComponent implements OnChanges { } onAddGroup() { - let newName: string = this.findNextName(this.keys(this.localCopyTeamGroups), 'Group'); + let newName: string = this.findNextName( + this.keys(this.localCopyTeamGroups), + this.settings.getGroupLabel() + ); this.localCopyTeamGroups[newName] = []; this.onGroupSelected(newName); } diff --git a/src/app/model/meta-store.ts b/src/app/model/meta-store.ts index 3a0a26b69..e33d7907d 100644 --- a/src/app/model/meta-store.ts +++ b/src/app/model/meta-store.ts @@ -4,12 +4,16 @@ import { ProgressDefinitions, TeamNames, TeamGroups } from './types'; import { perfNow } from 'src/app/util/util'; export interface MetaStrings { + team: string; + group: string; allTeamsGroupName: string; labels: string[]; maturityLevels: string[]; knowledgeLabels: string[]; } const fallbackMetaStrings: MetaStrings = { + team: 'Team', + group: 'Group', allTeamsGroupName: 'All', maturityLevels: ['Level 1', 'Level 2'], labels: ['Easy', 'Medium', 'Hard'], diff --git a/src/app/pages/circular-heatmap/circular-heatmap.component.html b/src/app/pages/circular-heatmap/circular-heatmap.component.html index 39f7a75b2..30e3459f5 100644 --- a/src/app/pages/circular-heatmap/circular-heatmap.component.html +++ b/src/app/pages/circular-heatmap/circular-heatmap.component.html @@ -35,7 +35,9 @@

Nothing to show

- Team Group Filter + {{ settings.getTeamLabel() }} {{ settings.getGroupLabel() }} Filter Nothing to show - Team Filter + {{ settings.getTeamLabel() }} Filter Nothing to show mat-raised-button class="downloadButtonClass" (click)="exportTeamProgress()"> - Download team progress + Download {{ settings.getTeamLabel() | lowercase }} progress
diff --git a/src/app/pages/report/report.component.ts b/src/app/pages/report/report.component.ts index 479ed4217..1ec0dfe35 100644 --- a/src/app/pages/report/report.component.ts +++ b/src/app/pages/report/report.component.ts @@ -69,7 +69,7 @@ export class ReportComponent implements OnInit { constructor( private loader: LoaderService, - private settings: SettingsService, + public settings: SettingsService, private dialog: MatDialog, private datePipe: DatePipe ) { diff --git a/src/app/pages/settings/settings.component.css b/src/app/pages/settings/settings.component.css index 7047e848b..a70d62eb5 100644 --- a/src/app/pages/settings/settings.component.css +++ b/src/app/pages/settings/settings.component.css @@ -248,3 +248,31 @@ mat-icon.mandatory-icon { margin-left: 0.5rem; font-weight: 500; } + +/* Terminology section */ +.terminology-section { + margin-top: 1rem; +} + +.terminology-description { + color: #666; + font-size: 0.9em; + margin-bottom: 1em; +} + +.terminology-grid { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.terminology-row { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.terminology-row mat-form-field { + flex: 1; + min-width: 180px; +} diff --git a/src/app/pages/settings/settings.component.html b/src/app/pages/settings/settings.component.html index 6eb5eb387..a9378445f 100644 --- a/src/app/pages/settings/settings.component.html +++ b/src/app/pages/settings/settings.component.html @@ -156,6 +156,56 @@

Progress Definitions

+ + +

Terminology

+

+ Customize the terminology to how you organize your "teams" and how you group them (e.g. 'Apps' + and 'Portfolios'). +

+
+
+ + Team (singular) + + + + Team (plural) + + +
+
+ + Group (singular) + + + + Group (plural) + + +
+
+

About the DSOMM Model

diff --git a/src/app/pages/settings/settings.component.spec.ts b/src/app/pages/settings/settings.component.spec.ts index 599bcfca8..3ded3f772 100644 --- a/src/app/pages/settings/settings.component.spec.ts +++ b/src/app/pages/settings/settings.component.spec.ts @@ -43,7 +43,22 @@ describe('SettingsComponent', () => { 'setMaxLevel', 'getDateFormat', 'setDateFormat', + 'getTeamLabel', + 'getTeamLabelPlural', + 'getGroupLabel', + 'getGroupLabelPlural', + 'setTeamLabel', + 'setGroupLabel', + 'getMetaTeamLabel', + 'getMetaGroupLabel', + 'initFromMeta', ]); + settingsService.getTeamLabel.and.returnValue('Team'); + settingsService.getTeamLabelPlural.and.returnValue('Teams'); + settingsService.getGroupLabel.and.returnValue('Group'); + settingsService.getGroupLabelPlural.and.returnValue('Groups'); + settingsService.getMetaTeamLabel.and.returnValue({ singular: 'Team', plural: 'Teams' }); + settingsService.getMetaGroupLabel.and.returnValue({ singular: 'Group', plural: 'Groups' }); modalComponent = jasmine.createSpyObj('ModalMessageComponent', ['openDialog']); await TestBed.configureTestingModule({ diff --git a/src/app/pages/settings/settings.component.ts b/src/app/pages/settings/settings.component.ts index f11c66b11..49f363fbf 100644 --- a/src/app/pages/settings/settings.component.ts +++ b/src/app/pages/settings/settings.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, FormArray, AbstractControl } from '@angular/forms'; -import { SettingsService } from '../../service/settings/settings.service'; +import { SettingsService, LabelParts } from '../../service/settings/settings.service'; import { GithubService, GithubReleaseInfo } from 'src/app/service/settings/github.service'; import { LoaderService } from 'src/app/service/loader/data-loader.service'; import { DataStore } from 'src/app/model/data-store'; @@ -61,6 +61,11 @@ export class SettingsComponent implements OnInit { ]; selectedDateFormat: string = this.BROWSER_LOCALE; + customTeamLabel: string = ''; + customTeamLabelPlural: string = ''; + customGroupLabel: string = ''; + customGroupLabelPlural: string = ''; + // GitHub release check state checkingLatest: boolean = false; latestReleaseInfo: GithubReleaseInfo | null = null; @@ -85,6 +90,7 @@ export class SettingsComponent implements OnInit { .then((dataStore: DataStore) => { this.setYamlData(dataStore); this.updateProgressDefinitionsForm(); + this.initLabels(); // Re-read labels now that meta.yaml is loaded }) .catch(err => { this.modal.openDialog(new DialogInfo(err.message, 'An error occurred')); @@ -145,6 +151,7 @@ export class SettingsComponent implements OnInit { initialize(): void { this.selectedDateFormat = this.settings.getDateFormat() || this.BROWSER_LOCALE; + this.initLabels(); // Init dates let date: Date = new Date(); @@ -158,6 +165,29 @@ export class SettingsComponent implements OnInit { } } + /** + * Read current labels from the service into the component fields. + * Called both on init and after meta.yaml loads. + */ + initLabels(): void { + const teamLabel = this.settings.getTeamLabel(); + const teamPlural = this.settings.getTeamLabelPlural(); + const groupLabel = this.settings.getGroupLabel(); + const groupPlural = this.settings.getGroupLabelPlural(); + + // Show the value in the field only if it differs from the meta.yaml default. + // Otherwise, leave the field empty and let the placeholder show the default. + const metaTeam = this.settings.getMetaTeamLabel(); + const metaGroup = this.settings.getMetaGroupLabel(); + + this.customTeamLabel = teamLabel === metaTeam.singular ? '' : teamLabel; + this.customGroupLabel = groupLabel === metaGroup.singular ? '' : groupLabel; + + // For plural: show empty if it matches the auto-generated default (singular + 's') + this.customTeamLabelPlural = teamPlural === teamLabel + 's' ? '' : teamPlural; + this.customGroupLabelPlural = groupPlural === groupLabel + 's' ? '' : groupPlural; + } + setYamlData(dataStore: DataStore): void { this.dataStoreMaxLevel = dataStore.getMaxLevel(); this.selectedMaxLevel = this.settings.getMaxLevel() || this.dataStoreMaxLevel; @@ -179,6 +209,52 @@ export class SettingsComponent implements OnInit { this.settings.setDateFormat(value); } + onTeamLabelChange(): void { + // If cleared, revert to meta.yaml default + const effective = this.customTeamLabel || this.settings.getMetaTeamLabel().singular; + this.settings.setTeamLabel(effective, this.customTeamLabelPlural); + // Clear plural so placeholder updates + if (!this.customTeamLabelPlural || this.customTeamLabelPlural === effective + 's') { + this.customTeamLabelPlural = ''; + } + } + + onTeamLabelPluralChange(): void { + const effective = this.customTeamLabel || this.settings.getMetaTeamLabel().singular; + this.settings.setTeamLabel(effective, this.customTeamLabelPlural); + } + + onGroupLabelChange(): void { + // If cleared, revert to meta.yaml default + const effective = this.customGroupLabel || this.settings.getMetaGroupLabel().singular; + this.settings.setGroupLabel(effective, this.customGroupLabelPlural); + // Clear plural so placeholder updates + if (!this.customGroupLabelPlural || this.customGroupLabelPlural === effective + 's') { + this.customGroupLabelPlural = ''; + } + } + + onGroupLabelPluralChange(): void { + const effective = this.customGroupLabel || this.settings.getMetaGroupLabel().singular; + this.settings.setGroupLabel(effective, this.customGroupLabelPlural); + } + + getTeamSingularPlaceholder(): string { + return this.settings.getMetaTeamLabel().singular || 'Team'; + } + + getGroupSingularPlaceholder(): string { + return this.settings.getMetaGroupLabel().singular || 'Group'; + } + + getTeamPluralPlaceholder(): string { + return (this.customTeamLabel || this.settings.getMetaTeamLabel().singular || 'Team') + 's'; + } + + getGroupPluralPlaceholder(): string { + return (this.customGroupLabel || this.settings.getMetaGroupLabel().singular || 'Group') + 's'; + } + onMaxLevelChange(value: number | null): void { if (value == null) value = this.dataStoreMaxLevel; if (value == this.dataStoreMaxLevel) { diff --git a/src/app/pages/teams/teams.component.html b/src/app/pages/teams/teams.component.html index 9dce214e1..60d8f8a22 100644 --- a/src/app/pages/teams/teams.component.html +++ b/src/app/pages/teams/teams.component.html @@ -1,4 +1,7 @@ - +
- - + +
@@ -37,7 +44,9 @@

{{ infoTitle }}

Activities in progress

- + diff --git a/src/app/pages/teams/teams.component.ts b/src/app/pages/teams/teams.component.ts index c16b10b6f..5f238c8f8 100644 --- a/src/app/pages/teams/teams.component.ts +++ b/src/app/pages/teams/teams.component.ts @@ -161,7 +161,12 @@ export class TeamsComponent implements OnInit, AfterViewInit { if (!yamlStr) { this.displayMessage( - new DialogInfo('No team and groups names stored locally in the browser', 'Export Error') + new DialogInfo( + `No ${this.settings.getTeamLabel().toLowerCase()} and ${this.settings + .getGroupLabelPlural() + .toLowerCase()} names stored locally in the browser`, + 'Export Error' + ) ); return; } @@ -182,7 +187,9 @@ export class TeamsComponent implements OnInit, AfterViewInit { return new Promise((resolve, reject) => { let title: string = 'Delete local browser data'; let message: string = - 'Do you want to reset all team and group names?' + + `Do you want to reset all ${this.settings.getTeamLabel().toLowerCase()} and ${this.settings + .getGroupLabel() + .toLowerCase()} names?` + '\n\nThis will revert the names to the names stored in the yaml file on the server.'; let buttons: string[] = ['Cancel', 'Delete']; this.modal diff --git a/src/app/service/loader/data-loader.service.ts b/src/app/service/loader/data-loader.service.ts index afe580015..89e869e42 100644 --- a/src/app/service/loader/data-loader.service.ts +++ b/src/app/service/loader/data-loader.service.ts @@ -13,6 +13,7 @@ import { } from 'src/app/model/activity-store'; import { DataStore } from 'src/app/model/data-store'; import { NotificationService } from '../notification.service'; +import { SettingsService } from '../settings/settings.service'; export class DataValidationError extends Error { constructor(message: string) { @@ -38,7 +39,8 @@ export class LoaderService { constructor( private yamlService: YamlService, private githubService: GithubService, - private notificationService: NotificationService + private notificationService: NotificationService, + private settingsService: SettingsService ) { this.DSOMM_MODEL_URL = this.githubService.getDsommModelUrl() + '/tree/main/generated'; } @@ -62,6 +64,9 @@ export class LoaderService { this.dataStore.meta = await this.loadMeta(); this.dataStore.progressStore?.init(this.dataStore.meta.progressDefinition); + // Initialize SettingsService with meta.yaml defaults for team/group terminology + this.settingsService.initFromMeta(this.dataStore.getMetaStrings()); + // Then load activities this.dataStore.addActivities(await this.loadActivities(this.dataStore.meta)); diff --git a/src/app/service/settings/settings.service.spec.ts b/src/app/service/settings/settings.service.spec.ts index 14962f813..d0a7b1d1e 100644 --- a/src/app/service/settings/settings.service.spec.ts +++ b/src/app/service/settings/settings.service.spec.ts @@ -1,16 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { SettingsService } from './settings.service'; +import { SettingsService, LabelParts } from './settings.service'; describe('SettingsService', () => { let service: SettingsService; - let localStorageSpy: any; beforeEach(() => { - // Clear all mocks and create fresh instance for each test localStorage.clear(); TestBed.configureTestingModule({}); service = TestBed.inject(SettingsService); - localStorageSpy = spyOn(localStorage, 'setItem').and.callThrough(); }); afterEach(() => { @@ -21,22 +18,124 @@ describe('SettingsService', () => { expect(service).toBeTruthy(); }); - describe('General Settings Operations', () => { - it('should handle empty string settings', () => { - service.saveSettings('test.key', ''); - expect(localStorage.getItem('test.key')).toBeNull(); + describe('parseLabelParts', () => { + it('should parse a simple label without pipe', () => { + const result: LabelParts = SettingsService.parseLabelParts('Team'); + expect(result).toEqual({ singular: 'Team', plural: 'Teams' }); }); - it('should handle empty array settings', () => { - service.saveSettings('test.key', []); - expect(localStorage.getItem('test.key')).toBeNull(); + it('should parse a pipe-separated label', () => { + const result: LabelParts = SettingsService.parseLabelParts('Industry|Industries'); + expect(result).toEqual({ singular: 'Industry', plural: 'Industries' }); }); - it('should handle empty object settings', () => { - service.saveSettings('test.key', {}); - expect(localStorage.getItem('test.key')).toBeNull(); + it('should handle empty string', () => { + const result: LabelParts = SettingsService.parseLabelParts(''); + expect(result).toEqual({ singular: '', plural: '' }); }); + it('should handle pipe with empty plural (fallback to +s)', () => { + const result: LabelParts = SettingsService.parseLabelParts('App|'); + expect(result).toEqual({ singular: 'App', plural: 'Apps' }); + }); + + it('should trim whitespace', () => { + const result: LabelParts = SettingsService.parseLabelParts(' Client | Clients '); + expect(result).toEqual({ singular: 'Client', plural: 'Clients' }); + }); + }); + + describe('encodeLabelParts', () => { + it('should encode without pipe when plural is default +s', () => { + expect(SettingsService.encodeLabelParts('Team', 'Teams')).toBe('Team'); + }); + + it('should encode with pipe when plural is custom', () => { + expect(SettingsService.encodeLabelParts('Industry', 'Industries')).toBe( + 'Industry|Industries' + ); + }); + + it('should encode without pipe when plural is empty', () => { + expect(SettingsService.encodeLabelParts('App', '')).toBe('App'); + }); + }); + + describe('initFromMeta', () => { + it('should set meta defaults for team and group', () => { + service.initFromMeta({ + team: 'App', + group: 'Portfolio|Portfolios', + allTeamsGroupName: 'All', + labels: [], + maturityLevels: [], + knowledgeLabels: [], + }); + // Without any localStorage override, getters should return meta defaults + expect(service.getTeamLabel()).toBe('App'); + expect(service.getTeamLabelPlural()).toBe('Apps'); + expect(service.getGroupLabel()).toBe('Portfolio'); + expect(service.getGroupLabelPlural()).toBe('Portfolios'); + }); + + it('should be overridden by localStorage values', () => { + service.initFromMeta({ + team: 'App', + group: 'Portfolio', + allTeamsGroupName: 'All', + labels: [], + maturityLevels: [], + knowledgeLabels: [], + }); + service.setTeamLabel('Client', 'Clients'); + expect(service.getTeamLabel()).toBe('Client'); + expect(service.getTeamLabelPlural()).toBe('Clients'); + }); + }); + + describe('Consolidated labels round-trip', () => { + it('should store and retrieve team label', () => { + service.setTeamLabel('App'); + expect(service.getTeamLabel()).toBe('App'); + expect(service.getTeamLabelPlural()).toBe('Apps'); + }); + + it('should store and retrieve team label with custom plural', () => { + service.setTeamLabel('Category', 'Categories'); + expect(service.getTeamLabel()).toBe('Category'); + expect(service.getTeamLabelPlural()).toBe('Categories'); + }); + + it('should store and retrieve group label', () => { + service.setGroupLabel('Division'); + expect(service.getGroupLabel()).toBe('Division'); + expect(service.getGroupLabelPlural()).toBe('Divisions'); + }); + + it('should store and retrieve group label with custom plural', () => { + service.setGroupLabel('Industry', 'Industries'); + expect(service.getGroupLabel()).toBe('Industry'); + expect(service.getGroupLabelPlural()).toBe('Industries'); + }); + + it('should use localStorage key settings.labels', () => { + service.setTeamLabel('Foo'); + service.setGroupLabel('Bar', 'Bars'); + const stored = JSON.parse(localStorage.getItem('settings.labels')!); + expect(stored.team).toBe('Foo'); + expect(stored.group).toBe('Bar'); + }); + + it('should clear labels from localStorage when reset to default', () => { + service.setTeamLabel('Custom'); + expect(localStorage.getItem('settings.labels')).not.toBeNull(); + service.setTeamLabel('Team'); + service.setGroupLabel('Group'); + expect(localStorage.getItem('settings.labels')).toBeNull(); + }); + }); + + describe('Settings helpers', () => { it('should properly store and retrieve number settings', () => { localStorage.setItem('test.key', '42'); expect(service.getSettingsNumber('test.key')).toBe(42); @@ -45,15 +144,5 @@ describe('SettingsService', () => { it('should return null for non-existent number settings', () => { expect(service.getSettingsNumber('nonexistent.key')).toBeNull(); }); - - it('should handle complex object settings', () => { - const complexObj = { - key1: 'value1', - key2: 42, - nested: { prop: true }, - }; - service.saveSettings('test.complex', complexObj); - expect(service.getSettings('test.complex')).toEqual(complexObj); - }); }); }); diff --git a/src/app/service/settings/settings.service.ts b/src/app/service/settings/settings.service.ts index 89fe9bce8..db45028d4 100644 --- a/src/app/service/settings/settings.service.ts +++ b/src/app/service/settings/settings.service.ts @@ -1,4 +1,15 @@ import { Injectable } from '@angular/core'; +import { MetaStrings } from 'src/app/model/meta-store'; + +export interface LabelParts { + singular: string; + plural: string; +} + +export interface Labels { + team: string; + group: string; +} @Injectable({ providedIn: 'root', @@ -6,9 +17,171 @@ import { Injectable } from '@angular/core'; export class SettingsService { private readonly KEY_DATEFORMAT = 'settings.dateformat'; private readonly KEY_MAX_LEVEL = 'settings.maxlevel'; + private readonly KEY_LABELS = 'settings.labels'; private dateformat: string | null = null; private maxLevel: number | null = null; + private _labels: Labels | null = null; + + // Server-level defaults from meta.yaml (set via initFromMeta) + private _metaTeam: LabelParts = { singular: 'Team', plural: 'Teams' }; + private _metaGroup: LabelParts = { singular: 'Group', plural: 'Groups' }; + private _metaInitialized: boolean = false; + + constructor() {} + + // --- Meta initialization --- + + /** + * Called by LoaderService after loading meta.yaml to set server-level defaults. + */ + initFromMeta(metaStrings: MetaStrings): void { + if (metaStrings.team) { + this._metaTeam = SettingsService.parseLabelParts(metaStrings.team); + } + if (metaStrings.group) { + this._metaGroup = SettingsService.parseLabelParts(metaStrings.group); + } + this._metaInitialized = true; + // Reset cached labels so next get reads fresh values with new defaults + this._labels = null; + } + + // --- Meta defaults (for placeholder display) --- + + getMetaTeamLabel(): LabelParts { + return this._metaTeam; + } + + getMetaGroupLabel(): LabelParts { + return this._metaGroup; + } + + // --- Label parsing --- + + /** + * Parse a |-split label string into singular and plural parts. + * "Industry|Industries" => { singular: "Industry", plural: "Industries" } + * "Team" => { singular: "Team", plural: "Teams" } + */ + static parseLabelParts(value: string): LabelParts { + if (!value || value.trim().length === 0) { + return { singular: '', plural: '' }; + } + const parts = value.split('|'); + const singular = parts[0].trim(); + const plural = + parts.length > 1 && parts[1].trim().length > 0 ? parts[1].trim() : singular + 's'; + return { singular, plural }; + } + + /** + * Encode singular + plural into a |-split string. + * If plural === singular + 's', omit the plural part (store just singular). + */ + static encodeLabelParts(singular: string, plural: string): string { + if (!singular || singular.trim().length === 0) return ''; + singular = singular.trim(); + plural = plural.trim(); + if (!plural || plural === singular + 's') { + return singular; + } + return `${singular}|${plural}`; + } + + // --- Labels (consolidated) --- + + private loadLabels(): Labels { + if (this._labels !== null) return this._labels; + + const stored = localStorage.getItem(this.KEY_LABELS); + if (stored) { + try { + this._labels = JSON.parse(stored); + return this._labels!; + } catch { + // Invalid JSON — ignore + } + } + this._labels = { team: '', group: '' }; + return this._labels; + } + + private saveLabels(labels: Labels): void { + this._labels = labels; + // Remove from storage if both are empty/default + if (!labels.team && !labels.group) { + localStorage.removeItem(this.KEY_LABELS); + } else { + localStorage.setItem(this.KEY_LABELS, JSON.stringify(labels)); + } + } + + // --- Team label --- + + getTeamLabel(): string { + const labels = this.loadLabels(); + if (labels.team) { + return SettingsService.parseLabelParts(labels.team).singular; + } + return this._metaTeam.singular || 'Team'; + } + + getTeamLabelPlural(): string { + const labels = this.loadLabels(); + if (labels.team) { + return SettingsService.parseLabelParts(labels.team).plural; + } + return this._metaTeam.plural || 'Teams'; + } + + setTeamLabel(singular: string | null, plural?: string | null): void { + const labels = this.loadLabels(); + const s = singular?.trim() || ''; + const p = plural?.trim() || ''; + + // If the value matches the meta default, don't store it + if (s === this._metaTeam.singular && (!p || p === this._metaTeam.plural)) { + labels.team = ''; + } else { + labels.team = s ? SettingsService.encodeLabelParts(s, p) : ''; + } + this.saveLabels(labels); + } + + // --- Group label --- + + getGroupLabel(): string { + const labels = this.loadLabels(); + if (labels.group) { + return SettingsService.parseLabelParts(labels.group).singular; + } + return this._metaGroup.singular || 'Group'; + } + + getGroupLabelPlural(): string { + const labels = this.loadLabels(); + if (labels.group) { + return SettingsService.parseLabelParts(labels.group).plural; + } + return this._metaGroup.plural || 'Groups'; + } + + setGroupLabel(singular: string | null, plural?: string | null): void { + const labels = this.loadLabels(); + const s = singular?.trim() || ''; + const p = plural?.trim() || ''; + + // If the value matches the meta default, don't store it + if (s === this._metaGroup.singular && (!p || p === this._metaGroup.plural)) { + labels.group = ''; + } else { + labels.group = s ? SettingsService.encodeLabelParts(s, p) : ''; + } + this.saveLabels(labels); + } + + // --- Date format --- getDateFormat(): string | null { if (this.dateformat == null) { @@ -22,6 +195,8 @@ export class SettingsService { this.saveSettings(this.KEY_DATEFORMAT, format); } + // --- Max level --- + getMaxLevel(): number | null { if (this.maxLevel == null) { this.maxLevel = this.getSettingsNumber(this.KEY_MAX_LEVEL); @@ -34,6 +209,8 @@ export class SettingsService { this.saveSettings(this.KEY_MAX_LEVEL, maxLevel); } + // --- Generic settings helpers --- + getSettingsNumber(key: string): number | null { let setting: string | null = localStorage.getItem(key); if (setting == null) { diff --git a/src/assets/YAML/meta.yaml b/src/assets/YAML/meta.yaml index 13d6afecd..289e14508 100644 --- a/src/assets/YAML/meta.yaml +++ b/src/assets/YAML/meta.yaml @@ -37,6 +37,9 @@ activityFiles: lang: en strings: en: + # Optional: customize terminology (use 'singular|plural' for irregular plurals, e.g. 'Industry|Industries') + team: 'Team' + group: 'Group' allTeamsGroupName: 'All teams' maturityLevels: [
Team + {{ settings.getTeamLabel() }} + {{ element?.team }}