diff --git a/src/app/model/data-store.ts b/src/app/model/data-store.ts index 78834609..9d71c842 100644 --- a/src/app/model/data-store.ts +++ b/src/app/model/data-store.ts @@ -2,16 +2,19 @@ import { ActivityStore } from './activity-store'; import { Progress } from './types'; import { MetaStore, MetaStrings } from './meta-store'; import { ProgressStore } from './progress-store'; +import { EvidenceData, EvidenceStore } from './evidence-store'; export class DataStore { public meta: MetaStore | null = null; public activityStore: ActivityStore | null = null; public progressStore: ProgressStore | null = null; + public evidenceStore: EvidenceStore | null = null; constructor() { this.meta = new MetaStore(); this.activityStore = new ActivityStore(); this.progressStore = new ProgressStore(); + this.evidenceStore = new EvidenceStore(); } public addActivities(activities: ActivityStore): void { @@ -20,6 +23,9 @@ export class DataStore { public addProgressData(progress: Progress): void { this.progressStore?.addProgressData(progress); } + public addEvidenceData(evidence: EvidenceData): void { + this.evidenceStore?.addEvidenceData(evidence); + } public getMetaStrings(): MetaStrings { if (this.meta == null) { diff --git a/src/app/model/evidence-store.ts b/src/app/model/evidence-store.ts new file mode 100644 index 00000000..f5b88f44 --- /dev/null +++ b/src/app/model/evidence-store.ts @@ -0,0 +1,194 @@ +import { YamlService } from '../service/yaml-loader/yaml-loader.service'; +import { Uuid } from './types'; + +export interface EvidenceAttachment { + type: string; // e.g. 'document', 'image', 'link' + externalLink: string; // URL +} + +export interface EvidenceEntry { + id: string; // stable UUID for this entry + teams: string[]; + title: string; + evidenceRecorded: string; // ISO date string + reviewer?: string; + description: string; + attachment?: EvidenceAttachment[]; +} + +export type EvidenceData = Record; + +const LOCALSTORAGE_KEY: string = 'evidence'; + +export class EvidenceStore { + private yamlService: YamlService = new YamlService(); + private _evidence: EvidenceData = {}; + + // ─── Lifecycle ──────────────────────────────────────────── + + public initFromLocalStorage(): void { + const stored = this.retrieveStoredEvidence(); + if (stored) { + this.addEvidenceData(stored); + } + } + + // ─── Accessors ──────────────────────────────────────────── + + public getEvidenceData(): EvidenceData { + return this._evidence; + } + + public getEvidence(activityUuid: Uuid): EvidenceEntry[] { + return this._evidence[activityUuid] || []; + } + + public hasEvidence(activityUuid: Uuid): boolean { + return (this._evidence[activityUuid]?.length || 0) > 0; + } + + public getEvidenceCount(activityUuid: Uuid): number { + return this._evidence[activityUuid]?.length ?? 0; + } + + public getTotalEvidenceCount(): number { + let count = 0; + for (const uuid in this._evidence) { + count += this._evidence[uuid].length; + } + return count; + } + + public getActivityUuidsWithEvidence(): Uuid[] { + return Object.keys(this._evidence).filter(uuid => this._evidence[uuid].length > 0); + } + + // ─── Mutators ──────────────────────────────────────────── + + public addEvidenceData(newEvidence: EvidenceData): void { + if (!newEvidence) return; + + for (const activityUuid in newEvidence) { + if (!this._evidence[activityUuid]) { + this._evidence[activityUuid] = []; + } + + const newEntries = newEvidence[activityUuid]; + if (Array.isArray(newEntries)) { + for (const entry of newEntries) { + if (!this.isDuplicateEntry(activityUuid, entry)) { + this._evidence[activityUuid].push(entry); + } + } + } + } + } + + public replaceEvidenceData(data: EvidenceData): void { + this._evidence = data; + this.saveToLocalStorage(); + } + + public addEvidence(activityUuid: Uuid, entry: EvidenceEntry): void { + if (!this._evidence[activityUuid]) { + this._evidence[activityUuid] = []; + } + this._evidence[activityUuid].push(entry); + this.saveToLocalStorage(); + } + + public updateEvidence( + activityUuid: Uuid, + entryId: string, + updatedEntry: Partial + ): void { + const entries = this._evidence[activityUuid]; + if (!entries) { + console.warn(`No evidence found for activity ${activityUuid}`); + return; + } + const index = entries.findIndex(e => e.id === entryId); + if (index === -1) { + console.warn(`Cannot find evidence with id ${entryId} for activity ${activityUuid}`); + return; + } + // Immutable update for Angular change detection + entries[index] = { ...entries[index], ...updatedEntry }; + this.saveToLocalStorage(); + } + + public deleteEvidence(activityUuid: Uuid, entryId: string): void { + const entries = this._evidence[activityUuid]; + if (!entries) { + console.warn(`No evidence found for activity ${activityUuid}`); + return; + } + const index = entries.findIndex(e => e.id === entryId); + if (index === -1) { + console.warn(`Cannot find evidence with id ${entryId} for activity ${activityUuid}`); + return; + } + entries.splice(index, 1); + + if (entries.length === 0) { + delete this._evidence[activityUuid]; + } + this.saveToLocalStorage(); + } + + public renameTeam(oldName: string, newName: string): void { + console.log(`Renaming team '${oldName}' to '${newName}' in evidence store`); + for (const uuid in this._evidence) { + this._evidence[uuid].forEach(entry => { + entry.teams = entry.teams.map(t => (t === oldName ? newName : t)); + }); + } + this.saveToLocalStorage(); + } + + // ─── Serialization ────────────────────────────────────── + + public asYamlString(): string { + return this.yamlService.stringify({ evidence: this._evidence }); + } + + public saveToLocalStorage(): void { + const yamlStr = this.asYamlString(); + localStorage.setItem(LOCALSTORAGE_KEY, yamlStr); + } + + public deleteBrowserStoredEvidence(): void { + console.log('Deleting evidence from browser storage'); + localStorage.removeItem(LOCALSTORAGE_KEY); + } + + public retrieveStoredEvidenceYaml(): string | null { + return localStorage.getItem(LOCALSTORAGE_KEY); + } + + public retrieveStoredEvidence(): EvidenceData | null { + const yamlStr = this.retrieveStoredEvidenceYaml(); + if (!yamlStr) return null; + + const parsed = this.yamlService.parse(yamlStr); + return parsed?.evidence ?? null; + } + + // ─── Helpers ───────────────────────────────────────────── + + private isDuplicateEntry(activityUuid: Uuid, entry: EvidenceEntry): boolean { + const existing = this._evidence[activityUuid]; + if (!existing) return false; + return existing.some(e => e.id === entry.id); + } + + public static todayDateString(): string { + const now = new Date(); + return now.toISOString().substring(0, 10); + } + + // to be used when creating new evidence entries to ensure they have a stable UUID + public static generateId(): string { + return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/src/app/model/meta-store.ts b/src/app/model/meta-store.ts index d36ab238..3a0a26b6 100644 --- a/src/app/model/meta-store.ts +++ b/src/app/model/meta-store.ts @@ -35,6 +35,7 @@ export class MetaStore { teams: TeamNames = []; activityFiles: string[] = []; teamProgressFile: string = ''; + teamEvidenceFile: string = ''; allowChangeTeamNameInBrowser: boolean = true; dimensionIcons: Record = { @@ -67,6 +68,7 @@ export class MetaStore { this.teams = metaData.teams || this.teams || []; this.activityFiles = metaData.activityFiles || this.activityFiles || []; this.teamProgressFile = metaData.teamProgressFile || this.teamProgressFile || ''; + this.teamEvidenceFile = metaData.teamEvidenceFile || this.teamEvidenceFile || ''; if (metaData.allowChangeTeamNameInBrowser !== undefined) this.allowChangeTeamNameInBrowser = metaData.allowChangeTeamNameInBrowser; } diff --git a/src/app/service/loader/data-loader.service.ts b/src/app/service/loader/data-loader.service.ts index 79c49ddc..9fe4acf2 100644 --- a/src/app/service/loader/data-loader.service.ts +++ b/src/app/service/loader/data-loader.service.ts @@ -84,6 +84,14 @@ export class LoaderService { this.dataStore.addProgressData(browserProgress?.progress); } + // Load evidence data + const evidenceData = await this.loadEvidence(this.dataStore.meta); + this.dataStore.addEvidenceData(evidenceData.evidence); + this.dataStore.evidenceStore?.initFromLocalStorage(); + + // DEBUG ONLY + console.log('Merged EvidenceStore:', this.dataStore.evidenceStore?.getEvidenceData()); + console.log(`${perfNow()}: YAML: All YAML files loaded`); return this.dataStore; @@ -134,6 +142,10 @@ export class LoaderService { meta.activityFiles = meta.activityFiles.map(file => this.yamlService.makeFullPath(file, this.META_FILE) ); + if (!meta.teamProgressFile) { + throw Error("The meta.yaml has no 'teamEvidenceFile' to be loaded"); + } + meta.teamEvidenceFile = this.yamlService.makeFullPath(meta.teamEvidenceFile, this.META_FILE); if (this.debug) console.log(`${perfNow()} s: meta loaded`); console.log(`${perfNow()} s: Loaded teams: ${meta.teams.join(', ')}`); @@ -145,6 +157,11 @@ export class LoaderService { return this.yamlService.loadYaml(meta.teamProgressFile); } + private async loadEvidence(meta: MetaStore): Promise<{ evidence: any }> { + if (this.debug) console.log(`${perfNow()}s: Loading Team Evidence: ${meta.teamEvidenceFile}`); + return this.yamlService.loadYaml(meta.teamEvidenceFile); + } + private async loadActivities(meta: MetaStore): Promise { const activityStore = new ActivityStore(); const errors: string[] = []; diff --git a/src/assets/YAML/meta.yaml b/src/assets/YAML/meta.yaml index f655a1d9..81c1c5fb 100644 --- a/src/assets/YAML/meta.yaml +++ b/src/assets/YAML/meta.yaml @@ -5,6 +5,7 @@ browserSettings: teamProgressFile: 'team-progress.yaml' +teamEvidenceFile: 'team-evidence.yaml' progressDefinition: Not implemented: score: 0% diff --git a/src/assets/YAML/team-evidence.yaml b/src/assets/YAML/team-evidence.yaml new file mode 100644 index 00000000..c6a846fd --- /dev/null +++ b/src/assets/YAML/team-evidence.yaml @@ -0,0 +1,2 @@ + # Export team evidence from the browser, and replace this file +evidence: