import { Injectable, NgZone } from '@angular/core'; import { ConfigService } from './config.service'; import { Observable, ReplaySubject, Subject, Subscribable, Subscription, from, map, tap, timer } from 'rxjs'; import { Plan } from '../_models/plan'; import { MatDialog } from '@angular/material/dialog'; import { Resume, ResumeDialog } from '../plan-display/resume-dialog.component'; import { v4 as uuidv4 } from 'uuid'; import { invoke } from '@tauri-apps/api'; import { appWindow } from '@tauri-apps/api/window'; export enum EntryType { PlanForceNext = "PlanForceNext", PlanForcePrev = "PlanForcePrev", ZoneEnter = "ZoneEnter", CheckpointReached = "CheckpointReached", } export interface TrackEntry { type: EntryType; zone: string; current_elapsed_millis: number; } export interface RunHistoryMetadata { uuid: string; currentElapsedMillis: number; associatedName: string; last_updated: number; } interface RunHistoryInterface { uuid: string; current_elapsed_millis: number; associated_name: string; last_updated: number; entries: TrackEntry[]; } export class RunHistory { uuid: string; currentElapsedMillis: number; entries: TrackEntry[]; associatedName: string; last_updated: number; plan?: Plan; constructor(data: RunHistoryInterface) { this.uuid = data.uuid; this.currentElapsedMillis = data.current_elapsed_millis; this.entries = data.entries; this.last_updated = data.last_updated; this.associatedName = data.associated_name; } toInterface(): RunHistoryInterface { return { uuid: this.uuid, current_elapsed_millis: this.currentElapsedMillis, last_updated: this.last_updated, entries: this.entries, associated_name: this.associatedName, } } } @Injectable({ providedIn: 'root' }) export class TimeTrackerService { private currentRunHistory?: RunHistory; private timerSubscription?: Subscription; private debouncedSaveStopwatch?: Subscription; resumeOnNext: boolean = false; private start?: Date; private latest?: Date; private newRun: Subject = new Subject(); private active: boolean = false; private storedHistoriesSubject: Subject> = new ReplaySubject>(1); private pushSubject: Subject = new ReplaySubject(1); private newCurrentRunHistorySubject: Subject = new ReplaySubject(1); constructor(private configService: ConfigService, public dialog: MatDialog, private zone: NgZone) { this.loadCache(); appWindow.listen("entered", (entered) => { if (entered.payload && typeof entered.payload === 'string') this.onZoneEnter(entered.payload); }); this.pushSubject.subscribe((entry) => { this.currentRunHistory?.entries.push(entry); }); } public getCurrentRunHistory(): Observable { return this.newCurrentRunHistorySubject.asObservable(); } getLatestEntry() { return this.pushSubject.asObservable(); } private setCurrentRunHistory(history: RunHistory) { this.currentRunHistory = history; this.newCurrentRunHistorySubject.next(history); } get elapsedTimeMillis() { this.latest = new Date(); return this.latest!.valueOf() - this.start!.valueOf(); } get isActive() { return this.active; } get hasRunLoaded() { return !!this.currentRunHistory; } public get storedHistories(): Observable> { return this.storedHistoriesSubject; } public getNewRunSubject(): Observable{ return this.newRun.asObservable(); } onNewRun(plan: Plan) { if (this.timerSubscription && !this.timerSubscription.closed) this.timerSubscription.unsubscribe(); if (this.debouncedSaveStopwatch && !this.debouncedSaveStopwatch.closed) this.debouncedSaveStopwatch.unsubscribe(); this.start = undefined; this.latest = undefined; this.active = false; if (plan.last_stored_time) { this.loadHistory(plan.last_stored_time).subscribe(history => { if (history) { this.setCurrentRunHistory(history); } else { //Legacy or missing history, attempt to preserve elapsed time this.setCurrentRunHistory(this.createNew(plan.name)); plan.last_stored_time = this.currentRunHistory!.uuid; const old_time = parseInt(plan.last_stored_time, 10); if (!isNaN(old_time) && old_time > 0) { this.currentRunHistory!.currentElapsedMillis = old_time; } plan.requestSelfSave(); } this.currentRunHistory!.plan = plan; this.askResume(plan); }); } else { this.setCurrentRunHistory(this.createNew(plan.name)); this.currentRunHistory!.plan = plan; this.resumeOnNext = true; } } ///Assumes currentPlan is set... private startStopwatch() { this.stop(); // Make sure we stop before starting again if (this.currentRunHistory?.currentElapsedMillis) { this.start = new Date(Date.now() - this.currentRunHistory.currentElapsedMillis); } else { this.start = new Date(); } this.latest = new Date(); this.active = true; //Make sure this is always cleared if e.g. force started! should be fine but just in case!! this.resumeOnNext = false; this.timerSubscription = timer(0, 1000).subscribe(() => { this.zone.run(() => { this.latest = new Date(); this.currentRunHistory!.currentElapsedMillis = this.elapsedTimeMillis; }); }); this.debouncedSaveStopwatch = timer(0, 5000).subscribe(() => { this.underlyingSaveStopwatch(); }) } private underlyingSaveStopwatch() { if (this.currentRunHistory && this.active && this.start && this.latest) { this.currentRunHistory!.currentElapsedMillis = this.elapsedTimeMillis; this.currentRunHistory!.last_updated = Date.now(); this.saveHistory(this.currentRunHistory!).subscribe(() => { }); this.loadCache(); if (this.currentRunHistory.plan) { this.currentRunHistory.plan.last_stored_time = this.currentRunHistory.uuid; this.currentRunHistory.plan.requestSelfSave(); } } } public saveHistory(currentRunHistory: RunHistory) { return from(invoke('save_history', { currentRunHistory: currentRunHistory.toInterface() })).pipe(tap(() => this.loadCache())); } public loadHistory(uuid: string) { return from(invoke('load_history_at_uuid', { uuid: uuid })).pipe(map(history => { if (history) { return new RunHistory(history); } return undefined; })); } public onForceNext(forced_area: string) { if (this.configService.config.enableStopwatch) { if (this.isActive) { this.pushSubject.next({ type: EntryType.PlanForceNext, zone: forced_area, current_elapsed_millis: this.elapsedTimeMillis }); } } } public onForcePrev(forced_area: string) { if (this.configService.config.enableStopwatch) { if (this.isActive) { this.pushSubject.next({ type: EntryType.PlanForcePrev, zone: forced_area, current_elapsed_millis: this.elapsedTimeMillis }); } } } //Not perfect but good enough.. public reportCheckpoint(checkpoint: string) { this.pushSubject.next({ type: EntryType.CheckpointReached, zone: checkpoint, current_elapsed_millis: this.elapsedTimeMillis }) } public stop() { if (this.timerSubscription && !this.timerSubscription.closed) this.timerSubscription.unsubscribe(); if (this.debouncedSaveStopwatch && !this.debouncedSaveStopwatch.closed) this.debouncedSaveStopwatch.unsubscribe(); //Do a nice little save here as well! this.underlyingSaveStopwatch(); this.start = undefined; this.latest = undefined; this.active = false; } public startLoaded() { if (this.currentRunHistory) { this.startStopwatch(); } } public hmsTimestamp(elapsed?: number): string { return hmsTimestamp(elapsed); } public loadCache() { from(invoke>('load_cache')).subscribe(data => { this.zone.run(() => { const cache = new Map(); Object.values(data).forEach((value) => { cache.set(value.uuid, { uuid: value.uuid, currentElapsedMillis: value.current_elapsed_millis, associatedName: value.associated_name, last_updated: value.last_updated, }); }); console.log("sending new cache!"); this.storedHistoriesSubject.next(cache); }); }); } private onZoneEnter(zone: string) { if (!this.currentRunHistory) return; if (this.configService.config.enableStopwatch) { if(!this.isActive && this.resumeOnNext) { this.resumeOnNext = false; this.startStopwatch(); } if(!this.isActive && !this.resumeOnNext) { //Don't start timer if not meant to auto resumes on next. return; } this.pushSubject.next({ type: EntryType.ZoneEnter, zone: zone, current_elapsed_millis: this.elapsedTimeMillis }); } } private askResume(plan: Plan) { const dialogRef = this.dialog.open(ResumeDialog, { disableClose: true }); dialogRef.afterClosed().subscribe(resume => { switch (resume) { case Resume.Instant: this.startStopwatch(); this.loadReachedCheckpoints(); break; case Resume.Next: this.resumeOnNext = true; this.loadReachedCheckpoints(); break; case Resume.Discard: this.setCurrentRunHistory(this.createNew(plan.name)); this.loadReachedCheckpoints(); plan.last_stored_time = this.currentRunHistory!.uuid; this.resumeOnNext = true; plan.requestSelfSave(); break; case Resume.NewRun: this.setCurrentRunHistory(this.createNew(plan.name)); this.loadReachedCheckpoints(); plan.last_stored_time = this.currentRunHistory!.uuid; this.resumeOnNext = true; plan.requestSelfSave(); this.newRun.next(); break; } }) } loadReachedCheckpoints() { if (!this.currentRunHistory || !this.currentRunHistory.plan || !this.configService.config.runCompareHistory) return; this.loadHistory(this.configService.config.runCompareHistory).subscribe(history => { if (!history) return; const checkpoints = new Map(history.entries.filter(entry => entry.type === EntryType.CheckpointReached).map(entry => [entry.zone, entry.current_elapsed_millis])); const ourCheckpointValidZones = new Map(this.currentRunHistory?.plan?.plan.filter(entry => checkpoints.has(entry.uuid!)).map(entry => [entry.uuid!, entry])); this.currentRunHistory?.entries.filter(entry => entry.type === EntryType.CheckpointReached).forEach(entry => { if (ourCheckpointValidZones.has(entry.zone)) { ourCheckpointValidZones.get(entry.zone)!.checkpoint_your_millis = entry.current_elapsed_millis; } }) }); } private createNew(associatedName?: string) { const uuid = uuidv4(); return new RunHistory({ uuid, associated_name: associatedName || 'Unnamed', current_elapsed_millis: 0, last_updated: Date.now(), entries: [], }); } } export function hmsTimestamp(elapsed?: number): string { if (elapsed == null || elapsed == undefined) return "N/A"; const h = String(Math.floor(elapsed / 3600000)).padStart(2, '0'); const m = String(Math.floor((elapsed % 3600000) / 60000)).padStart(2, '0'); const s = String(Math.floor((elapsed % 60000) / 1000)).padStart(2, '0'); return `${h}:${m}:${s}`; }