You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Nothing/src/app/_services/time-tracker.service.ts

382 lines
11 KiB

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 active: boolean = false;
private storedHistoriesSubject: Subject<Map<string, RunHistoryMetadata>> = new ReplaySubject<Map<string, RunHistoryMetadata>>(1);
private pushSubject: Subject<TrackEntry> = new ReplaySubject<TrackEntry>(1);
private newCurrentRunHistorySubject: Subject<RunHistory> = new ReplaySubject<RunHistory>(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<RunHistory> {
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<Map<string, RunHistoryMetadata>> {
return this.storedHistoriesSubject;
}
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<RunHistoryInterface>('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<Map<string, RunHistoryMetadata>>('load_cache')).subscribe(data => {
this.zone.run(() => {
const cache = new Map<string, RunHistoryMetadata>();
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;
}
})
}
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}`;
}