In practice this means we're recording every zone entry, the elapsed time at that time and also recording any time you manually shift the plan as well. Later on I'll look into creating some views of this data e.g. an aggregate view e.g. time spent grouped by each zone and suchmain 1.6.0
parent
fda12b2f90
commit
5db8663819
@ -0,0 +1,51 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RunHistory {
|
||||
entries: Vec<TrackEntry>,
|
||||
|
||||
#[serde(flatten)]
|
||||
metadata: RunHistoryMetadata,
|
||||
}
|
||||
|
||||
impl RunHistory {
|
||||
pub fn uuid(&self) -> String {
|
||||
self.metadata.uuid.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RunHistory> for RunHistoryMetadata {
|
||||
fn from(history: RunHistory) -> Self {
|
||||
history.metadata
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum EntryType {
|
||||
PlanForceNext,
|
||||
PlanForcePrev,
|
||||
ZoneEnter,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TrackEntry {
|
||||
#[serde(rename = "type")]
|
||||
entry_type: EntryType,
|
||||
zone: String,
|
||||
current_elapsed_millis: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RunHistoryMetadata {
|
||||
uuid: String,
|
||||
current_elapsed_millis: u64,
|
||||
associated_name: String,
|
||||
#[serde(default = "default_last_updated_date_now")]
|
||||
last_updated: u64,
|
||||
}
|
||||
|
||||
fn default_last_updated_date_now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
@ -0,0 +1,281 @@
|
||||
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",
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
get elapsedTimeMillis() {
|
||||
return this.latest!.valueOf() - this.start!.valueOf();
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get hasRunLoaded() {
|
||||
return !!this.currentRunHistory;
|
||||
}
|
||||
|
||||
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.currentRunHistory = history;
|
||||
} else {
|
||||
//Legacy or missing history, attempt to preserve elapsed time
|
||||
this.currentRunHistory = 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.askResume(plan);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
///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(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
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.currentRunHistory?.entries.push({
|
||||
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.currentRunHistory?.entries.push({
|
||||
type: EntryType.PlanForcePrev,
|
||||
zone: forced_area,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
from(invoke<Map<string, RunHistoryMetadata>>('load_cache')).subscribe(data => {
|
||||
this.storedHistoriesSubject.next(data);
|
||||
});
|
||||
}
|
||||
|
||||
private onZoneEnter(zone: string) {
|
||||
if (!this.currentRunHistory) return;
|
||||
|
||||
if (this.configService.config.enableStopwatch) {
|
||||
if (!this.start || this.resumeOnNext) {
|
||||
this.resumeOnNext = false;
|
||||
this.startStopwatch();
|
||||
}
|
||||
|
||||
this.currentRunHistory?.entries.push({
|
||||
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();
|
||||
break;
|
||||
case Resume.Next:
|
||||
this.resumeOnNext = true;
|
||||
break;
|
||||
case Resume.Discard:
|
||||
this.currentRunHistory = this.createNew(plan.name);
|
||||
plan.last_stored_time = this.currentRunHistory.uuid;
|
||||
plan.requestSelfSave();
|
||||
break;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createNew(associatedName?: string) {
|
||||
const uuid = uuidv4();
|
||||
|
||||
return new RunHistory({
|
||||
uuid,
|
||||
associated_name: associatedName || 'Unnamed',
|
||||
current_elapsed_millis: 0,
|
||||
last_updated: Date.now(),
|
||||
entries: [],
|
||||
});
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
Resume instantaneously, on next zone enter or not at all?
|
||||
You have an ongoing run history going, would you like to resume it?
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button color="color-resume-instant" (click)="instant()">Instant</button>
|
||||
<button mat-button color="color-resume-next" (click)="next()" cdkFocusInitial>Next zone</button>
|
||||
<button mat-button color="color-resume-no" (click)="discard()" cdkFocusInitial>Not at all</button>
|
||||
<button mat-raised-button color="color-resume-instant" (click)="instant()">Resume instantaneously</button>
|
||||
<button mat-raised-button color="color-resume-next" (click)="next()" cdkFocusInitial>Resume on entering next zone</button>
|
||||
<button mat-raised-button color="color-resume-no" (click)="discard()" cdkFocusInitial>Start new</button>
|
||||
</div>
|
Loading…
Reference in new issue