Feature/zen mode (#110)

* Start with implementation
* Refactor AuthGuard, persist displayMode in user settings
* Refactor DisplayMode to ViewMode
* Update changelog
pull/111/head
Thomas 4 years ago committed by GitHub
parent 702ee956a2
commit 78a4946e8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added _Zen Mode_: the distraction-free view
## 1.4.0 - 20.05.2021 ## 1.4.0 - 20.05.2021
### Added ### Added

@ -1,7 +1,10 @@
import { Currency } from '@prisma/client'; import { Currency, ViewMode } from '@prisma/client';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdateUserSettingsDto { export class UpdateUserSettingsDto {
@IsString() @IsString()
currency: Currency; baseCurrency: Currency;
@IsString()
viewMode: ViewMode;
} }

@ -93,8 +93,9 @@ export class UserController {
} }
return await this.userService.updateUserSettings({ return await this.userService.updateUserSettings({
currency: data.currency, currency: data.baseCurrency,
userId: this.request.user.id userId: this.request.user.id,
viewMode: data.viewMode
}); });
} }
} }

@ -5,7 +5,7 @@ import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add } from 'date-fns'; import { add } from 'date-fns';
const crypto = require('crypto'); const crypto = require('crypto');
@ -52,8 +52,9 @@ export class UserService {
accounts: Account, accounts: Account,
permissions: currentPermissions, permissions: currentPermissions,
settings: { settings: {
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY, locale,
locale baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings.viewMode ?? ViewMode.DEFAULT
}, },
subscription: { subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })), expiresAt: resetHours(add(new Date(), { days: 7 })),
@ -80,7 +81,8 @@ export class UserService {
user.Settings = { user.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(), updatedAt: new Date(),
userId: user?.id userId: user?.id,
viewMode: ViewMode.DEFAULT
}; };
} }
@ -187,10 +189,12 @@ export class UserService {
public async updateUserSettings({ public async updateUserSettings({
currency, currency,
userId userId,
viewMode
}: { }: {
currency: Currency; currency?: Currency;
userId: string; userId: string;
viewMode?: ViewMode;
}) { }) {
await this.prisma.settings.upsert({ await this.prisma.settings.upsert({
create: { create: {
@ -199,10 +203,12 @@ export class UserService {
connect: { connect: {
id: userId id: userId
} }
} },
viewMode
}, },
update: { update: {
currency currency,
viewMode
}, },
where: { where: {
userId: userId userId: userId

@ -1,6 +1,13 @@
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper'; import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import { AccountType, Currency, DataSource, Role, Type } from '@prisma/client'; import {
AccountType,
Currency,
DataSource,
Role,
Type,
ViewMode
} from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DataProviderService } from '../services/data-provider.service'; import { DataProviderService } from '../services/data-provider.service';
@ -120,7 +127,8 @@ describe('Portfolio', () => {
Settings: { Settings: {
currency: Currency.CHF, currency: Currency.CHF,
updatedAt: new Date(), updatedAt: new Date(),
userId: USER_ID userId: USER_ID,
viewMode: ViewMode.DEFAULT
}, },
thirdPartyId: null, thirdPartyId: null,
updatedAt: new Date() updatedAt: new Date()

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid'; import { bool, cleanEnv, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
import { DataSource } from '.prisma/client';
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {

@ -78,6 +78,11 @@ const routes: Routes = [
(m) => m.TransactionsPageModule (m) => m.TransactionsPageModule
) )
}, },
{
path: 'zen',
loadChildren: () =>
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
},
{ {
// wildcard, if requested url doesn't match any paths for routes defined // wildcard, if requested url doesn't match any paths for routes defined
// earlier // earlier

@ -8,11 +8,14 @@
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'home' ? 'primary' : null" [color]="
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
> >
<a <a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
@ -21,6 +24,7 @@
>Analysis</a >Analysis</a
> >
<a <a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button

@ -5,16 +5,19 @@ import {
Router, Router,
RouterStateSnapshot RouterStateSnapshot
} from '@angular/router'; } from '@angular/router';
import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { DataService } from '../services/data.service';
import { SettingsStorageService } from '../services/settings-storage.service'; import { SettingsStorageService } from '../services/settings-storage.service';
import { TokenStorageService } from '../services/token-storage.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor( constructor(
private dataService: DataService,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService
private tokenStorageService: TokenStorageService
) {} ) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
@ -25,23 +28,46 @@ export class AuthGuard implements CanActivate {
); );
} }
const isLoggedIn = !!this.tokenStorageService.getToken(); return new Promise<boolean>((resolve) => {
this.dataService
.fetchUser()
.pipe(
catchError(() => {
if (state.url !== '/start') {
this.router.navigate(['/start']);
resolve(false);
return EMPTY;
}
if (isLoggedIn) { resolve(true);
if (state.url === '/start') { return EMPTY;
this.router.navigate(['/home']); })
return false; )
} .subscribe((user) => {
if (
state.url === '/home' &&
user.settings.viewMode === ViewMode.ZEN
) {
this.router.navigate(['/zen']);
resolve(false);
} else if (state.url === '/start') {
if (user.settings.viewMode === ViewMode.ZEN) {
this.router.navigate(['/zen']);
} else {
this.router.navigate(['/home']);
}
return true; resolve(false);
} } else if (
state.url === '/zen' &&
// Not logged in user.settings.viewMode === ViewMode.DEFAULT
if (state.url !== '/start') { ) {
this.router.navigate(['/start']); this.router.navigate(['/home']);
return false; resolve(false);
} }
return true; resolve(true);
});
});
} }
} }

