diff --git a/CHANGELOG.md b/CHANGELOG.md index 722bf0e8b..7fe5b7f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/apps/api/src/app/user/update-user-settings.dto.ts b/apps/api/src/app/user/update-user-settings.dto.ts index def1914bd..953913bac 100644 --- a/apps/api/src/app/user/update-user-settings.dto.ts +++ b/apps/api/src/app/user/update-user-settings.dto.ts @@ -1,7 +1,10 @@ -import { Currency } from '@prisma/client'; +import { Currency, ViewMode } from '@prisma/client'; import { IsString } from 'class-validator'; export class UpdateUserSettingsDto { @IsString() - currency: Currency; + baseCurrency: Currency; + + @IsString() + viewMode: ViewMode; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 6b11c064e..f7e5d275b 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -93,8 +93,9 @@ export class UserController { } return await this.userService.updateUserSettings({ - currency: data.currency, - userId: this.request.user.id + currency: data.baseCurrency, + userId: this.request.user.id, + viewMode: data.viewMode }); } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index f1994fffe..fc5a461c4 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -5,7 +5,7 @@ import { resetHours } from '@ghostfolio/common/helper'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { getPermissions, permissions } from '@ghostfolio/common/permissions'; 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'; const crypto = require('crypto'); @@ -52,8 +52,9 @@ export class UserService { accounts: Account, permissions: currentPermissions, settings: { - baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY, - locale + locale, + baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, + viewMode: Settings.viewMode ?? ViewMode.DEFAULT }, subscription: { expiresAt: resetHours(add(new Date(), { days: 7 })), @@ -80,7 +81,8 @@ export class UserService { user.Settings = { currency: UserService.DEFAULT_CURRENCY, updatedAt: new Date(), - userId: user?.id + userId: user?.id, + viewMode: ViewMode.DEFAULT }; } @@ -187,10 +189,12 @@ export class UserService { public async updateUserSettings({ currency, - userId + userId, + viewMode }: { - currency: Currency; + currency?: Currency; userId: string; + viewMode?: ViewMode; }) { await this.prisma.settings.upsert({ create: { @@ -199,10 +203,12 @@ export class UserService { connect: { id: userId } - } + }, + viewMode }, update: { - currency + currency, + viewMode }, where: { userId: userId diff --git a/apps/api/src/models/portfolio.spec.ts b/apps/api/src/models/portfolio.spec.ts index e4e4ecf9b..00c3e0ec7 100644 --- a/apps/api/src/models/portfolio.spec.ts +++ b/apps/api/src/models/portfolio.spec.ts @@ -1,6 +1,13 @@ import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; 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 { DataProviderService } from '../services/data-provider.service'; @@ -120,7 +127,8 @@ describe('Portfolio', () => { Settings: { currency: Currency.CHF, updatedAt: new Date(), - userId: USER_ID + userId: USER_ID, + viewMode: ViewMode.DEFAULT }, thirdPartyId: null, updatedAt: new Date() diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index a52bf85cd..0ba57989c 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; import { bool, cleanEnv, json, num, port, str } from 'envalid'; import { Environment } from './interfaces/environment.interface'; -import { DataSource } from '.prisma/client'; @Injectable() export class ConfigurationService { diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 8c81bd1bd..e26e0dc9f 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -78,6 +78,11 @@ const routes: Routes = [ (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 // earlier diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index a7b6800aa..627701fb6 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -8,11 +8,14 @@ class="d-none d-sm-block" i18n mat-flat-button - [color]="currentRoute === 'home' ? 'primary' : null" + [color]=" + currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null + " [routerLink]="['/']" >Overview Analysis ((resolve) => { + this.dataService + .fetchUser() + .pipe( + catchError(() => { + if (state.url !== '/start') { + this.router.navigate(['/start']); + resolve(false); + return EMPTY; + } - if (isLoggedIn) { - if (state.url === '/start') { - this.router.navigate(['/home']); - return false; - } + resolve(true); + return EMPTY; + }) + ) + .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; - } - - // Not logged in - if (state.url !== '/start') { - this.router.navigate(['/start']); - return false; - } + resolve(false); + } else if ( + state.url === '/zen' && + user.settings.viewMode === ViewMode.DEFAULT + ) { + this.router.navigate(['/home']); + resolve(false); + } - return true; + resolve(true); + }); + }); } } diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 72de866d2..3f49e7891 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -79,7 +79,6 @@ export class HttpResponseInterceptor implements HttpInterceptor { } } else if (error.status === StatusCodes.UNAUTHORIZED) { this.tokenStorageService.signOut(); - this.router.navigate(['start']); } return throwError(''); diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 957b4007c..c57eacde7 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -68,9 +68,14 @@ export class AccountPageComponent implements OnDestroy, OnInit { this.update(); } - public onChangeBaseCurrency({ value: currency }: { value: Currency }) { + public onChangeUserSettings(aKey: string, aValue: string) { + const settings = { ...this.user.settings, [aKey]: aValue }; + this.dataService - .putUserSettings({ currency }) + .putUserSettings({ + baseCurrency: settings?.baseCurrency, + viewMode: settings?.viewMode + }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { this.dataService.fetchUser().subscribe((user) => { diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index e5bad92db..abd1f0fa4 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -30,14 +30,14 @@
Settings
-
- + + Base Currency + + View Mode + + Default + Zen + +
diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index ef2932096..515d1e63b 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -132,6 +132,11 @@ export class HomePageComponent implements OnDestroy, OnInit { this.update(); } + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + private openDialog(): void { const dialogRef = this.dialog.open(PerformanceChartDialog, { autoFocus: false, @@ -195,9 +200,4 @@ export class HomePageComponent implements OnDestroy, OnInit { this.cd.markForCheck(); } - - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } } diff --git a/apps/client/src/app/pages/home/home-page.module.ts b/apps/client/src/app/pages/home/home-page.module.ts index 9f3c495e2..3306228dd 100644 --- a/apps/client/src/app/pages/home/home-page.module.ts +++ b/apps/client/src/app/pages/home/home-page.module.ts @@ -1,6 +1,5 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; @@ -27,7 +26,6 @@ import { HomePageComponent } from './home-page.component'; GfPositionsModule, GfToggleModule, HomePageRoutingModule, - MatButtonModule, MatCardModule, RouterModule ], diff --git a/apps/client/src/app/pages/zen/zen-page-routing.module.ts b/apps/client/src/app/pages/zen/zen-page-routing.module.ts new file mode 100644 index 000000000..59d4cb062 --- /dev/null +++ b/apps/client/src/app/pages/zen/zen-page-routing.module.ts @@ -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 {} diff --git a/apps/client/src/app/pages/zen/zen-page.component.ts b/apps/client/src/app/pages/zen/zen-page.component.ts new file mode 100644 index 000000000..1fc5e65ee --- /dev/null +++ b/apps/client/src/app/pages/zen/zen-page.component.ts @@ -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(); + + /** + * @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(); + } +} diff --git a/apps/client/src/app/pages/zen/zen-page.html b/apps/client/src/app/pages/zen/zen-page.html new file mode 100644 index 000000000..a3e16b732 --- /dev/null +++ b/apps/client/src/app/pages/zen/zen-page.html @@ -0,0 +1,25 @@ +
+
+
+ +
+
+
+
+ +
+
+
diff --git a/apps/client/src/app/pages/zen/zen-page.module.ts b/apps/client/src/app/pages/zen/zen-page.module.ts new file mode 100644 index 000000000..117f69136 --- /dev/null +++ b/apps/client/src/app/pages/zen/zen-page.module.ts @@ -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 {} diff --git a/apps/client/src/app/pages/zen/zen-page.scss b/apps/client/src/app/pages/zen/zen-page.scss new file mode 100644 index 000000000..a0921ff14 --- /dev/null +++ b/apps/client/src/app/pages/zen/zen-page.scss @@ -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)); +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 3c724127c..66191843d 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -1,4 +1,4 @@ -import { Currency } from '.prisma/client'; +import { Currency } from '@prisma/client'; export const baseCurrency = Currency.CHF; diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 182845b71..01d3aa5be 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -1,6 +1,7 @@ -import { Currency } from '@prisma/client'; +import { Currency, ViewMode } from '@prisma/client'; export interface UserSettings { baseCurrency: Currency; locale: string; + viewMode: ViewMode; } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eb7a084c9..56f367929 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,10 +92,11 @@ model Property { } model Settings { - currency Currency - updatedAt DateTime @updatedAt - User User @relation(fields: [userId], references: [id]) - userId String @id + currency Currency? + viewMode ViewMode? + updatedAt DateTime @updatedAt + User User @relation(fields: [userId], references: [id]) + userId String @id } model User { @@ -133,6 +134,11 @@ enum DataSource { YAHOO } +enum ViewMode { + DEFAULT + ZEN +} + enum Provider { ANONYMOUS GOOGLE