Compare commits
31 Commits
merge-note
...
main
Author | SHA1 | Date |
---|---|---|
|
d3ef66e498 | 11 months ago |
|
fa8c21e16c | 1 year ago |
|
66a738389c | 1 year ago |
|
3bbb50123a | 1 year ago |
|
84695203ea | 1 year ago |
|
e1cdbd566a | 1 year ago |
|
cc96e42af0 | 1 year ago |
|
67f43d4b7f | 1 year ago |
|
5730861acf | 1 year ago |
|
9cf16ab3df | 1 year ago |
|
5c2d03272f | 1 year ago |
|
7c3e739f8d | 1 year ago |
|
a8443964bc | 1 year ago |
|
cd042d99ca | 1 year ago |
|
3272c3bda2 | 1 year ago |
|
5db8663819 | 1 year ago |
|
fda12b2f90 | 1 year ago |
|
b1550d68e5 | 1 year ago |
|
32a2424783 | 1 year ago |
|
54fd9ca62e | 1 year ago |
|
9b22ad942a | 1 year ago |
|
7b1fbe01df | 1 year ago |
|
4f30d8b0ba | 1 year ago |
|
a1249b46ab | 1 year ago |
|
6282f12ce9 | 1 year ago |
|
033b061fb6 | 1 year ago |
|
48d1dda124 | 1 year ago |
|
21333908ec | 1 year ago |
|
a8334cbd03 | 2 years ago |
|
b10619182e | 2 years ago |
|
d1d64b7960 | 2 years ago |
@ -0,0 +1,47 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": [
|
||||
"projects/**/*"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "app",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "app",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/template/recommended",
|
||||
"plugin:@angular-eslint/template/accessibility"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -0,0 +1,52 @@
|
||||
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,
|
||||
CheckpointReached
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
@ -1,90 +1,162 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { Observable, ReplaySubject, Subject, from, map, tap } from 'rxjs';
|
||||
import { Plan, PlanInterface } from '../_models/plan';
|
||||
import { EMPTY, Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs';
|
||||
import { Plan, PlanInterface, PlanMetadata } from '../_models/plan';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { UrlDialog } from '../plan-display/url-dialog.component';
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import { Response } from '@tauri-apps/api/http';
|
||||
|
||||
export class UrlError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PlanService {
|
||||
|
||||
currentPlan?: Plan;
|
||||
planStore: string[] = [];
|
||||
basePlan?: Plan;
|
||||
private basePlanSubj: Subject<Plan> = new ReplaySubject<Plan>(1);
|
||||
private _currentPlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
|
||||
private _basePlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
|
||||
private _storedPlansSubject: Subject<PlanMetadata[]> = new ReplaySubject<PlanMetadata[]>(1);
|
||||
|
||||
|
||||
|
||||
constructor() {
|
||||
this.getPreviousPlans();
|
||||
constructor(private dialog: MatDialog) {
|
||||
this.loadBasePlan();
|
||||
this.loadStoredPlans();
|
||||
}
|
||||
|
||||
public getBasePlan(): Observable<Plan> {
|
||||
return this._basePlanSubject.asObservable();
|
||||
}
|
||||
|
||||
loadPlan(path: string) {
|
||||
return from(invoke<PlanInterface>('load_plan', { path })).pipe(
|
||||
map(plan => {
|
||||
this.currentPlan = new Plan(plan);
|
||||
return plan
|
||||
})
|
||||
);
|
||||
public getCurrentPlan(): Observable<Plan> {
|
||||
return this._currentPlanSubject.asObservable();
|
||||
}
|
||||
|
||||
loadPlanNoSave(path: string) {
|
||||
return from(invoke<PlanInterface>('load_plan', { path })).pipe(
|
||||
map(plan => {
|
||||
this.currentPlan = new Plan(plan);
|
||||
this.currentPlan.setPath(path);
|
||||
return plan
|
||||
})
|
||||
);
|
||||
public setCurrentPlan(plan: Plan) {
|
||||
this._currentPlanSubject.next(plan);
|
||||
}
|
||||
|
||||
|
||||
public getStoredPlans(): Observable<PlanMetadata[]> {
|
||||
return this._storedPlansSubject.asObservable();
|
||||
}
|
||||
|
||||
loadBasePlan() {
|
||||
if(!this.basePlan) {
|
||||
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
|
||||
plan.plan.forEach(elem => {elem.edited = false;});
|
||||
this.basePlan = new Plan(plan);
|
||||
this.basePlanSubj?.next(this.basePlan);
|
||||
public loadPlanFromPath(path: string, save_local: boolean = true): Observable<Plan> {
|
||||
return from(invoke<PlanInterface>('load_plan_at_path', { path, saveLocal: save_local })).pipe(map(plan => {
|
||||
return new Plan(plan)
|
||||
})).pipe(tap(() => {
|
||||
this.loadStoredPlans();
|
||||
}));
|
||||
}
|
||||
|
||||
public loadFromUrl(url?: string, name?: string, save_local: boolean = false): Observable<Plan> {
|
||||
if (!url || !name) {
|
||||
const dialogRef = this.dialog.open(UrlDialog, {
|
||||
data: {
|
||||
url: url,
|
||||
name: name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return this.basePlanSubj!.asObservable();
|
||||
return dialogRef.afterClosed().pipe(switchMap(data => {
|
||||
if (data.url) {
|
||||
return this._loadFromUrl(data.url, data.name, save_local);
|
||||
}
|
||||
|
||||
return new Observable<Plan>((s) => s.complete());
|
||||
}));
|
||||
|
||||
} else {
|
||||
return this._loadFromUrl(url, name, save_local);
|
||||
}
|
||||
}
|
||||
|
||||
savePlan(path: string, plan: Plan) {
|
||||
public savePlanAtPath(path: string, plan: Plan) {
|
||||
plan.plan.forEach(elem => {
|
||||
if (!elem.notes) { elem.notes = "" }
|
||||
});
|
||||
|
||||
return from(invoke<boolean>('save_plan', {
|
||||
path,
|
||||
plan: {
|
||||
plan: plan.plan,
|
||||
current: plan.current
|
||||
},
|
||||
})).subscribe(status => {
|
||||
});
|
||||
return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() }));
|
||||
}
|
||||
|
||||
getPreviousPlans() {
|
||||
from(invoke<string[]>('load_stored_plans')).subscribe(plans => this.planStore = plans);
|
||||
public checkForPlanUpdate(plan: Plan | PlanMetadata): Observable<Plan> {
|
||||
if (!plan.update_url) return EMPTY;
|
||||
|
||||
return from(fetch(
|
||||
plan.update_url,
|
||||
{
|
||||
method: 'HEAD',
|
||||
timeout: 10
|
||||
})).pipe(switchMap(response => {
|
||||
this.validateResponse(response);
|
||||
|
||||
if (response.headers['etag'] === plan.latest_server_etag) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this._loadFromUrl(plan.update_url!, plan.name!, false);
|
||||
}));
|
||||
}
|
||||
|
||||
loadPrevious(name: string) {
|
||||
return from(invoke<PlanInterface>('load_previous', { name })).pipe(tap(plan => {
|
||||
console.log("previous loaded: ", plan);
|
||||
this.currentPlan = new Plan(plan);
|
||||
this.currentPlan.setPrevious(name);
|
||||
public savePlanAtStore(name: string, plan: Plan, allowOverwrite: boolean = false) {
|
||||
return from(invoke<string>('save_plan_at_store', { name, plan: plan.toInterface(), allowOverwrite })).pipe(tap(() => {
|
||||
this.loadStoredPlans();
|
||||
}));
|
||||
}
|
||||
|
||||
zoneFromUuid(uuid: string) {
|
||||
if (!this.basePlan) {
|
||||
return undefined;
|
||||
private _loadFromUrl(url: string, name: string, save_local: boolean): Observable<Plan> {
|
||||
//Tauri fetch
|
||||
return from(fetch(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
timeout: 10
|
||||
})).pipe(map(response => {
|
||||
this.validateResponse(response);
|
||||
|
||||
const plan = new Plan(response.data as PlanInterface);
|
||||
const etag = response.headers['etag'];
|
||||
if (etag) {
|
||||
plan.latest_server_etag = etag;
|
||||
console.log("got etag: ", etag);
|
||||
}
|
||||
|
||||
return plan;
|
||||
})).pipe(tap(plan => {
|
||||
plan.update_url = url;
|
||||
plan.name = name;
|
||||
|
||||
if (save_local) {
|
||||
this.savePlanAtStore(name, plan).subscribe();
|
||||
}
|
||||
|
||||
this.loadStoredPlans();
|
||||
}));
|
||||
}
|
||||
|
||||
private validateResponse(response: Response<unknown>) {
|
||||
if (!response.ok) {
|
||||
throw new UrlError(response.status, "Error fetching plan from URL, status " + response.status + ", URL: " + response.url + " with body:" + response.data);
|
||||
}
|
||||
}
|
||||
|
||||
private loadBasePlan() {
|
||||
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
|
||||
plan.plan.forEach(elem => { elem.edited = false; });
|
||||
plan.current = 0;
|
||||
plan.name = "Base Plan";
|
||||
this._basePlanSubject?.next(new Plan(plan));
|
||||
});
|
||||
}
|
||||
|
||||
return this.basePlan.plan.find(elem => elem.uuid === uuid);
|
||||
private loadStoredPlans() {
|
||||
from(invoke<PlanMetadata[]>('enumerate_stored_plans')).subscribe(plans => {
|
||||
this._storedPlansSubject.next(plans);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { EntryType, RunHistory, TrackEntry } from './time-tracker.service';
|
||||
import { Plan } from '../_models/plan';
|
||||
|
||||
|
||||
export interface RunStat {
|
||||
zoneName: string;
|
||||
|
||||
entryTime: string;
|
||||
estimatedExit: string;
|
||||
estimatedTimeSpent: string;
|
||||
}
|
||||
|
||||
export interface AggregateRunStat {
|
||||
zoneName: string;
|
||||
|
||||
aggregateFirstEntry: string;
|
||||
aggregateLastExit: string;
|
||||
aggregateTimeSpent: string;
|
||||
aggregateNumEntries: string;
|
||||
}
|
||||
|
||||
export interface UnformattedAggregateRunStat {
|
||||
zoneId: string;
|
||||
|
||||
aggregateFirstEntry: number;
|
||||
aggregateLastExit: number;
|
||||
aggregateTimeSpent: number;
|
||||
aggregateNumEntries: number;
|
||||
}
|
||||
|
||||
export interface UnformattedAggregationData {
|
||||
aggregation: UnformattedAggregateRunStat[];
|
||||
aggregateNAId: string;
|
||||
}
|
||||
|
||||
export interface UnformattedRunStat {
|
||||
zoneId: string;
|
||||
|
||||
entryTime: number;
|
||||
estimatedExit?: number;
|
||||
estimatedTimeSpent?: number;
|
||||
|
||||
entryType: EntryType;
|
||||
}
|
||||
|
||||
export type RunStatType = RunStat | AggregateRunStat;
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RunStatService {
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
calcAggregated(data: RunHistory): UnformattedAggregationData {
|
||||
const aggregation = new Map<string, UnformattedAggregateRunStat>();
|
||||
|
||||
data.entries.forEach((entry, index) => {
|
||||
const hasExit = !(data.entries.length - 1 === index);
|
||||
|
||||
let aggregate: UnformattedAggregateRunStat = {
|
||||
zoneId: entry.zone,
|
||||
aggregateFirstEntry: entry.current_elapsed_millis,
|
||||
aggregateLastExit: hasExit ? data.entries[index + 1].current_elapsed_millis : 0,
|
||||
aggregateTimeSpent: hasExit ? (data.entries[index + 1].current_elapsed_millis - data.entries[index].current_elapsed_millis) : 0,
|
||||
aggregateNumEntries: 1,
|
||||
}
|
||||
|
||||
const existing = aggregation.get(entry.zone);
|
||||
if (existing) {
|
||||
existing.aggregateLastExit = aggregate.aggregateLastExit;
|
||||
existing.aggregateTimeSpent += aggregate.aggregateTimeSpent;
|
||||
existing.aggregateNumEntries++;
|
||||
}
|
||||
|
||||
aggregation.set(entry.zone, existing ?? aggregate);
|
||||
|
||||
});
|
||||
|
||||
return {
|
||||
aggregation: Array.from(aggregation.values()),
|
||||
aggregateNAId: data.entries.length > 0 ? data.entries[data.entries.length - 1].zone : "",
|
||||
};
|
||||
}
|
||||
|
||||
calcDirect(data: RunHistory): UnformattedRunStat[] {
|
||||
return data.entries.map((entry, index) => {
|
||||
const hasExit = !(data.entries.length - 1 === index);
|
||||
return {
|
||||
zoneId: entry.zone,
|
||||
entryTime: entry.current_elapsed_millis,
|
||||
estimatedExit: hasExit ? data.entries[index + 1].current_elapsed_millis : undefined,
|
||||
estimatedTimeSpent: hasExit ? (data.entries[index + 1].current_elapsed_millis - data.entries[index].current_elapsed_millis) : undefined,
|
||||
entryType: entry.type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
insertTimesAtCheckpoints(history: RunHistory, plan: Plan) {
|
||||
const data = this.calcDirect(history);
|
||||
const checkPointEntries = new Map(data.filter(entry => entry.entryType === EntryType.CheckpointReached).map(entry => [entry.zoneId, entry.entryTime]));
|
||||
plan.plan.forEach(elem => {
|
||||
if (checkPointEntries.has(elem.uuid!)) {
|
||||
elem.checkpoint_millis = checkPointEntries.get(elem.uuid!);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,45 +1,52 @@
|
||||
import { Injectable, NgZone } from '@angular/core';
|
||||
import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut';
|
||||
import { EMPTY, from } from 'rxjs';
|
||||
import { Observable, Subscriber, from } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ShortcutService {
|
||||
bound: Map<ShortcutHandler, string> = new Map<ShortcutHandler, string>();
|
||||
private internalHandlers: Map<string, [ShortcutHandler, Subscriber<string>, () => void]> = new Map<string, [ShortcutHandler, Subscriber<string>, () => void]>();
|
||||
|
||||
constructor(private zone: NgZone) {
|
||||
}
|
||||
register(shortcut: string, handler: ShortcutHandler) {
|
||||
this.bound.set(handler, shortcut);
|
||||
constructor(private zone: NgZone) { }
|
||||
|
||||
return from(register(shortcut, (s) => {
|
||||
this.zone.run(() => handler(s));
|
||||
}));
|
||||
}
|
||||
register(shortcut: string) {
|
||||
return new Observable<string>((subscriber) => {
|
||||
|
||||
unregister(handler: ShortcutHandler) {
|
||||
const shortcut = this.bound.get(handler);
|
||||
this.bound.delete(handler);
|
||||
let originalHandler: ShortcutHandler = (s) => this.zone.run(() => subscriber.next(s));
|
||||
|
||||
return shortcut ? from(unregister(shortcut)) : EMPTY;
|
||||
}
|
||||
const teardown = () => {
|
||||
unregister(shortcut);
|
||||
this.internalHandlers.delete(shortcut);
|
||||
};
|
||||
|
||||
rebind(shortcut: string, handler: ShortcutHandler) {
|
||||
const prevShortcut = this.bound.get(handler);
|
||||
this.register(shortcut, handler).subscribe(
|
||||
{
|
||||
error: (_err) => {
|
||||
if (prevShortcut) {
|
||||
this.register(prevShortcut, handler);
|
||||
}
|
||||
return EMPTY;
|
||||
}
|
||||
});
|
||||
this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]);
|
||||
register(shortcut, originalHandler).catch(e => subscriber.error(e));
|
||||
|
||||
return teardown;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
rebind_from_to(previousShortcut: string, nextShortcut: string) {
|
||||
const oldHandler = [...this.bound.entries()].find((entry: [ShortcutHandler, string]) => entry[1] === previousShortcut)?.[0];
|
||||
this.rebind(nextShortcut, oldHandler!);
|
||||
let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!;
|
||||
|
||||
subscriber.remove(teardown);
|
||||
teardown();
|
||||
|
||||
teardown = () => {
|
||||
unregister(nextShortcut);
|
||||
this.internalHandlers.delete(nextShortcut);
|
||||
};
|
||||
|
||||
register(nextShortcut, oldHandler);
|
||||
|
||||
this.internalHandlers.set(nextShortcut, [oldHandler, subscriber, teardown]);
|
||||
}
|
||||
|
||||
|
||||
///No safety checks
|
||||
force_unbind(shortcut: string) {
|
||||
return from(unregister(shortcut));
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,382 @@
|
||||
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}`;
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
<div *ngIf="latestEntry && timeTrackerService.isActive" class="d-flex flex-column">
|
||||
<div class="zone"><b>{{resolveZone(latestEntry.zone)}}</b></div>
|
||||
<div class="d-flex flex-row">
|
||||
Spent:
|
||||
<app-wrap-value [value]="{current: currentSpent(), compare: compareSpent()}">
|
||||
<ng-template let-v>
|
||||
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current | hms}}</span>
|
||||
<span>{{v.compare | hms}}</span>
|
||||
</ng-template>
|
||||
</app-wrap-value>
|
||||
</div>
|
||||
<div class="d-flex flex-row">
|
||||
First Entry:
|
||||
<app-wrap-value [value]="{current: currentFirst(), compare: compareFirst()}">
|
||||
<ng-template let-v>
|
||||
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current | hms}}</span>
|
||||
<span>{{v.compare | hms}}</span>
|
||||
</ng-template>
|
||||
</app-wrap-value>
|
||||
</div>
|
||||
<div class="d-flex flex-row">
|
||||
Last Entry:
|
||||
<app-wrap-value [value]="{current: currentLast(), compare: compareLast()}">
|
||||
<ng-template let-v>
|
||||
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current | hms}}</span>
|
||||
<span>{{v.compare | hms}}</span>
|
||||
</ng-template>
|
||||
</app-wrap-value>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="d-flex flex-row">
|
||||
Number of Entries:
|
||||
<app-wrap-value [value]="{current: currentEntries(), compare: compareEntries()}">
|
||||
<ng-template let-v>
|
||||
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current}}</span>
|
||||
<span>{{v.compare}}</span>
|
||||
</ng-template>
|
||||
</app-wrap-value>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<ng-container *ngIf="!latestEntry">
|
||||
Awaiting zone entry to compare data
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="latestEntry && !timeTrackerService.isActive">
|
||||
Awaiting stopwatch to start
|
||||
</ng-container>
|
@ -0,0 +1,9 @@
|
||||
.indeterminate {
|
||||
color: orange;
|
||||
}
|
||||
.good-diff {
|
||||
color: rgb(46, 179, 46);
|
||||
}
|
||||
.bad-diff {
|
||||
color: rgb(199, 13, 13);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AggregateDisplayComponent } from './aggregate-display.component';
|
||||
|
||||
describe('AggregateDisplayComponent', () => {
|
||||
let component: AggregateDisplayComponent;
|
||||
let fixture: ComponentFixture<AggregateDisplayComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AggregateDisplayComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(AggregateDisplayComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,155 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PlanService } from '../_services/plan.service';
|
||||
import { EntryType, RunHistory, TimeTrackerService, TrackEntry } from '../_services/time-tracker.service';
|
||||
import { Plan } from '../_models/plan';
|
||||
import { ConfigService } from '../_services/config.service';
|
||||
import { RunStatService, UnformattedAggregateRunStat, UnformattedAggregationData } from '../_services/run-stat.service';
|
||||
import { WorldArea } from '../_models/world-area';
|
||||
import { WorldAreaService } from '../_services/world-area.service';
|
||||
import { HmsPipe } from "../hms/hms.pipe";
|
||||
import { WrapValueComponent } from '../wrap-value/wrap-value.component';
|
||||
import { timer } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-aggregate-display',
|
||||
standalone: true,
|
||||
templateUrl: './aggregate-display.component.html',
|
||||
styleUrls: ['./aggregate-display.component.scss'],
|
||||
imports: [CommonModule, HmsPipe, WrapValueComponent]
|
||||
})
|
||||
export class AggregateDisplayComponent {
|
||||
latestEntry?: TrackEntry;
|
||||
private worldAreaMap?: Map<string, WorldArea>;
|
||||
private currentRunHistory?: RunHistory;
|
||||
|
||||
private compareAggregate?: Map<string, UnformattedAggregateRunStat>;
|
||||
private currentAggregate?: Map<string, UnformattedAggregateRunStat>;
|
||||
|
||||
constructor(
|
||||
public timeTrackerService: TimeTrackerService,
|
||||
private configService: ConfigService,
|
||||
private runStatService: RunStatService,
|
||||
private worldAreaService: WorldAreaService,
|
||||
) {
|
||||
this.timeTrackerService.getCurrentRunHistory().subscribe(history => {
|
||||
this.onNewCurrent(history);
|
||||
});
|
||||
|
||||
this.worldAreaService.getFullWorldAreas().subscribe((data) => {
|
||||
this.worldAreaMap = data;
|
||||
})
|
||||
|
||||
this.timeTrackerService.getLatestEntry().subscribe(entry => {
|
||||
if (entry.type != EntryType.ZoneEnter) return;
|
||||
|
||||
if (this.latestEntry) {
|
||||
this.expandCurrentAggregated(this.latestEntry, entry);
|
||||
}
|
||||
this.latestEntry = entry;
|
||||
});
|
||||
}
|
||||
|
||||
private onNewCurrent(history: RunHistory) {
|
||||
this.currentRunHistory = history;
|
||||
this.loadComparisonData();
|
||||
const aggregatedRunHistory = this.runStatService.calcAggregated(history);
|
||||
this.currentAggregate = new Map(aggregatedRunHistory.aggregation.map(agg => [agg.zoneId, agg]));
|
||||
if (history.entries.length > 0) {
|
||||
this.latestEntry = history.entries[history.entries.length - 1];
|
||||
} else {
|
||||
this.latestEntry = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private loadComparisonData() {
|
||||
if (!this.configService.config.runCompareHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeTrackerService.loadHistory(this.configService.config.runCompareHistory).subscribe(history => {
|
||||
if (history) {
|
||||
this.compareAggregate = new Map<string, UnformattedAggregateRunStat>(
|
||||
this.runStatService.calcAggregated(history).aggregation.map(agg => [agg.zoneId, agg])
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private expandCurrentAggregated(oldEntry: TrackEntry, newEntry: TrackEntry) {
|
||||
if (!this.currentAggregate) return;
|
||||
|
||||
//Expand with old entry
|
||||
let aggregate: UnformattedAggregateRunStat = {
|
||||
zoneId: oldEntry.zone,
|
||||
aggregateFirstEntry: oldEntry.current_elapsed_millis,
|
||||
aggregateLastExit: newEntry.current_elapsed_millis,
|
||||
aggregateTimeSpent: (newEntry.current_elapsed_millis - oldEntry.current_elapsed_millis),
|
||||
aggregateNumEntries: 1,
|
||||
}
|
||||
|
||||
const existing = this.currentAggregate.get(oldEntry.zone);
|
||||
if (existing) {
|
||||
existing.aggregateLastExit = aggregate.aggregateLastExit;
|
||||
existing.aggregateTimeSpent += aggregate.aggregateTimeSpent;
|
||||
existing.aggregateNumEntries++;
|
||||
}
|
||||
|
||||
this.currentAggregate.set(aggregate.zoneId, existing ?? aggregate);
|
||||
}
|
||||
|
||||
hms(value?: number) {
|
||||
if (value) {
|
||||
return this.timeTrackerService.hmsTimestamp(value);
|
||||
} else {
|
||||
return "N/A";
|
||||
}
|
||||
}
|
||||
|
||||
resolveZone(zoneId: string) {
|
||||
const area = this.worldAreaMap?.get(zoneId);
|
||||
if (!area) {
|
||||
return "Unknown zone: " + zoneId;
|
||||
}
|
||||
|
||||
// Act might not be very reasonable but it's the best we have it..
|
||||
return area.name + " (A" + area.act + ")"
|
||||
}
|
||||
|
||||
classCurrentLowBetter(current?: number, compare?: number) {
|
||||
if (compare == undefined || compare == null || current == undefined || current == null) return "indeterminate";
|
||||
|
||||
return current <= compare ? "good-diff" : "bad-diff";
|
||||
}
|
||||
|
||||
currentSpent() {
|
||||
return (this.currentAggregate?.get(this.latestEntry!.zone)?.aggregateTimeSpent ?? 0) + (this.currentRunHistory!.currentElapsedMillis - this.latestEntry!.current_elapsed_millis);
|
||||
}
|
||||
|
||||
compareSpent() {
|
||||
return this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateTimeSpent;
|
||||
}
|
||||
|
||||
compareEntries() {
|
||||
return this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateNumEntries ?? 0;
|
||||
}
|
||||
currentEntries() {
|
||||
return (this.currentAggregate?.get(this.latestEntry!.zone)?.aggregateNumEntries ?? 0) + 1;
|
||||
}
|
||||
|
||||
compareLast() {
|
||||
return this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateLastExit;
|
||||
}
|
||||
currentLast() {
|
||||
return this.latestEntry?.current_elapsed_millis ?? 0;
|
||||
}
|
||||
|
||||
compareFirst() {
|
||||
return (this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateFirstEntry) ?? 0;
|
||||
}
|
||||
|
||||
currentFirst() {
|
||||
return (this.currentAggregate?.get(this.latestEntry!.zone)?.aggregateFirstEntry) ?? this.latestEntry!.current_elapsed_millis;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
<ng-container *ngIf="init">
|
||||
<ng-container *ngIf="rect">
|
||||
<div class="target" [style.transform]="transform()" [style.width]="rect.width + 'px'"
|
||||
[style.height]="rect.height + 'px'"
|
||||
[style.background-color]="backgroundColor ? backgroundColor : 'rgba(0, 0, 0, 0.1)'" #targetRef>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
<ngx-moveable #moveable [target]="targetRef" [draggable]="configurable" [resizable]="configurable"
|
||||
(drag)="onDrag($event)" (resize)="onResize($event)" (dragEnd)="onDragEnd($event)"
|
||||
(resizeEnd)="onResizeEnd($event)" [bounds]="bounds" [snappable]="true"
|
||||
[style.visibility]="configurable ? 'visible' : 'hidden'">
|
||||
</ngx-moveable>
|
||||
</ng-container>
|
||||
</ng-container>
|
@ -0,0 +1,9 @@
|
||||
:host {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.target {
|
||||
position: absolute;
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DraggableWindowComponent } from './draggable-window.component';
|
||||
|
||||
describe('DraggableWindowComponent', () => {
|
||||
let component: DraggableWindowComponent;
|
||||
let fixture: ComponentFixture<DraggableWindowComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [DraggableWindowComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(DraggableWindowComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,88 @@
|
||||
import { AfterViewInit, ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { Rect } from '../_models/generated/Rect';
|
||||
import { NgxMoveableComponent, NgxMoveableModule, OnDrag, OnDragEnd, OnResize, OnResizeEnd } from 'ngx-moveable';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-draggable-window',
|
||||
templateUrl: './draggable-window.component.html',
|
||||
styleUrls: ['./draggable-window.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgxMoveableModule]
|
||||
|
||||
})
|
||||
export class DraggableWindowComponent implements AfterViewInit {
|
||||
rect?: Rect;
|
||||
bounds: any = { "left": 0, "top": 0, "right": 0, "bottom": 0, "position": "css" };
|
||||
@Input() public backgroundColor: string = "rgba(0, 0, 0, 0.1)";
|
||||
@Input() public configurable: boolean = true;
|
||||
@Input() public initialRect?: Rect;
|
||||
@Output() savedRect: EventEmitter<Rect> = new EventEmitter();
|
||||
@ViewChild("moveable") moveable?: NgxMoveableComponent;
|
||||
init = false;
|
||||
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef, private zone: NgZone) {
|
||||
window.addEventListener("resize", () => {
|
||||
this.zone.run(() => {
|
||||
this.windowInitHandler()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
windowInitHandler() {
|
||||
if (window.innerWidth > 0) {
|
||||
const cfgRect = this.initialRect || { x: 0, y: 0, width: 0.1, height: 0.1 };
|
||||
|
||||
this.rect = {
|
||||
x: cfgRect.x * window.innerWidth,
|
||||
y: cfgRect.y * window.innerHeight,
|
||||
width: cfgRect.width * window.innerWidth,
|
||||
height: cfgRect.height * window.innerHeight,
|
||||
}
|
||||
|
||||
this.moveable?.updateRect();
|
||||
|
||||
setTimeout(() => this.cdr.detectChanges(), 0);
|
||||
this.init = true;
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.windowInitHandler();
|
||||
}
|
||||
|
||||
transform() {
|
||||
return `translate(${this.rect!.x}px, ${this.rect!.y}px)`;
|
||||
}
|
||||
|
||||
onDrag(e: OnDrag) {
|
||||
this.rect!.x = e.translate[0];
|
||||
this.rect!.y = e.translate[1];
|
||||
}
|
||||
|
||||
onDragEnd(_e: OnDragEnd) {
|
||||
this.saveRect();
|
||||
}
|
||||
|
||||
onResize(e: OnResize) {
|
||||
this.rect!.width = e.width;
|
||||
this.rect!.height = e.height;
|
||||
this.onDrag(e.drag);
|
||||
}
|
||||
|
||||
onResizeEnd(_e: OnResizeEnd) {
|
||||
this.saveRect();
|
||||
}
|
||||
|
||||
saveRect() {
|
||||
if (this.rect) {
|
||||
this.savedRect.emit({
|
||||
x: this.rect.x / window.innerWidth,
|
||||
y: this.rect.y / window.innerHeight,
|
||||
width: this.rect.width / window.innerWidth,
|
||||
height: this.rect.height / window.innerHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +1,15 @@
|
||||
<div class="NOTES_COMPONENT">
|
||||
<div class="container">
|
||||
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
|
||||
<div class="left">
|
||||
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
|
||||
</div>
|
||||
|
||||
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
|
||||
<div class="right">
|
||||
<notes [note]="note"></notes>
|
||||
</div>
|
||||
<div class="container">
|
||||
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
|
||||
<div>
|
||||
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button color="warn" (click)="cancel()">Cancel</button>
|
||||
<button mat-button [mat-dialog-close]="note" cdkFocusInitial>Save</button>
|
||||
|
||||
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
|
||||
<div class="w-100">
|
||||
<div class="display-component" [innerHTML]="md.render(note ?? '')"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button color="warn" (click)="cancel()">Cancel</button>
|
||||
<button mat-button (click)="save()" cdkFocusInitial>Save</button>
|
||||
</div>
|
@ -0,0 +1,24 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 700px;
|
||||
width: 500px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.display-component {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
|
||||
width: 100%;
|
||||
}
|
@ -1 +1 @@
|
||||
<div #ref *ngIf="note" class="NOTES_COMPONENT display-component" [innerHTML]="md.render(note)"></div>
|
||||
<div #ref *ngIf="note" class="display-component" [innerHTML]="md.render(this.note)"></div>
|
@ -1,30 +1,30 @@
|
||||
.NOTES_COMPONENT {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 800px;
|
||||
width: 1200px;
|
||||
}
|
||||
|
||||
&.display-component {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.display-component {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
min-height: 400px;
|
||||
}
|
||||
.note-preview {
|
||||
min-height: 400px;
|
||||
max-height: 400px;
|
||||
min-width: 600px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
@ -0,0 +1,12 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { hmsTimestamp } from '../_services/time-tracker.service';
|
||||
|
||||
@Pipe({
|
||||
name: 'hms',
|
||||
standalone: true
|
||||
})
|
||||
export class HmsPipe implements PipeTransform {
|
||||
transform(value?: number): string {
|
||||
return hmsTimestamp(value);
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Rect } from "./Rect";
|
||||
|
||||
export interface Config { initialPlanWindowPosition: Rect, hideOnUnfocus: boolean, toggleOverlay: string, prev: string, next: string, planBg: string, backdropBg: string, noteDefaultFg: string, poeClientLogPath: string | null, }
|
@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface Rect { x: number, y: number, width: number, height: number, }
|
@ -0,0 +1,37 @@
|
||||
<div class="d-flex justify-content-evenly align-items-center mb-2 mt-2">
|
||||
<div class="col-xs-4">
|
||||
<button class="" mat-raised-button color="accent" (click)="openDialog()">Browse
|
||||
Plans
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<button class="" mat-raised-button color="accent" (click)="loadBasePlan()">
|
||||
Load base plan
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-xs-4">
|
||||
<button class="" mat-raised-button color="accent" (click)="loadFromUrl()">
|
||||
Import from url
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enumerated d-flex flex-column">
|
||||
<mat-list role="list" class="d-flex flex-column h-100">
|
||||
<cdk-virtual-scroll-viewport itemSize="10" class="h-100">
|
||||
<mat-list-item class="d-flex flex-column" role="listitem" *cdkVirtualFor="let plan of plans">
|
||||
<span>
|
||||
<button [disabled]="disablePlans" (click)="loadPrevious(plan.stored_path!)">
|
||||
<img *ngIf="plan.update_url" src="assets/public.svg">{{plan.name}}
|
||||
</button><button *ngIf="plan.update_url" class="planActionButton" [disabled]="disablePlans"
|
||||
(click)="checkForPlanUpdate(plan)" [title]="recentPlanTitle(plan)">
|
||||
<svg-icon *ngIf="!hasUpdate(plan)" src="assets/material-sync.svg" />
|
||||
<svg-icon *ngIf="hasUpdate(plan) && !isErr(plan)" src="assets/material-check.svg"
|
||||
class="nice" />
|
||||
<svg-icon *ngIf="hasUpdate(plan) && isErr(plan)" src="assets/material-warning.svg"
|
||||
class="notnice" />
|
||||
</button>
|
||||
</span>
|
||||
</mat-list-item>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</mat-list>
|
||||
</div>
|
@ -0,0 +1,34 @@
|
||||
.planChooser {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.enumerated {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
background-color: rgba(200, 200, 220, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
|
||||
mat-list {
|
||||
max-height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.nice {
|
||||
svg {
|
||||
fill: green;
|
||||
}
|
||||
}
|
||||
|
||||
.notnice {
|
||||
svg {
|
||||
fill: red;
|
||||
}
|
||||
}
|
||||
|
||||
.planActionButton {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 1px;
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PlanSelectionComponent } from './plan-selection.component';
|
||||
|
||||
describe('PlanSelectionComponent', () => {
|
||||
let component: PlanSelectionComponent;
|
||||
let fixture: ComponentFixture<PlanSelectionComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [PlanSelectionComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(PlanSelectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,143 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Plan, PlanMetadata } from 'src/app/_models/plan';
|
||||
import { PlanService, UrlError } from 'src/app/_services/plan.service';
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { from } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { AngularSvgIconModule } from 'angular-svg-icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plan-selection',
|
||||
templateUrl: './plan-selection.component.html',
|
||||
styleUrls: ['./plan-selection.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatButtonModule, ScrollingModule, MatListModule, AngularSvgIconModule]
|
||||
})
|
||||
export class PlanSelectionComponent {
|
||||
plans: PlanMetadata[] = [];
|
||||
checkingPlanUpdate: boolean = false;
|
||||
|
||||
recentUpdateAttempts: Map<string, Date | UrlError> = new Map<string, Date | UrlError>();
|
||||
constructor(
|
||||
private planService: PlanService,
|
||||
) {
|
||||
this.planService.getStoredPlans().subscribe(plans => {
|
||||
this.plans = plans;
|
||||
})
|
||||
}
|
||||
|
||||
get disablePlans(): boolean {
|
||||
return this.checkingPlanUpdate;
|
||||
}
|
||||
|
||||
openDialog() {
|
||||
from(open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "JSON (.json)",
|
||||
extensions: ['json']
|
||||
}
|
||||
]
|
||||
})).subscribe(file => {
|
||||
if (file) {
|
||||
this.planService.loadPlanFromPath(file as string).subscribe(plan => {
|
||||
if (plan) {
|
||||
this.planService.setCurrentPlan(plan);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadBasePlan() {
|
||||
this.planService.getBasePlan().subscribe(plan => {
|
||||
plan.current = 0;
|
||||
this.planService.setCurrentPlan(plan);
|
||||
});
|
||||
}
|
||||
|
||||
loadFromUrl() {
|
||||
this.planService.loadFromUrl().subscribe(plan => {
|
||||
if (plan) {
|
||||
this.planService.savePlanAtStore(plan.name!, plan).subscribe((path) => {
|
||||
if (path) {
|
||||
plan.setPath(path);
|
||||
}
|
||||
});
|
||||
this.planService.setCurrentPlan(plan);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadPrevious(path: string) {
|
||||
this.planService.loadPlanFromPath(path, false).subscribe(plan => {
|
||||
this.planService.setCurrentPlan(plan);
|
||||
});
|
||||
}
|
||||
|
||||
checkForPlanUpdate(plan: PlanMetadata) {
|
||||
this.checkingPlanUpdate = true;
|
||||
|
||||
this.planService.checkForPlanUpdate(plan).subscribe({
|
||||
next: updatedPlan => {
|
||||
console.log("check update: ", updatedPlan);
|
||||
this.planService.loadPlanFromPath(plan.stored_path!, false).subscribe(oldPlan => {
|
||||
console.log("oldPlan", oldPlan);
|
||||
const newCurrent = this.commonUUIDToNewCurrent(oldPlan, updatedPlan);
|
||||
updatedPlan.current = newCurrent;
|
||||
//TODO: Interface to determine if we want to save the plan or not (allow not doing it in case user regrets... Also needs position error fix).
|
||||
this.planService.savePlanAtStore(oldPlan.name!, updatedPlan, true).subscribe();
|
||||
})
|
||||
},
|
||||
complete: () => {
|
||||
this.checkingPlanUpdate = false
|
||||
this.recentUpdateAttempts.set(plan.update_url!, new Date());
|
||||
},
|
||||
error: (err) => {
|
||||
this.recentUpdateAttempts.set(plan.update_url!, err);
|
||||
if (err instanceof UrlError) {
|
||||
alert("Error retrieving plan update (" + err.status + "): " + err.message);
|
||||
} else if (err instanceof Error) {
|
||||
alert("Unexpected error: " + err.message);
|
||||
}
|
||||
|
||||
this.checkingPlanUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recentPlanTitle(plan: PlanMetadata) {
|
||||
if (!this.hasUpdate(plan)) return "Check for update";
|
||||
if (this.hasUpdate(plan) && !this.isErr(plan)) return "Up to date";
|
||||
if (this.hasUpdate(plan) && this.isErr(plan)) return "Error updating";
|
||||
return "";
|
||||
}
|
||||
|
||||
isErr(plan: PlanMetadata) {
|
||||
return this.recentUpdateAttempts.get(plan.update_url!) instanceof UrlError;
|
||||
}
|
||||
|
||||
hasUpdate(plan: PlanMetadata): any {
|
||||
return this.recentUpdateAttempts.has(plan.update_url!);
|
||||
}
|
||||
|
||||
private commonUUIDToNewCurrent(oldPlan: Plan, updatedPlan: Plan): number {
|
||||
let bestOldCurrent = oldPlan.current;
|
||||
let newIndex = 0;
|
||||
//Attempt current plan element's UUID first, otherwise step backwards until we find a match
|
||||
while (bestOldCurrent >= 0) {
|
||||
const tempNewIndex = updatedPlan.plan.findIndex(e => e.uuid === oldPlan.plan[bestOldCurrent].uuid);
|
||||
if (tempNewIndex !== -1) {
|
||||
newIndex = tempNewIndex;
|
||||
break;
|
||||
}
|
||||
bestOldCurrent--;
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
You have an ongoing run history <b>(time tracking)</b> going, would you like to resume it?
|
||||
<div mat-dialog-actions>
|
||||
<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>
|
@ -0,0 +1,33 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { MatDialogModule, MatDialogRef } from "@angular/material/dialog";
|
||||
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
|
||||
export enum Resume {
|
||||
Discard,
|
||||
Next,
|
||||
Instant
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'resume-dialog',
|
||||
templateUrl: 'resume-dialog.component.html',
|
||||
standalone: true,
|
||||
imports: [MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule],
|
||||
})
|
||||
export class ResumeDialog {
|
||||
|
||||
instant() {
|
||||
this.dialogRef.close(Resume.Instant);
|
||||
}
|
||||
next() {
|
||||
this.dialogRef.close(Resume.Next);
|
||||
}
|
||||
discard() {
|
||||
this.dialogRef.close(Resume.Discard);
|
||||
}
|
||||
|
||||
constructor(public dialogRef: MatDialogRef<ResumeDialog>) {}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
<button mat-raised-button color="warn" *ngIf="timeTrackerService.isActive"
|
||||
(click)="timeTrackerService.stop()">Force stop stopwatch</button>
|
||||
<button mat-raised-button color="warn"
|
||||
*ngIf="!timeTrackerService.isActive && timeTrackerService.hasRunLoaded" (click)="timeTrackerService.startLoaded()">Start last
|
||||
loaded</button>
|
@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { StopwatchControlsComponent } from './stopwatch-controls.component';
|
||||
|
||||
describe('StopwatchControlsComponent', () => {
|
||||
let component: StopwatchControlsComponent;
|
||||
let fixture: ComponentFixture<StopwatchControlsComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [StopwatchControlsComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(StopwatchControlsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { TimeTrackerService } from 'src/app/_services/time-tracker.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stopwatch-controls',
|
||||
templateUrl: './stopwatch-controls.component.html',
|
||||
styleUrls: ['./stopwatch-controls.component.scss'],
|
||||
imports: [CommonModule, MatButtonModule],
|
||||
standalone: true
|
||||
})
|
||||
export class StopwatchControlsComponent {
|
||||
constructor(public timeTrackerService: TimeTrackerService) { }
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
<label>Plan name</label><input matInput type="text" [(ngModel)]="data2.name"><br>
|
||||
<label>Url</label><input matInput type="text" [(ngModel)]="data2.url">
|
||||
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button (click)="cancel()">Cancel</button>
|
||||
<button mat-button [mat-dialog-close]="data2" cdkFocusInitial>Save</button>
|
||||
</div>
|
@ -0,0 +1,31 @@
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog";
|
||||
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
|
||||
interface UrlData {
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'url-dialog',
|
||||
templateUrl: 'url-dialog.component.html',
|
||||
standalone: true,
|
||||
imports: [MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule],
|
||||
})
|
||||
export class UrlDialog {
|
||||
public data2: UrlData;
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<UrlDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: UrlData,
|
||||
) {
|
||||
this.data2 = data;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
<button mat-stroked-button color="warn" (click)="reset()">Reset view</button>
|
||||
|
||||
<ng-container *ngIf="!shouldShowTable && cache">
|
||||
<div class="cache-viewport">
|
||||
<div *ngFor="let item of this.cache | keyvalue: myOrder" class="cache-item d-flex flex-row p-4">
|
||||
<div class="d-flex flex-column">
|
||||
<span>Plan: {{item.value.associatedName}}</span>
|
||||
<span>Run time: {{hms(item.value.currentElapsedMillis)}}</span>
|
||||
<span>Last updated: {{dateFormat(item.value.last_updated)}}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column justify-content-center align-items-center">
|
||||
<button mat-stroked-button color="accent" (click)="loadMain(item.key)">Load</button>
|
||||
<button mat-stroked-button color="accent" (click)="setComparison(item.key)">Live compare</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="shouldShowTable">
|
||||
<mat-slide-toggle [(ngModel)]="this.aggregate">Show aggregated results</mat-slide-toggle>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="zoneName">
|
||||
<th mat-header-cell *matHeaderCellDef> Zone </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.zoneName}} </td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="entryTime">
|
||||
<th mat-header-cell *matHeaderCellDef> Time of entry </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.entryTime}} </td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="estimatedExit">
|
||||
<th mat-header-cell *matHeaderCellDef> Time of estimated exit </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.estimatedExit}} </td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="estimatedTimeSpent">
|
||||
<th mat-header-cell *matHeaderCellDef> Estimated time spent </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.estimatedTimeSpent}} </td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="aggregateFirstEntry">
|
||||
<th mat-header-cell *matHeaderCellDef> Time of first entry </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.aggregateFirstEntry}} </td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="aggregateLastExit">
|
||||
<th mat-header-cell *matHeaderCellDef> Time of estimated last exit </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.aggregateLastExit}} </td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="aggregateTimeSpent">
|
||||
<th mat-header-cell *matHeaderCellDef> Estimated total time spent </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.aggregateTimeSpent}} </td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="aggregateNumEntries">
|
||||
<th mat-header-cell *matHeaderCellDef> Number of entries </th>
|
||||
<td mat-cell *matCellDef="let entry"> {{entry.aggregateNumEntries}} </td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</ng-container>
|
@ -0,0 +1,3 @@
|
||||
.cache-item {
|
||||
gap: 12px;
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RunStatsComponent } from './run-stats.component';
|
||||
|
||||
describe('RunStatsComponent', () => {
|
||||
let component: RunStatsComponent;
|
||||
let fixture: ComponentFixture<RunStatsComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RunStatsComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(RunStatsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,155 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule, KeyValue } from '@angular/common';
|
||||
import { EntryType, RunHistory, RunHistoryMetadata, TimeTrackerService } from '../_services/time-tracker.service';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { WorldAreaService } from '../_services/world-area.service';
|
||||
import { WorldArea } from '../_models/world-area';
|
||||
import { AggregateRunStat, RunStat, RunStatService, RunStatType, UnformattedAggregationData, UnformattedRunStat } from '../_services/run-stat.service';
|
||||
import { ConfigService } from '../_services/config.service';
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-run-stats',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatTableModule, MatSlideToggleModule, FormsModule, ScrollingModule, MatButtonModule],
|
||||
templateUrl: './run-stats.component.html',
|
||||
styleUrls: ['./run-stats.component.scss']
|
||||
})
|
||||
export class RunStatsComponent implements OnInit {
|
||||
aggregated?: AggregateRunStat[];
|
||||
direct?: RunStat[];
|
||||
|
||||
aggregate: boolean = true;
|
||||
cache?: Map<string, RunHistoryMetadata>;
|
||||
worldAreaMap?: Map<string, WorldArea>;
|
||||
|
||||
constructor(
|
||||
private timeTrackerService: TimeTrackerService,
|
||||
private worldAreaService: WorldAreaService,
|
||||
private runStatService: RunStatService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.worldAreaService.getFullWorldAreas().subscribe((data) => {
|
||||
this.worldAreaMap = data;
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.timeTrackerService.storedHistories.subscribe((data) => {
|
||||
this.cache = data;
|
||||
})
|
||||
}
|
||||
|
||||
get dataSource(): RunStatType[] {
|
||||
return (this.aggregate ? this.aggregated : this.direct) ?? [];
|
||||
}
|
||||
|
||||
get hasInitializedData() {
|
||||
return this.aggregated || this.direct;
|
||||
}
|
||||
|
||||
get shouldShowTable() {
|
||||
return this.hasInitializedData;
|
||||
}
|
||||
|
||||
loadMain(uuid: string) {
|
||||
this.timeTrackerService.loadHistory(uuid).subscribe((data => {
|
||||
if (data) {
|
||||
this.onLoad(data);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
formatAggregate(data: UnformattedAggregationData) {
|
||||
const { aggregation, aggregateNAId } = data;
|
||||
|
||||
return aggregation.map((entry) => {
|
||||
let aggregateTimeSpent;
|
||||
if (aggregateNAId === entry.zoneId) {
|
||||
aggregateTimeSpent = this.timeTrackerService.hmsTimestamp(entry.aggregateTimeSpent) + " + N/A"
|
||||
} else {
|
||||
aggregateTimeSpent = this.timeTrackerService.hmsTimestamp(entry.aggregateTimeSpent);
|
||||
}
|
||||
|
||||
return {
|
||||
zoneName: this.resolveZone(entry.zoneId),
|
||||
aggregateFirstEntry: this.timeTrackerService.hmsTimestamp(entry.aggregateFirstEntry),
|
||||
aggregateLastExit: aggregateNAId === entry.zoneId ? "N/A" : this.timeTrackerService.hmsTimestamp(entry.aggregateLastExit),
|
||||
aggregateTimeSpent: aggregateTimeSpent,
|
||||
aggregateNumEntries: entry.aggregateNumEntries.toString(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatDirect(direct: UnformattedRunStat[]) {
|
||||
return direct.filter(entry => entry.entryType == EntryType.ZoneEnter).map(entry => {
|
||||
return {
|
||||
zoneName: this.resolveZone(entry.zoneId),
|
||||
entryTime: this.timeTrackerService.hmsTimestamp(entry.entryTime),
|
||||
estimatedExit: entry.estimatedExit ? this.timeTrackerService.hmsTimestamp(entry.estimatedExit) : "N/A",
|
||||
estimatedTimeSpent: entry.estimatedTimeSpent ? this.timeTrackerService.hmsTimestamp(entry.estimatedTimeSpent) : "N/A",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dateFormat(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
onLoad(data: RunHistory) {
|
||||
this.direct = this.formatDirect(this.runStatService.calcDirect(data));
|
||||
this.aggregated = this.formatAggregate(this.runStatService.calcAggregated(data));
|
||||
}
|
||||
|
||||
hms(time: number) {
|
||||
return this.timeTrackerService.hmsTimestamp(time);
|
||||
}
|
||||
|
||||
private resolveZone(zoneId: string) {
|
||||
const area = this.worldAreaMap?.get(zoneId);
|
||||
if (!area) {
|
||||
return "Unknown zone: " + zoneId;
|
||||
}
|
||||
|
||||
// Act might not be very reasonable but it's the best we have it..
|
||||
return area.name + " (A" + area.act + ")"
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.timeTrackerService.loadCache();
|
||||
this.aggregated = undefined;
|
||||
this.direct = undefined;
|
||||
}
|
||||
|
||||
myOrder(a: KeyValue<string, RunHistoryMetadata>, b: KeyValue<string, RunHistoryMetadata>): number {
|
||||
return b.value.last_updated - a.value.last_updated;
|
||||
}
|
||||
|
||||
setComparison(id: string) {
|
||||
this.configService.config.runCompareHistory = id;
|
||||
}
|
||||
|
||||
get displayedColumns() {
|
||||
if (this.aggregate) {
|
||||
return [
|
||||
"zoneName",
|
||||
"aggregateFirstEntry",
|
||||
"aggregateLastExit",
|
||||
"aggregateTimeSpent",
|
||||
"aggregateNumEntries",
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
"zoneName",
|
||||
"entryTime",
|
||||
"estimatedExit",
|
||||
"estimatedTimeSpent",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
<ng-container *ngTemplateOutlet="template!; context: { $implicit: value }">
|
||||
</ng-container>
|
@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { WrapValueComponent } from './wrap-value.component';
|
||||
|
||||
describe('WrapValueComponent', () => {
|
||||
let component: WrapValueComponent;
|
||||
let fixture: ComponentFixture<WrapValueComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [WrapValueComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(WrapValueComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,14 @@
|
||||
import { Component, ContentChild, Input, TemplateRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wrap-value',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './wrap-value.component.html',
|
||||
styleUrls: ['./wrap-value.component.scss']
|
||||
})
|
||||
export class WrapValueComponent<T> {
|
||||
@Input() value?: T;
|
||||
@ContentChild(TemplateRef) template?: TemplateRef<any>;
|
||||
}
|
After Width: | Height: | Size: 159 B |
After Width: | Height: | Size: 388 B |
After Width: | Height: | Size: 295 B |
After Width: | Height: | Size: 457 B |
Loading…
Reference in new issue