feat(timeSheetExport): half way there

This commit is contained in:
Johannes Millan 2018-10-30 02:17:01 +01:00
parent c7ad410aa4
commit bfc79e8cb9
8 changed files with 547 additions and 200 deletions

9
package-lock.json generated
View file

@ -1123,6 +1123,15 @@
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.4.2.tgz",
"integrity": "sha512-cDB930/7MbzaGF6U3IwSQp6XBru8xWajF5PV2YZZeV8DyiliTuld11afVztGI9+yJZ29il5E+NpGA6ooV/Cjkg=="
},
"@types/moment-duration-format": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@types/moment-duration-format/-/moment-duration-format-2.2.2.tgz",
"integrity": "sha512-CuYswsMI3y5uR5sD9i/VUqIbZrsYN2eaCs7nH3qpDl2CZlNI48mjMf4ve2RpQ/65irprtnQVetfnea9my+jqcg==",
"dev": true,
"requires": {
"moment": ">=2.14.0"
}
},
"@types/node": {
"version": "8.9.5",
"resolved": "http://registry.npmjs.org/@types/node/-/node-8.9.5.tgz",

View file

@ -77,6 +77,7 @@
"@ngrx/schematics": "^6.1.0",
"@types/jasmine": "~2.8.6",
"@types/jasminewd2": "~2.0.3",
"@types/moment-duration-format": "^2.2.2",
"@types/node": "~8.9.4",
"codelyzer": "~4.2.1",
"dbus-native": "^0.2.5",

View file

@ -1,196 +1,207 @@
<h1 mat-dialog-title="">Time Sheet Export</h1>
<div class="mat-typography">
<h1 mat-dialog-title="">Time Sheet Export</h1>
<form (submit)="save()">
<div mat-dialog-content=""
class="dialog-content">
<collapsible class="help-collapsible"
title="What is this and how does it work?"
[initiallyExpanded]="opts.spreadsheetId">
<div class="info">
<p>This view allows you to export your worked time to a google sheet. You need to allow for your Google Spreadsheets to be accessed by Super Productivity. You also need to create a spreadsheet with a headings in the first row nad specify it's ID in the input field (<a external-link
target="_blank"
href="https://stackoverflow.com/questions/36061433/how-to-do-i-locate-a-google-spreadsheet-id">Ho to find the id of a spreadsheet?</a>).
</p>
<p>After successfully loading your spreadsheet a table will show up with 4 rows. The first row shows the heading you specified in the spreadsheet itself.</p>
<p>The second row is for informational purposes and shows the last row from the spreadsheet.</p>
<p>The forth row is a list of values you can directly enter save to the spreadsheet.</p>
<p>The third row is there to automatically define some values for the forth row. There are several special strings you can enter into the cells:</p>
</div>
<dl class="possible-properties">
<dt>{{ '{' }}startTime}</dt>
<dd>The time when you first used this app today. It's possible to round this via the options.</dd>
<dt>{{ '{' }}currentTime}</dt>
<dd>The current time. Could be used for the for the end time of todays working day It's possible to round this via the options.</dd>
<dt>{{ '{' }}date}</dt>
<dd>Todays date in standard format (mm/dd/yyyy)</dd>
<dt>{{ '{' }}date:DD/MM/YYYY} (example)</dt>
<dd>Date with a custom date format string.</dd>
<dt>{{ '{' }}taskTitles}</dt>
<dd>Comma separated (parent) task titles</dd>
<dt>{{ '{' }}subTaskTitles}</dt>
<dd>Comma separated sub task titles</dd>
<dt>{{ '{' }}totalTime}</dt>
<dd>The total time you spend working on your todays tasks.</dd>
</dl>
<p>In addition to this there are several options you can use to modify the calculation of those values.</p>
</collapsible>
<collapsible title="Options"
class="options-collapsible"
[initiallyExpanded]="opts.spreadsheetId">
<mat-slide-toggle [(ngModel)]="opts.isAutoLogin">Auto-login and load data next time</mat-slide-toggle>
<!--<mat-slide-toggle [(ngModel)]="opts.isAutoFocusEmpty">Auto-focus first empty field after loading table headings</mat-slide-toggle>-->
<mat-slide-toggle [(ngModel)]="opts.isRoundWorkTimeUp"
(change)="updateDefaults()">Always round work time up
</mat-slide-toggle>
<mat-form-field>
<label>Round start time to</label>
<mat-select (change)="updateDefaults()"
[(ngModel)]="opts.roundStartTimeTo">
<mat-option><em>don't round</em></mat-option>
<mat-option *ngFor="let roundOption of roundTimeOptions"
[value]="roundOption.id">
{{roundOption.title}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<label>Round end time to</label>
<mat-select (change)="updateDefaults()"
[(ngModel)]="opts.roundEndTimeTo">
<mat-option><em>don't round</em></mat-option>
<mat-option *ngFor="let roundOption of roundTimeOptions"
[value]="roundOption.id">
{{roundOption.title}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<label>Round time worked to</label>
<mat-select (change)="updateDefaults()"
[(ngModel)]="opts.roundWorkTimeTo">
<mat-option><em>don't round</em></mat-option>
<mat-option *ngFor="let roundOption of roundTimeOptions"
[value]="roundOption.id">
{{roundOption.title}}
</mat-option>
</mat-select>
</mat-form-field>
</collapsible>
<div>
<button mat-raised-button=""
color="primary"
promise-btn
*ngIf="!GoogleApi.isLoggedIn"
(click)="login()">Login
</button>
<button mat-raised-button=""
color="primary"
promise-btn
*ngIf="GoogleApi.isLoggedIn"
(click)="logout()">Logout
</button>
<button mat-raised-button=""
color="primary"
promise-btn
[disabled]="!opts.spreadsheetId"
*ngIf="GoogleApi.isLoggedIn"
(click)="readSpreadsheet()">Read spreadsheet
</button>
<a mat-raised-button=""
color="primary"
*ngIf="GoogleApi.isLoggedIn"
external-link
target="_blank"
href="https://myaccount.google.com/permissions">Revoke permissions
</a>
<a mat-raised-button=""
color="primary"
external-link
target="_blank"
*ngIf="GoogleApi.isLoggedIn && opts.spreadsheetId"
href="https://docs.google.com/spreadsheets/d/{{opts.spreadsheetId}}">Edit spreadsheet
</a>
</div>
<div class="loading-spinner"
*ngIf="isLoading && !GoogleApi.isLoggedIn">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
</div>
<section *ngIf="GoogleApi.isLoggedIn">
<mat-form-field class="md-block">
<label>Spreadsheet ID</label>
<input type="text"
[(ngModel)]="opts.spreadsheetId">
</mat-form-field>
<div class="loading-spinner"
*ngIf="isLoading && GoogleApi.isLoggedIn">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
</div>
<table class="export-input-table"
*ngIf="headings">
<tr>
<th></th>
<th class="heading"
*ngFor="let heading of headings"
[innerHtml]="heading"></th>
</tr>
<tr>
<th>Last saved row</th>
<td *ngFor="let col of lastRow">{{col}}</td>
</tr>
<tr>
<th>Default</th>
<td class="default"
*ngFor="let heading of headings; let i = index;">
<input type="text"
(change)="updateDefaults()"
[(ngModel)]="opts.defaultValues[i]">
</td>
</tr>
<tr>
<th>Actual</th>
<td class="actual"
*ngFor="let heading of headings; let i = index;">
<input type="text"
[(ngModel)]="actualValues[i]">
</td>
</tr>
</table>
</section>
<div class="loading-spinner"
*ngIf="isLoading && !googleApiService.isLoggedIn">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
</div>
<div mat-dialog-actions="">
<button (click)="save()"
*ngIf="actualValues.length > 0 && GoogleApi.isLoggedIn"
type="button"
mat-raised-button=""
color="primary"
promise-btn>
<mat-icon>save</mat-icon>
Save row
</button>
<button (click)="cancel()"
type="button"
class="md-raised">
<mat-icon>close</mat-icon>
Close
</button>
</div>
</form>
<form (submit)="save()">
<div mat-dialog-content=""
class="dialog-content">
<collapsible class="help-collapsible"
title="What is this and how does it work?"
[initiallyExpanded]="opts.spreadsheetId">
<div class="info">
<p>This view allows you to export your worked time to a google sheet. You need to allow for your Google Spreadsheets to be accessed by Super Productivity. You also need to create a spreadsheet with a headings in the first row nad specify it's ID in the input field (<a external-link
target="_blank"
href="https://stackoverflow.com/questions/36061433/how-to-do-i-locate-a-google-spreadsheet-id">Ho to find the id of a spreadsheet?</a>).
</p>
<p>After successfully loading your spreadsheet a table will show up with 4 rows. The first row shows the heading you specified in the spreadsheet itself.</p>
<p>The second row is for informational purposes and shows the last row from the spreadsheet.</p>
<p>The forth row is a list of values you can directly enter save to the spreadsheet.</p>
<p>The third row is there to automatically define some values for the forth row. There are several special strings you can enter into the cells:</p>
</div>
<dl class="possible-properties">
<dt>{{ '{' }}startTime}</dt>
<dd>The time when you first used this app today. It's possible to round this via the options.</dd>
<dt>{{ '{' }}currentTime}</dt>
<dd>The current time. Could be used for the for the end time of todays working day It's possible to round this via the options.</dd>
<dt>{{ '{' }}date}</dt>
<dd>Todays date in standard format (mm/dd/yyyy)</dd>
<dt>{{ '{' }}date:DD/MM/YYYY} (example)</dt>
<dd>Date with a custom date format string.</dd>
<dt>{{ '{' }}taskTitles}</dt>
<dd>Comma separated (parent) task titles</dd>
<dt>{{ '{' }}subTaskTitles}</dt>
<dd>Comma separated sub task titles</dd>
<dt>{{ '{' }}totalTime}</dt>
<dd>The total time you spend working on your todays tasks.</dd>
</dl>
<p>In addition to this there are several options you can use to modify the calculation of those values.</p>
</collapsible>
<collapsible title="Options"
class="options-collapsible"
[initiallyExpanded]="opts.spreadsheetId">
<mat-slide-toggle
[ngModelOptions]="{standalone: true}"
[(ngModel)]="opts.isAutoLogin">Auto-login and load data next time
</mat-slide-toggle>
<!--<mat-slide-toggle [(ngModel)]="optsname="".is.isAutoFocusEmpty">Auto-focus first empty field after loading table headings</mat-slide-toggle>-->
<mat-slide-toggle
[ngModelOptions]="{standalone: true}"
[(ngModel)]="opts.isRoundWorkTimeUp"
(change)="updateDefaults()">Always round work time up
</mat-slide-toggle>
<mat-form-field>
<label>Round start time to</label>
<mat-select (change)="updateDefaults()"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="opts.roundStartTimeTo">
<mat-option><em>don't round</em></mat-option>
<mat-option *ngFor="let roundOption of roundTimeOptions"
[value]="roundOption.id">
{{roundOption.title}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<label>Round end time to</label>
<mat-select (change)="updateDefaults()"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="opts.roundEndTimeTo">
<mat-option><em>don't round</em></mat-option>
<mat-option *ngFor="let roundOption of roundTimeOptions"
[value]="roundOption.id">
{{roundOption.title}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<label>Round time worked to</label>
<mat-select (change)="updateDefaults()"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="opts.roundWorkTimeTo">
<mat-option><em>don't round</em></mat-option>
<mat-option *ngFor="let roundOption of roundTimeOptions"
[value]="roundOption.id">
{{roundOption.title}}
</mat-option>
</mat-select>
</mat-form-field>
</collapsible>
<div>
<button mat-raised-button=""
color="primary"
promise-btn
*ngIf="!googleApiService.isLoggedIn"
(click)="login()">Login
</button>
<button mat-raised-button=""
color="primary"
promise-btn
*ngIf="googleApiService.isLoggedIn"
(click)="logout()">Logout
</button>
<button mat-raised-button=""
color="primary"
promise-btn
[disabled]="!opts.spreadsheetId"
*ngIf="googleApiService.isLoggedIn"
(click)="readSpreadsheet()">Read spreadsheet
</button>
<a mat-raised-button=""
color="primary"
*ngIf="googleApiService.isLoggedIn"
external-link
target="_blank"
href="https://myaccount.google.com/permissions">Revoke permissions
</a>
<a mat-raised-button=""
color="primary"
external-link
target="_blank"
*ngIf="googleApiService.isLoggedIn && opts.spreadsheetId"
href="https://docs.google.com/spreadsheets/d/{{opts.spreadsheetId}}">Edit spreadsheet
</a>
</div>
<section *ngIf="googleApiService.isLoggedIn">
<mat-form-field class="md-block">
<label>Spreadsheet ID</label>
<input type="text"
matInput=""
name="spreadsheetId"
[(ngModel)]="opts.spreadsheetId">
</mat-form-field>
<table class="export-input-table"
*ngIf="headings">
<tr>
<th></th>
<th class="heading"
*ngFor="let heading of headings"
[innerHtml]="heading"></th>
</tr>
<tr>
<th>Last saved row</th>
<td *ngFor="let col of lastRow">{{col}}</td>
</tr>
<tr>
<th>Default</th>
<td class="default"
*ngFor="let heading of headings; let i = index;">
<input type="text"
(change)="updateDefaults()"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="opts.defaultValues[i]">
</td>
</tr>
<tr>
<th>Actual</th>
<td class="actual"
*ngFor="let heading of headings; let i = index;">
<input type="text"
name="actualValues"
[(ngModel)]="actualValues[i]">
</td>
</tr>
</table>
</section>
</div>
<div mat-dialog-actions="">
<button (click)="save()"
*ngIf="actualValues.length > 0 && googleApiService.isLoggedIn"
type="button"
mat-raised-button=""
color="primary"
promise-btn>
<mat-icon>save</mat-icon>
Save row
</button>
<button (click)="cancel()"
type="button"
mat-raised-button="">
<mat-icon>close</mat-icon>
Close
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,52 @@
.help-collapsible {
::ng-deep.collapsible-title {
font-size: 20px;
}
}
.options-collapsible {
::ng-deep .collapsible-title {
font-size: 20px;
margin-top: 0;
}
}
.export-input-table {
th {
font-weight: bold;
}
input {
width: 100%;
min-width: 40px;
}
}
.possible-properties {
dt {
margin-bottom: 4px;
}
dd {
margin-bottom: 10px;
}
}
.loading-spinner {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.3);
mat-progress-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%, 0);
}
}
mat-input-container {
min-width: 150px;
}