@ -79,7 +79,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
this.router.navigate(['start']);
} }
return throwError(''); return throwError('');

@ -68,9 +68,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeBaseCurrency({ value: currency }: { value: Currency }) { public onChangeUserSettings(aKey: string, aValue: string) {
const settings = { ...this.user.settings, [aKey]: aValue };
this.dataService this.dataService
.putUserSettings({ currency }) .putUserSettings({
baseCurrency: settings?.baseCurrency,
viewMode: settings?.viewMode
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.dataService.fetchUser().subscribe((user) => { this.dataService.fetchUser().subscribe((user) => {

@ -30,14 +30,14 @@
<div class="d-flex mt-4 py-1"> <div class="d-flex mt-4 py-1">
<div class="pt-4 w-50" i18n>Settings</div> <div class="pt-4 w-50" i18n>Settings</div>
<div class="w-50"> <div class="w-50">
<form #addTransactionForm="ngForm"> <form #changeUserSettingsForm="ngForm">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Base Currency</mat-label> <mat-label i18n>Base Currency</mat-label>
<mat-select <mat-select
name="baseCurrency" name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency" [value]="user.settings.baseCurrency"
(selectionChange)="onChangeBaseCurrency($event)" (selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
> >
<mat-option <mat-option
*ngFor="let currency of currencies" *ngFor="let currency of currencies"
@ -46,6 +46,18 @@
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>View Mode</mat-label>
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</form> </form>
</div> </div>
</div> </div>

@ -132,6 +132,11 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openDialog(): void { private openDialog(): void {
const dialogRef = this.dialog.open(PerformanceChartDialog, { const dialogRef = this.dialog.open(PerformanceChartDialog, {
autoFocus: false, autoFocus: false,
@ -195,9 +200,4 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.cd.markForCheck(); this.cd.markForCheck();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
@ -27,7 +26,6 @@ import { HomePageComponent } from './home-page.component';
GfPositionsModule, GfPositionsModule,
GfToggleModule, GfToggleModule,
HomePageRoutingModule, HomePageRoutingModule,
MatButtonModule,
MatCardModule, MatCardModule,
RouterModule RouterModule
], ],

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ZenPageComponent } from './zen-page.component';
const routes: Routes = [
{ path: '', component: ZenPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ZenPageRoutingModule {}

@ -0,0 +1,104 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-zen-page',
templateUrl: './zen-page.html',
styleUrls: ['./zen-page.scss']
})
export class ZenPageComponent implements OnDestroy, OnInit {
public dateRange: DateRange = 'max';
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToReadForeignPortfolio: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
) {
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.update();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() {
this.isLoadingPerformance = true;
this.dataService
.fetchChart({ range: this.dateRange })
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
return {
date: chartDataItem.date,
value: chartDataItem.value
};
});
this.cd.markForCheck();
});
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;
this.cd.markForCheck();
});
this.cd.markForCheck();
}
}

@ -0,0 +1,25 @@
<div class="container">
<div class="row">
<div class="chart-container col mr-3">
<gf-line-chart
symbol="Performance"
[historicalDataItems]="historicalDataItems"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
<div class="overview-container row mb-5 mt-1">
<div class="col">
<gf-portfolio-performance-summary
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="!hasImpersonationId || hasPermissionToReadForeignPortfolio"
></gf-portfolio-performance-summary>
</div>
</div>
</div>

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfPortfolioPerformanceSummaryModule } from '@ghostfolio/client/components/portfolio-performance-summary/portfolio-performance-summary.module';
import { ZenPageRoutingModule } from './zen-page-routing.module';
import { ZenPageComponent } from './zen-page.component';
@NgModule({
declarations: [ZenPageComponent],
exports: [],
imports: [
CommonModule,
GfLineChartModule,
GfPortfolioPerformanceSummaryModule,
MatCardModule,
ZenPageRoutingModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ZenPageModule {}

@ -0,0 +1,38 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.chart-container {
aspect-ratio: 16 / 9;
margin-top: 3rem;
max-height: 50vh;
// Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) {
&::before {
float: left;
padding-top: 56.25%;
content: '';
}
&::after {
display: block;
content: '';
clear: both;
}
}
gf-line-chart {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: -1;
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

@ -1,4 +1,4 @@
import { Currency } from '.prisma/client'; import { Currency } from '@prisma/client';
export const baseCurrency = Currency.CHF; export const baseCurrency = Currency.CHF;

@ -1,6 +1,7 @@
import { Currency } from '@prisma/client'; import { Currency, ViewMode } from '@prisma/client';
export interface UserSettings { export interface UserSettings {
baseCurrency: Currency; baseCurrency: Currency;
locale: string; locale: string;
viewMode: ViewMode;
} }

@ -92,10 +92,11 @@ model Property {
} }
model Settings { model Settings {
currency Currency currency Currency?
updatedAt DateTime @updatedAt viewMode ViewMode?
User User @relation(fields: [userId], references: [id]) updatedAt DateTime @updatedAt
userId String @id User User @relation(fields: [userId], references: [id])
userId String @id
} }
model User { model User {
@ -133,6 +134,11 @@ enum DataSource {
YAHOO YAHOO
} }
enum ViewMode {
DEFAULT
ZEN
}
enum Provider { enum Provider {
ANONYMOUS ANONYMOUS
GOOGLE GOOGLE

Loading…
Cancel
Save