View file

@ -1,4 +1,8 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import * as moment from 'moment';
import { Duration, Moment } from 'moment';
import { GoogleApiService } from '../google-api.service';
import { SnackService } from '../../snack/snack.service';
@Component({
selector: 'dialog-google-export-time',
@ -7,11 +11,275 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DialogGoogleExportTimeComponent implements OnInit {
opts: any = {
spreadsheetId: undefined,
isAutoLogin: false,
isAutoFocusEmpty: false,
isRoundWorkTimeUp: undefined,
roundStartTimeTo: undefined,
roundEndTimeTo: undefined,
roundWorkTimeTo: undefined,
defaultValues: [
''
]
};
// $rootScope.r.uiHelper.timeSheetExportSettings;
actualValues = [];
isLoading = false;
isLoggedIn = false;
headings: string[] = [];
lastRow: string[] = [];
MISSING = {
startedTimeToday: 'MISSING startedTimeToday',
getTimeWorkedToday: 'MISSING getTimeWorkedToday',
getToday: []
};
constructor() {
roundTimeOptions = [
{id: 'QUARTER', title: 'full quarters'},
{id: 'HALF', title: 'full half hours'},
{id: 'HOUR', title: 'full hours'},
];
constructor(
public googleApiService: GoogleApiService,
private _snackService: SnackService,
private _cd: ChangeDetectorRef
) {
}
ngOnInit() {
if (this.opts.isAutoLogin) {
this.login()
.then(() => {
if (this.opts.spreadsheetId) {
this.readSpreadsheet();
}
})
.then(() => {
this.updateDefaults();
});
}
}
cancel() {
// $mdDialog.hide();
}
login() {
this.isLoading = true;
return this.googleApiService.login()
.then(() => {
this.isLoading = false;
this.isLoggedIn = true;
this._cd.detectChanges();
});
}
readSpreadsheet() {
this.isLoading = true;
this.headings = undefined;
return this.googleApiService.getSpreadsheetHeadingsAndLastRow(this.opts.spreadsheetId)
.then((data: any) => {
this.headings = data.headings;
this.lastRow = data.lastRow;
this.updateDefaults();
this.isLoading = false;
this._cd.detectChanges();
});
}
logout() {
this.isLoading = false;
return this.googleApiService.logout()
.then(() => {
this.isLoading = false;
this.isLoggedIn = false;
this._cd.detectChanges();
});
}
save() {
this.isLoading = true;
const arraysEqual = (arr1, arr2) => {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = arr1.length; i--;) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
};
if (arraysEqual(this.actualValues, this.lastRow)) {
this._snackService.open('Current values and the last saved row have equal values, that is probably not what you want.');
} else {
this.googleApiService.appendRow(this.opts.spreadsheetId, this.actualValues)
.then(() => {
this._snackService.open({
message: 'Row successfully appended',
type: 'SUCCESS'
});
// $mdDialog.hide();
// $rootScope.r.currentSession.isTimeSheetExported = true;
this.isLoading = false;
});
}
}
updateDefaults() {
this.opts.defaultValues.forEach((val, index) => {
this.actualValues[index] = this.replaceVals(val);
});
}
replaceVals(defaultVal: string): string {
if (!defaultVal) {
return;
}
const dVal = defaultVal.trim();
if (dVal.match(/\{date:/)) {
return this.getCustomDate(dVal);
}
switch (dVal) {
case '{startTime}':
return this.getStartTime();
case '{currentTime}':
return this.getCurrentTime();
case '{date}':
return moment().format('MM/DD/YYYY');
case '{taskTitles}':
return this.getTaskTitles();
case '{subTaskTitles}':
return this.getSubTaskTitles();
case '{totalTime}':
return this.getTotalTimeWorked();
default:
return dVal;
}
}
private roundDuration(value: Duration, roundTo, isRoundUp): Duration {
let rounded;
switch (roundTo) {
case 'QUARTER':
rounded = Math.round(value.asMinutes() / 15) * 15;
if (isRoundUp) {
rounded = Math.ceil(value.asMinutes() / 15) * 15;
}
return moment.duration({minutes: rounded});
case 'HALF':
rounded = Math.round(value.asMinutes() / 30) * 30;
if (isRoundUp) {
rounded = Math.ceil(value.asMinutes() / 30) * 30;
}
return moment.duration({minutes: rounded});
case 'HOUR':
rounded = Math.round(value.asMinutes() / 60) * 60;
if (isRoundUp) {
rounded = Math.ceil(value.asMinutes() / 60) * 60;
}
return moment.duration({minutes: rounded});
default:
return value;
}
}
private roundTime(value: Moment, roundTo, isRoundUp = false): Moment {
let rounded;
switch (roundTo) {
case 'QUARTER':
rounded = Math.round(value.minute() / 15) * 15;
if (isRoundUp) {
rounded = Math.ceil(value.minute() / 15) * 15;
}
return value.minute(rounded).second(0);
case 'HALF':
rounded = Math.round(value.minute() / 30) * 30;
if (isRoundUp) {
rounded = Math.ceil(value.minute() / 30) * 30;
}
return value.minute(rounded).second(0);
case 'HOUR':
rounded = Math.round(value.minute() / 60) * 60;
if (isRoundUp) {
rounded = Math.ceil(value.minute() / 60) * 60;
}
return value.minute(rounded).second(0);
default:
return value;
}
}
private getCustomDate(dVal: string): string {
const dateFormatStr = dVal
.replace('{date:', '')
.replace('}', '')
.trim();
return moment().format(dateFormatStr);
}
private getStartTime() {
const val = moment(this.MISSING.startedTimeToday);
const roundTo = this.opts.roundStartTimeTo;
return this.roundTime(val, roundTo)
.format('HH:mm');
}
private getCurrentTime(): string {
const val = moment();
const roundTo = this.opts.roundEndTimeTo;
return this.roundTime(val, roundTo)
.format('HH:mm');
}
private getTotalTimeWorked(): string {
const val = moment.duration(this.MISSING.getTimeWorkedToday);
const roundTo = this.opts.roundWorkTimeTo;
const dur = this.roundDuration(val, roundTo, this.opts.isRoundWorkTimeUp) as any;
return dur.format('HH:mm');
}
private getTaskTitles(): string {
const tasks = this.MISSING.getToday;
let titleStr = '';
tasks.forEach((task) => {
titleStr += task.title + ', ';
});
return titleStr.substring(0, titleStr.length - 2);
}
private getSubTaskTitles(): string {
const tasks = this.MISSING.getToday;
let titleStr = '';
tasks.forEach((task) => {
if (task.subTasks) {
task.subTasks.forEach((subTask) => {
titleStr += subTask.title + ', ';
});
} else {
titleStr += task.title + ', ';
}
});
return titleStr.substring(0, titleStr.length - 2);
}
}

View file

@ -3,7 +3,7 @@ import { GOOGLE_DEFAULT_FIELDS_FOR_DRIVE, GOOGLE_DISCOVERY_DOCS, GOOGLE_SCOPES,
import * as moment from 'moment';
import { IS_ELECTRON } from '../../app.constants';
import { MultiPartBuilder } from './util/multi-part-builder';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpRequest } from '@angular/common/http';
import { SnackService } from '../snack/snack.service';
import { SnackType } from '../snack/snack.model';
@ -138,9 +138,9 @@ export class GoogleApiService {
return this._requestWrapper(new Promise((resolve, reject) => {
this.getSpreadsheetData(spreadsheetId, 'A1:Z99')
.then((response: any) => {
const range = response.result || response.data;
const range = response.result || response.data || response;
if (range.values && range.values[0]) {
if (range && range.values && range.values[0]) {
resolve({
headings: range.values[0],
lastRow: range.values[range.values.length - 1],
@ -364,8 +364,8 @@ export class GoogleApiService {
});
}
private _mapHttp(params: any): Promise<any> {
return this._http.request(params).toPromise();
private _mapHttp(p: HttpRequest | any): Promise<any> {
return this._http[p.method.toLowerCase()](p.url, p).toPromise();
}

View file

@ -10,7 +10,10 @@ export class SnackService {
constructor(private _store$: Store<any>) {
}
open(params: SnackParams) {
open(params: SnackParams | string) {
if (typeof params === 'string') {
params = {message: params};
}
this._store$.dispatch(new SnackOpen(params));
}

View file

@ -13,6 +13,9 @@
"typeRoots": [
"node_modules/@types"
],
"types": [
"@types/moment-duration-format"
],
"lib": [
"es2017",
"dom"