Browse Source

Merge branch 'ghostfolio:main' into Overview_Graph

pull/5570/head
Batwam 2 weeks ago
committed by GitHub
parent
commit
23406cd545
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .github/FUNDING.yml
  2. 46
      CHANGELOG.md
  3. 5
      apps/api/src/app/app.module.ts
  4. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  5. 20
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  6. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  7. 38
      apps/api/src/app/import/import.service.ts
  8. 3
      apps/api/src/app/order/create-order.dto.ts
  9. 6
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  10. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  11. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  12. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  13. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  14. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  15. 68
      apps/api/src/app/portfolio/portfolio.controller.ts
  16. 10
      apps/api/src/app/portfolio/portfolio.service.ts
  17. 4
      apps/api/src/helper/object.helper.spec.ts
  18. 16
      apps/api/src/main.ts
  19. 21
      apps/api/src/services/data-provider/data-provider.service.ts
  20. 7
      apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts
  21. 105
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  22. 5
      apps/client/src/app/app-routing.module.ts
  23. 190
      apps/client/src/app/app.component.html
  24. 18
      apps/client/src/app/app.component.scss
  25. 35
      apps/client/src/app/app.component.ts
  26. 7
      apps/client/src/app/app.module.ts
  27. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  28. 6
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html
  29. 181
      apps/client/src/app/components/footer/footer.component.html
  30. 16
      apps/client/src/app/components/footer/footer.component.scss
  31. 74
      apps/client/src/app/components/footer/footer.component.ts
  32. 4
      apps/client/src/app/components/header/header.component.html
  33. 8
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  34. 16
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  35. 9
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  36. 2
      apps/client/src/app/pages/about/about-page.html
  37. 2
      apps/client/src/app/pages/about/changelog/changelog-page.html
  38. 2
      apps/client/src/app/pages/about/license/license-page.html
  39. 2
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.html
  40. 2
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.html
  41. 2
      apps/client/src/app/pages/admin/admin-page.html
  42. 2
      apps/client/src/app/pages/faq/faq-page.html
  43. 2
      apps/client/src/app/pages/home/home-page.html
  44. 2
      apps/client/src/app/pages/landing/landing-page.component.ts
  45. 105
      apps/client/src/app/pages/landing/landing-page.html
  46. 76
      apps/client/src/app/pages/landing/landing-page.scss
  47. 59
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  48. 35
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  49. 16
      apps/client/src/app/pages/portfolio/fire/fire-page.scss
  50. 2
      apps/client/src/app/pages/portfolio/portfolio-page.html
  51. 10
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  52. 43
      apps/client/src/app/pages/pricing/pricing-page.html
  53. 17
      apps/client/src/app/pages/resources/glossary/resources-glossary.component.html
  54. 2
      apps/client/src/app/pages/resources/resources-page.html
  55. 2
      apps/client/src/app/pages/user-account/user-account-page.html
  56. 2
      apps/client/src/app/pages/zen/zen-page.html
  57. 2
      apps/client/src/index.html
  58. 2
      apps/client/src/locales/messages.de.xlf
  59. 10
      libs/common/src/lib/helper.ts
  60. 4
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  61. 2
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  62. 3
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  63. 16
      libs/ui/src/lib/activities-table/activities-table.component.html
  64. 2
      libs/ui/src/lib/carousel/carousel.component.html
  65. 3
      libs/ui/src/lib/fire-calculator/fire-calculator.component.html
  66. 62
      libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts
  67. 1
      libs/ui/src/lib/logo-carousel/index.ts
  68. 7
      libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts
  69. 16
      libs/ui/src/lib/logo-carousel/logo-carousel.component.html
  70. 207
      libs/ui/src/lib/logo-carousel/logo-carousel.component.scss
  71. 13
      libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts
  72. 110
      libs/ui/src/lib/logo-carousel/logo-carousel.component.ts
  73. 293
      libs/ui/src/lib/mocks/holdings.ts
  74. 351
      libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts
  75. 8
      libs/ui/src/lib/value/value.component.html
  76. 83
      package-lock.json
  77. 7
      package.json
  78. 7
      test/import/ok/novn-buy-and-sell-partially.json
  79. 7
      test/import/ok/novn-buy-and-sell.json
  80. 53
      test/import/ok/penthouse-apartment.json

1
.github/FUNDING.yml

@ -1 +1,2 @@
buy_me_a_coffee: ghostfolio
github: ghostfolio

46
CHANGELOG.md

@ -5,6 +5,52 @@ 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
- Extended the glossary of the resources page by _Stealth Wealth_
- Extended the content of the pricing page
- Added a _Storybook_ story for the holdings table component
### Changed
- Disabled the zoom functionality in the _Progressive Web App_ (PWA)
- Improved the currency validation in the get asset profiles functionality of the data provider service
- Improved the currency validation in the search functionality of the data provider service
- Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service
- Extracted the footer to a component
- Improved the portfolio calculator unit tests to load the user currency from the exported file
### Fixed
- Respected the include indices flag in the search functionality of the _Financial Modeling Prep_ service
- Fixed an issue where the scroll position was not restored when changing pages
- Fixed the word wrap in the menus of the activities table component
- Fixed the dark mode in the _As seen in_ section on the landing page
## 2.208.0 - 2025-10-11
### Added
- Added support for configuring the safe withdrawal rate in the _FIRE_ section (experimental)
### Changed
- Changed the _As seen in_ section on the landing page to an animated carousel
- Refactored `transactionCount` to `activitiesCount` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Refactored various components to use self-closing tags
- Removed the deprecated endpoint `GET api/v1/portfolio/position/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/portfolio/position/:dataSource/:symbol/tags`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.16.3`
### Fixed
- Fixed the server startup message to properly display IPv6 addresses
- Enabled IPv6 connectivity for _Redis_ in the job queue module by setting the address family
- Fixed an issue where importing custom asset profiles failed due to validation errors
## 2.207.0 - 2025-10-08
### Added

5
apps/api/src/app/app.module.ts

@ -71,9 +71,10 @@ import { UserModule } from './user/user.module';
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
family: 0,
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD
password: process.env.REDIS_PASSWORD,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
}
}),
CacheModule,

10
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
@ -66,7 +67,14 @@ export class GhostfolioController {
});
return assetProfile;
} catch {
} catch (error) {
if (error instanceof AssetProfileInvalidError) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR

20
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -40,10 +40,7 @@ export class GhostfolioService {
private readonly propertyService: PropertyService
) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
public async getAssetProfile({ symbol }: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
@ -51,12 +48,15 @@ export class GhostfolioService {
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then(async (assetProfile) => {
this.dataProviderService
.getAssetProfiles([
{
symbol,
dataSource: dataProviderService.getName()
}
])
.then(async (assetProfiles) => {
const assetProfile = assetProfiles[symbol];
const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) {

2
apps/api/src/app/endpoints/public/public.controller.ts

@ -40,7 +40,7 @@ export class PublicController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio(
@Param('accessId') accessId
@Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });

38
apps/api/src/app/import/import.service.ts

@ -373,6 +373,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
});
@ -698,10 +699,12 @@ export class ImportService {
private async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
@ -749,6 +752,41 @@ export class ImportService {
)?.[symbol]
};
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
// Merge all fields of custom asset profiles into the validation object
Object.assign(assetProfile, {
assetClass: assetProfileInImport.assetClass,
assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
}
}
if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||

3
apps/api/src/app/order/create-order.dto.ts

@ -44,8 +44,7 @@ export class CreateOrderDto {
customCurrency?: string;
@IsEnum(DataSource)
@IsOptional()
dataSource?: DataSource;
dataSource: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)

6
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,3 +1,5 @@
import { Export } from '@ghostfolio/common/interfaces';
import { readFileSync } from 'node:fs';
export const activityDummyData = {
@ -37,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};
export function loadActivityExportFile(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')).activities;
export function loadExportFile(filePath: string): Export {
return JSON.parse(readFileSync(filePath, 'utf8'));
}

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json')
);
});
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD short sell (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
);
});
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

68
apps/api/src/app/portfolio/portfolio.controller.ts

@ -197,7 +197,7 @@ export class PortfolioController {
'filteredValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'interestInBaseCurrency',
'items',
'liabilities',
'netPerformance',
@ -610,36 +610,6 @@ export class PortfolioController {
return performanceInformation;
}
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport(
@ -699,40 +669,4 @@ export class PortfolioController {
userId: this.request.user.id
});
}
/**
* @deprecated
*/
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePositionTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
}

10
apps/api/src/app/portfolio/portfolio.service.ts

@ -778,6 +778,7 @@ export class PortfolioService {
if (activities.length === 0) {
return {
activities: [],
activitiesCount: 0,
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
@ -802,7 +803,6 @@ export class PortfolioService {
quantity: undefined,
SymbolProfile: undefined,
tags: [],
transactionCount: undefined,
value: undefined
};
}
@ -966,8 +966,8 @@ export class PortfolioService {
marketPriceMin,
SymbolProfile,
tags,
transactionCount,
activities: activitiesOfHolding,
activitiesCount: transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -1070,6 +1070,7 @@ export class PortfolioService {
marketPriceMin,
SymbolProfile,
activities: [],
activitiesCount: 0,
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
@ -1095,7 +1096,6 @@ export class PortfolioService {
},
quantity: 0,
tags: [],
transactionCount: undefined,
value: 0
};
}
@ -2105,8 +2105,8 @@ export class PortfolioService {
)
.plus(fees)
.toNumber(),
interest: interest.toNumber(),
liabilities: liabilities.toNumber(),
interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth
};

4
apps/api/src/helper/object.helper.spec.ts

@ -1536,7 +1536,7 @@ describe('redactAttributes', () => {
fireWealth: null,
grossPerformance: null,
grossPerformanceWithCurrencyEffect: null,
interest: null,
interestInBaseCurrency: null,
items: null,
liabilities: null,
totalInvestment: null,
@ -3039,7 +3039,7 @@ describe('redactAttributes', () => {
fireWealth: null,
grossPerformance: null,
grossPerformanceWithCurrencyEffect: null,
interest: null,
interestInBaseCurrency: null,
items: null,
liabilities: null,
totalInvestment: null,

16
apps/api/src/main.ts

@ -90,7 +90,21 @@ async function bootstrap() {
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`);
let address = app.getHttpServer().address();
if (typeof address === 'object') {
const addressObject = address;
let host = addressObject.address;
if (addressObject.family === 'IPv6') {
host = `[${addressObject.address}]`;
}
address = `${host}:${addressObject.port}`;
}
Logger.log(`Listening at http://${address}`);
Logger.log('');
});
}

21
apps/api/src/services/data-provider/data-provider.service.ts

@ -35,6 +35,8 @@ import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms';
import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@Injectable()
export class DataProviderService implements OnModuleInit {
private dataProviderMapping: { [dataProviderName: string]: string };
@ -106,9 +108,9 @@ export class DataProviderService implements OnModuleInit {
);
promises.push(
promise.then((symbolProfile) => {
if (symbolProfile) {
response[symbol] = symbolProfile;
promise.then((assetProfile) => {
if (isCurrency(assetProfile?.currency)) {
response[symbol] = assetProfile;
}
})
);
@ -117,6 +119,12 @@ export class DataProviderService implements OnModuleInit {
try {
await Promise.all(promises);
if (isEmpty(response)) {
throw new AssetProfileInvalidError(
'No valid asset profiles have been found'
);
}
} catch (error) {
Logger.error(error, 'DataProviderService');
@ -645,8 +653,11 @@ export class DataProviderService implements OnModuleInit {
const filteredItems = lookupItems
.filter(({ currency }) => {
// Only allow symbols with supported currency
return currency ? true : false;
if (includeIndices) {
return true;
}
return currency ? isCurrency(currency) : false;
})
.map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

7
apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts

@ -0,0 +1,7 @@
export class AssetProfileInvalidError extends Error {
public constructor(message: string) {
super(message);
this.name = 'AssetProfileInvalidError';
}
}

105
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -12,6 +12,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS
@ -49,7 +50,8 @@ export class FinancialModelingPrepService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly prismaService: PrismaService
) {
this.apiKey = this.configurationService.get(
'API_KEY_FINANCIAL_MODELING_PREP'
@ -100,6 +102,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
).then((res) => res.json());
if (!assetProfile) {
throw new Error(`${symbol} not found`);
}
const { assetClass, assetSubClass } =
this.parseAssetClass(assetProfile);
@ -220,7 +226,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
dataSource: DataSource.FINANCIAL_MODELING_PREP,
dataSource: this.getName(),
isPremium: true,
name: 'Financial Modeling Prep',
url: 'https://financialmodelingprep.com/developer/docs'
@ -359,25 +365,57 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[symbol: string]: Pick<SymbolProfile, 'currency'>;
} = {};
const quotes = await fetch(
`${this.getUrl({ version: 'stable' })}/batch-quote-short?symbols=${symbols.join(',')}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
const [assetProfileResolutions, quotes] = await Promise.all([
this.prismaService.assetProfileResolution.findMany({
where: {
dataSourceTarget: this.getDataProviderInfo().dataSource,
symbolTarget: { in: symbols }
}
}),
fetch(
`${this.getUrl({ version: 'stable' })}/batch-quote-short?symbols=${symbols.join(',')}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then(
(res) => res.json() as unknown as { price: number; symbol: string }[]
)
]);
for (const { currency, symbolTarget } of assetProfileResolutions) {
currencyBySymbolMap[symbolTarget] = { currency };
}
const resolvedSymbols = assetProfileResolutions.map(
({ symbolTarget }) => {
return symbolTarget;
}
).then((res) => res.json());
);
const symbolsToFetch = quotes
.map(({ symbol }) => {
return symbol;
})
.filter((symbol) => {
return !resolvedSymbols.includes(symbol);
});
if (symbolsToFetch.length > 0) {
await Promise.all(
symbolsToFetch.map(async (symbol) => {
const assetProfile = await this.getAssetProfile({
requestTimeout,
symbol
});
await Promise.all(
quotes.map(({ symbol }) => {
return this.getAssetProfile({
requestTimeout,
symbol
}).then((assetProfile) => {
if (assetProfile?.currency) {
currencyBySymbolMap[symbol] = { currency: assetProfile.currency };
currencyBySymbolMap[symbol] = {
currency: assetProfile.currency
};
}
});
})
);
})
);
}
for (const { price, symbol } of quotes) {
let marketState: MarketState = 'delayed';
@ -394,7 +432,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
marketState,
currency: currencyBySymbolMap[symbol]?.currency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP,
dataSource: this.getDataProviderInfo().dataSource,
marketPrice: price
};
}
@ -420,6 +458,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
public async search({
includeIndices = false,
query,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetSearchParams): Promise<LookupResponse> {
@ -466,17 +505,25 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
).then((res) => res.json());
items = result.map(({ currency, name, symbol }) => {
return {
currency,
symbol,
assetClass: undefined, // TODO
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.formatName({ name })
};
});
items = result
.filter(({ symbol }) => {
if (includeIndices === false && symbol.startsWith('^')) {
return false;
}
return true;
})
.map(({ currency, name, symbol }) => {
return {
currency,
symbol,
assetClass: undefined, // TODO
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.formatName({ name })
};
});
}
} catch (error) {
let message = error;

5
apps/client/src/app/app-routing.module.ts

@ -155,8 +155,9 @@ const routes: Routes = [
// Preload all lazy loaded modules with the attribute preload === true
{
anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService
// enableTracing: true // <-- debugging purposes only
// enableTracing: true, // <-- debugging purposes only
preloadingStrategy: ModulePreloadService,
scrollPositionRestoration: 'top'
}
)
],

190
apps/client/src/app/app.component.html

@ -11,8 +11,8 @@
>
<span i18n>You are using the Live Demo.</span>
<span class="a ml-2 p-1" i18n>Create Account</span>
</div></a
>
</div>
</a>
}
@if (!canCreateAccount && user?.systemMessage) {
<div
@ -43,191 +43,11 @@
</header>
<main role="main">
<router-outlet></router-outlet>
<router-outlet />
</main>
@if (showFooter) {
<footer class="justify-content-center overflow-hidden py-4 w-100">
<div class="container">
<div class="mb-3 row">
<div class="col-sm">
<a [routerLink]="['/']"><gf-logo /></a>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled">
@if (hasPermissionToAccessFearAndGreedIndex) {
<li>
<a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li>
}
<li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkBlog">Blog</a>
</li>
}
<li>
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li>
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</li>
}
@if (!hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li>
}
@if (hasPermissionForStatistics) {
<li>
<a [routerLink]="routerLinkOpenStartup">Open Startup</a>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a
>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutTermsOfService"
>Terms of Service</a
>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a
class="align-items-baseline d-flex"
href="https://status.ghostfol.io"
target="_blank"
title="Ghostfolio Status"
>Status<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
}
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Community</div>
<ul class="list-unstyled">
<li>
<a
class="align-items-baseline d-flex"
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://linkedin.com/company/ghostfolio"
target="_blank"
title="Follow Ghostfolio on LinkedIn"
>LinkedIn<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://x.com/ghostfolio_"
target="_blank"
title="Follow Ghostfolio on X (formerly Twitter)"
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>&nbsp;</li>
<!--
<li>
<a href="../ca" title="Ghostfolio en català">Català</a>
</li>
-->
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
</li>
<li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li>
<li>
<a href="../en" title="Ghostfolio in English">English</a>
</li>
<li>
<a href="../es" title="Ghostfolio in Español">Español</a>
</li>
<li>
<a href="../fr" title="Ghostfolio en Français">Français</a>
</li>
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
<!--
<li>
<a href="../uk" title="Ghostfolio in Українська">Українська</a>
</li>
-->
</ul>
</div>
</div>
<div class="mb-2 row text-center">
<div class="col">
© 2021 - {{ currentYear }}
<a href="https://ghostfol.io">Ghostfolio</a>
</div>
</div>
<div class="row text-center text-muted">
<div class="col">
<small class="d-block" i18n
>The risk of loss in trading can be substantial. It is not advisable
to invest money you may need in the short term.</small
>
</div>
</div>
</div>
<div class="container d-none d-md-block mt-5">
<div class="row justify-content-center">
<div class="font-weight-bold line-height-1 logotype">Ghostfolio</div>
</div>
</div>
<footer class="justify-content-center overflow-hidden w-100">
<gf-footer class="py-4" [info]="info" [user]="user" />
</footer>
}

18
apps/client/src/app/app.component.scss

@ -34,18 +34,6 @@
}
}
footer {
background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%;
.logotype {
font-size: 13vw;
letter-spacing: -0.03em;
margin-bottom: -5svw;
opacity: 0.05;
}
}
header {
height: var(--mat-toolbar-standard-height);
}
@ -54,9 +42,3 @@
min-height: calc(100svh - var(--mat-toolbar-standard-height));
}
}
:host-context(.theme-dark) {
footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
}

35
apps/client/src/app/app.component.ts

@ -52,36 +52,16 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean;
public currentRoute: string;
public currentSubRoute: string;
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false;
public info: InfoItem;
public pageTitle: string;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
public showFooter = false;
public user: User;
@ -126,21 +106,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionForStatistics = hasPermission(
this.info?.globalPermissions,
permissions.enableStatistics
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
this.hasPromotion =
!!this.info?.subscriptionOffer?.coupon ||
!!this.info?.subscriptionOffer?.durationExtension;

7
apps/client/src/app/app.module.ts

@ -1,5 +1,3 @@
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { Platform } from '@angular/cdk/platform';
import {
provideHttpClient,
@ -20,7 +18,6 @@ import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { provideIonicAngular } from '@ionic/angular/standalone';
import { IonIcon } from '@ionic/angular/standalone';
import { provideMarkdown } from 'ngx-markdown';
import { provideNgxSkeletonLoader } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
@ -30,6 +27,7 @@ import { CustomDateAdapter } from './adapter/custom-date-adapter';
import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component';
import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
@ -47,10 +45,9 @@ export function NgxStripeFactory(): string {
AppRoutingModule,
BrowserAnimationsModule,
BrowserModule,
GfFooterComponent,
GfHeaderComponent,
GfLogoComponent,
GfNotificationModule,
IonIcon,
MatAutocompleteModule,
MatChipsModule,
MatNativeDateModule,

3
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -64,8 +64,7 @@
[checked]="selection.isSelected(element)"
(change)="$event ? selection.toggle(element) : null"
(click)="$event.stopPropagation()"
>
</mat-checkbox>
/>
}
</td>
</ng-container>

6
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html

@ -14,11 +14,9 @@
>
<mat-radio-button name="auto" value="auto" />
<label class="m-0" for="auto" i18n>Search</label>
<mat-radio-button class="ml-3" name="manual" value="manual">
</mat-radio-button>
<mat-radio-button class="ml-3" name="manual" value="manual" />
<label class="m-0" for="manual" i18n>Add Manually</label>
<mat-radio-button class="ml-3" name="currency" value="currency">
</mat-radio-button>
<mat-radio-button class="ml-3" name="currency" value="currency" />
<label class="m-0" for="currency" i18n>Add Currency</label>
</mat-radio-group>
</div>

181
apps/client/src/app/components/footer/footer.component.html

@ -0,0 +1,181 @@
<div class="container">
<div class="mb-3 row">
<div class="col-sm">
<a [routerLink]="['/']"><gf-logo /></a>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled">
@if (hasPermissionToAccessFearAndGreedIndex) {
<li>
<a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li>
}
<li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkBlog">Blog</a>
</li>
}
<li>
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li>
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</li>
}
@if (!hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li>
}
@if (hasPermissionForStatistics) {
<li>
<a [routerLink]="routerLinkOpenStartup">Open Startup</a>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a
>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutTermsOfService"
>Terms of Service</a
>
</li>
}
@if (hasPermissionForSubscription) {
<li>
<a
class="align-items-baseline d-flex"
href="https://status.ghostfol.io"
target="_blank"
title="Ghostfolio Status"
>Status<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
}
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Community</div>
<ul class="list-unstyled">
<li>
<a
class="align-items-baseline d-flex"
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://linkedin.com/company/ghostfolio"
target="_blank"
title="Follow Ghostfolio on LinkedIn"
>LinkedIn<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://x.com/ghostfolio_"
target="_blank"
title="Follow Ghostfolio on X (formerly Twitter)"
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>&nbsp;</li>
<!--
<li>
<a href="../ca" title="Ghostfolio en català">Català</a>
</li>
-->
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
</li>
<li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li>
<li>
<a href="../en" title="Ghostfolio in English">English</a>
</li>
<li>
<a href="../es" title="Ghostfolio in Español">Español</a>
</li>
<li>
<a href="../fr" title="Ghostfolio en Français">Français</a>
</li>
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
<!--
<li>
<a href="../uk" title="Ghostfolio in Українська">Українська</a>
</li>
-->
</ul>
</div>
</div>
<div class="mb-2 row text-center">
<div class="col">
© 2021 - {{ currentYear }}
<a href="https://ghostfol.io">Ghostfolio</a>
</div>
</div>
<div class="row text-center text-muted">
<div class="col">
<small class="d-block" i18n
>The risk of loss in trading can be substantial. It is not advisable to
invest money you may need in the short term.</small
>
</div>
</div>
</div>
<div class="container d-none d-md-block mt-5">
<div class="row justify-content-center">
<div class="font-weight-bold line-height-1 logotype">Ghostfolio</div>
</div>
</div>

16
apps/client/src/app/components/footer/footer.component.scss

@ -0,0 +1,16 @@
:host {
background-color: rgba(var(--palette-foreground-text), 0.05);
display: block;
font-size: 90%;
.logotype {
font-size: 13vw;
letter-spacing: -0.03em;
margin-bottom: -5svw;
opacity: 0.05;
}
}
:host-context(.theme-dark) {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}

74
apps/client/src/app/components/footer/footer.component.ts

@ -0,0 +1,74 @@
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
Input,
OnChanges
} from '@angular/core';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, GfLogoComponent, IonIcon, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-footer',
styleUrls: ['./footer.component.scss'],
templateUrl: './footer.component.html'
})
export class GfFooterComponent implements OnChanges {
@Input() public info: InfoItem;
@Input() public user: User;
public currentYear = new Date().getFullYear();
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
public constructor() {
addIcons({
openOutline
});
}
public ngOnChanges() {
this.hasPermissionForStatistics = hasPermission(
this.info?.globalPermissions,
permissions.enableStatistics
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
}
}

4
apps/client/src/app/components/header/header.component.html

@ -414,8 +414,8 @@
class="d-none d-sm-block no-min-width p-1"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
><ion-icon name="logo-github"></ion-icon
></a>
><ion-icon name="logo-github"
/></a>
</li>
<li class="list-inline-item">
<button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">

8
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -100,6 +100,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html'
})
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public activitiesCount: number;
public activityForm: FormGroup;
public accounts: Account[];
public assetClass: string;
@ -151,8 +152,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public tagsAvailable: Tag[];
public totalItems: number;
public transactionCount: number;
public user: User;
public value: number;
@ -261,6 +260,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
activitiesCount,
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
@ -279,9 +279,9 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
quantity,
SymbolProfile,
tags,
transactionCount,
value
}) => {
this.activitiesCount = activitiesCount;
this.averagePrice = averagePrice;
if (
@ -429,8 +429,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
this.transactionCount = transactionCount;
this.totalItems = transactionCount;
this.value = value;
if (SymbolProfile?.assetClass) {

16
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -223,9 +223,9 @@
<gf-value
size="medium"
[locale]="data.locale"
[value]="transactionCount"
[value]="activitiesCount"
>
@if (transactionCount === 1) {
@if (activitiesCount === 1) {
<ng-container i18n>Activity</ng-container>
} @else {
<ng-container i18n>Activities</ng-container>
@ -363,7 +363,7 @@
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="totalItems"
[totalItems]="activitiesCount"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
@ -438,8 +438,9 @@
}"
[routerLink]="routerLinkAdminControlMarketData"
(click)="onClose()"
><ion-icon class="mr-1" name="create-outline"></ion-icon
><span i18n>Manage Asset Profile</span>...</a
><ion-icon class="mr-1" name="create-outline" /><span i18n
>Manage Asset Profile</span
>...</a
>
}
@if (
@ -447,8 +448,9 @@
data.hasPermissionToReportDataGlitch === true
) {
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span>...</a
><ion-icon class="mr-1" name="flag-outline" /><span i18n
>Report Data Glitch</span
>...</a
>
}
</div>

9
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -242,7 +242,10 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end">
@if (summary?.liabilities || summary?.liabilities === 0) {
@if (
summary?.liabilitiesInBaseCurrency ||
summary?.liabilitiesInBaseCurrency === 0
) {
<span class="mr-1">-</span>
}
<gf-value
@ -250,7 +253,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.liabilities"
[value]="isLoading ? undefined : summary?.liabilitiesInBaseCurrency"
/>
</div>
</div>
@ -299,7 +302,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.interest"
[value]="isLoading ? undefined : summary?.interestInBaseCurrency"
/>
</div>
</div>

2
apps/client/src/app/pages/about/about-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/app/pages/about/changelog/changelog-page.html

@ -46,7 +46,7 @@
}
<div class="changelog">
<markdown [src]="'../assets/CHANGELOG.md'" (load)="onLoad()"></markdown>
<markdown [src]="'../assets/CHANGELOG.md'" (load)="onLoad()" />
</div>
</div>
</div>

2
apps/client/src/app/pages/about/license/license-page.html

@ -3,7 +3,7 @@
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>License</h1>
<div>
<markdown [src]="'../assets/LICENSE'"></markdown>
<markdown [src]="'../assets/LICENSE'" />
</div>
</div>
</div>

2
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.html

@ -2,7 +2,7 @@
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Privacy Policy</h1>
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
<markdown [src]="'../assets/privacy-policy.md'" />
</div>
</div>
</div>

2
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.html

@ -4,7 +4,7 @@
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Terms of Service
</h1>
<markdown [src]="'../assets/terms-of-service.md'"></markdown>
<markdown [src]="'../assets/terms-of-service.md'" />
</div>
</div>
</div>

2
apps/client/src/app/pages/admin/admin-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/app/pages/faq/faq-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/app/pages/home/home-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/app/pages/landing/landing-page.component.ts

@ -4,6 +4,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfCarouselComponent } from '@ghostfolio/ui/carousel';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { GfLogoCarouselComponent } from '@ghostfolio/ui/logo-carousel';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
@ -27,6 +28,7 @@ import { Subject } from 'rxjs';
imports: [
CommonModule,
GfCarouselComponent,
GfLogoCarouselComponent,
GfLogoComponent,
GfValueComponent,
GfWorldMapChartComponent,

105
apps/client/src/app/pages/landing/landing-page.html

@ -114,109 +114,8 @@
<div class="col-12 text-center text-muted">
<small i18n>As seen in</small>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-alternative-to mask"
href="https://alternativeto.net"
target="_blank"
title="AlternativeTo - Crowdsourced software recommendations"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-awesome"
href="https://github.com/awesome-selfhosted/awesome-selfhosted"
target="_blank"
title="Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-dev-community mask"
href="https://dev.to"
target="_blank"
title="DEV Community - A constructive and inclusive social network for software developers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-hacker-news mask"
href="https://news.ycombinator.com"
target="_blank"
title="Hacker News"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-openalternative mask"
href="https://openalternative.co"
target="_blank"
title="OpenAlternative: Open Source Alternatives to Popular Software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-privacy-tools mask"
href="https://www.privacytools.io"
target="_blank"
title="Privacy Tools: Software Alternatives and Encryption"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-product-hunt"
href="https://www.producthunt.com"
target="_blank"
title="Product Hunt – The best new products in tech."
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-reddit mask"
href="https://www.reddit.com"
target="_blank"
title="Reddit - Dive into anything"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sackgeld mask"
href="https://www.sackgeld.com"
target="_blank"
title="Sackgeld.com – Apps für ein höheres Sackgeld"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-selfh-st mask"
href="https://selfh.st"
target="_blank"
title="selfh.st — Self-hosted content and software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sourceforge mask"
href="https://sourceforge.net"
target="_blank"
title="SourceForge: The Complete Open-Source and Business Software Platform"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-umbrel mask"
href="https://umbrel.com"
target="_blank"
title="Umbrel — A personal server OS for self-hosting"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-unraid mask"
href="https://unraid.net"
target="_blank"
title="Unraid | Unleash Your Hardware"
></a>
<div class="col-12">
<gf-logo-carousel class="py-3" />
</div>
</div>

76
apps/client/src/app/pages/landing/landing-page.scss

@ -34,69 +34,6 @@
&.logo-agplv3 {
mask-image: url('/assets/images/logo-AGPLv3.svg');
}
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
}
.outro-inner-container {
@ -128,18 +65,7 @@
}
.logo {
&.logo-agplv3,
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-openalternative,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
&.logo-agplv3 {
background-color: rgba(var(--light-primary-text));
}
}

59
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -7,8 +7,10 @@ import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { NgStyle } from '@angular/common';
import { CommonModule, NgStyle } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl } from '@angular/forms';
import { Big } from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,11 +19,14 @@ import { takeUntil } from 'rxjs/operators';
@Component({
imports: [
CommonModule,
FormsModule,
GfFireCalculatorComponent,
GfPremiumIndicatorComponent,
GfValueComponent,
NgStyle,
NgxSkeletonLoaderModule
NgxSkeletonLoaderModule,
ReactiveFormsModule
],
selector: 'gf-fire-page',
styleUrls: ['./fire-page.scss'],
@ -33,6 +38,8 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false;
public safeWithdrawalRateControl = new FormControl<number>(undefined);
public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045];
public user: User;
public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: Big;
@ -70,11 +77,7 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
};
}
this.withdrawalRatePerYear = Big(
this.fireWealth.today.valueInBaseCurrency
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
this.calculateWithdrawalRates();
this.isLoading = false;
@ -88,6 +91,12 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!impersonationId;
});
this.safeWithdrawalRateControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((value) => {
this.onSafeWithdrawalRateChange(Number(value));
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -102,6 +111,13 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.safeWithdrawalRateControl.setValue(
this.user.settings.safeWithdrawalRate,
{ emitEvent: false }
);
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck();
}
});
@ -141,6 +157,25 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
});
});
}
public onSafeWithdrawalRateChange(safeWithdrawalRate: number) {
this.dataService
.putUserSetting({ safeWithdrawalRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck();
});
});
}
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
@ -180,4 +215,14 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private calculateWithdrawalRates() {
if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) {
this.withdrawalRatePerYear = new Big(
this.fireWealth.today.valueInBaseCurrency
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
}
}
}

35
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -105,15 +105,32 @@
</span>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>and a safe withdrawal rate (SWR) of</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="1"
[value]="user?.settings?.safeWithdrawalRate" /></span
>.
@if (
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
) {
<select
class="border-0 cursor-pointer d-inline-block font-weight-bold safe-withdrawal-rate-select"
[formControl]="safeWithdrawalRateControl"
>
@for (rate of safeWithdrawalRateOptions; track rate) {
<option [value]="rate">
{{ rate | percent: '1.1-1' }}
</option>
}</select
>.
} @else {
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="1"
[value]="user?.settings?.safeWithdrawalRate" /></span
>.
}
</div>
}
</div>

16
apps/client/src/app/pages/portfolio/fire/fire-page.scss

@ -1,3 +1,19 @@
:host {
display: block;
.safe-withdrawal-rate-select {
background-color: transparent;
color: rgb(var(--dark-primary-text));
&:focus {
box-shadow: none;
outline: 0;
}
}
}
:host-context(.theme-dark) {
.safe-withdrawal-rate-select {
color: rgb(var(--light-primary-text));
}
}

2
apps/client/src/app/pages/portfolio/portfolio-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

10
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -69,6 +69,16 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public professionalDataProviderTooltipPremium = translate(
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
);
public referralBrokers = [
'DEGIRO',
'finpension',
'frankly',
'Interactive Brokers',
'Mintos',
'Swissquote',
'VIAC',
'Zak'
];
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public user: User;

43
apps/client/src/app/pages/pricing/pricing-page.html

@ -326,16 +326,43 @@
<div class="row">
<div class="col mt-3">
<p>
If you plan to open an account at <i>DEGIRO</i>, <i>finpension</i>,
<i>frankly</i>, <i>Interactive Brokers</i>, <i>Swissquote</i>,
<i>VIAC</i>, or <i>Zak</i>, please
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..."
<ng-container i18n>If you plan to open an account at</ng-container>
<ng-container>&nbsp;</ng-container>
@for (
broker of referralBrokers;
track broker;
let i = $index;
let last = $last
) {
<i>{{ broker }}</i>
@if (last) {
<span>, </span>
} @else {
@if (i === referralBrokers.length - 2) {
<ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container>
} @else {
<span>, </span>
}
}
}
<ng-container i18n>please</ng-container>
<ng-container>&nbsp;</ng-container>
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..." i18n
>contact us</a
>
to use our referral link and get a Ghostfolio Premium membership for
one year. Looking for a student discount? Request it
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a>
with your university e-mail address.
<ng-container>&nbsp;</ng-container>
<ng-container i18n
>to use our referral link and get a Ghostfolio Premium membership
for one year</ng-container
>. <ng-container i18n>Looking for a student discount?</ng-container>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>Request it</ng-container>
<ng-container>&nbsp;</ng-container>
<a href="mailto:hi@ghostfol.io?Subject=Student Discount" i18n>here</a>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>with your university e-mail address</ng-container>.
</p>
</div>
</div>

17
apps/client/src/app/pages/resources/glossary/resources-glossary.component.html

@ -132,6 +132,23 @@
</div>
</div>
</div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Stealth Wealth</h3>
<div class="mb-1">
Stealth wealth is a lifestyle choice where you don’t openly show
off your wealth, but instead live quietly to maintain privacy and
security.
</div>
<div>
<a
href="https://en.wikipedia.org/wiki/Stealth_wealth"
target="_blank"
>Stealth Wealth →</a
>
</div>
</div>
</div>
</div>
</div>
</div>

2
apps/client/src/app/pages/resources/resources-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/app/pages/user-account/user-account-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/app/pages/zen/zen-page.html

@ -1,5 +1,5 @@
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
<router-outlet />
</mat-tab-nav-panel>
<nav

2
apps/client/src/index.html

@ -13,7 +13,7 @@
<meta content="${rootUrl}/${featureGraphicPath}" name="twitter:image" />
<meta content="${title}" name="twitter:title" />
<meta
content="initial-scale=1, viewport-fit=cover, width=device-width"
content="initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, width=device-width"
name="viewport"
/>
<meta content="#FFFFFF" name="theme-color" />

2
apps/client/src/locales/messages.de.xlf

@ -5409,7 +5409,7 @@
</trans-unit>
<trans-unit id="3302046820145091217" datatype="html">
<source>,</source>
<target state="translated">entnehmen,</target>
<target state="translated">&nbsp;entnehmen,</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">93</context>

10
libs/common/src/lib/helper.ts

@ -1,7 +1,7 @@
import * as currencies from '@dinero.js/currencies';
import { NumberParser } from '@internationalized/number';
import { Type as ActivityType, DataSource, MarketData } from '@prisma/client';
import { Big } from 'big.js';
import { isISO4217CurrencyCode } from 'class-validator';
import {
getDate,
getMonth,
@ -340,8 +340,12 @@ export function interpolate(template: string, context: any) {
});
}
export function isCurrency(aCurrency = '') {
return currencies[aCurrency] || isDerivedCurrency(aCurrency);
export function isCurrency(aCurrency: string) {
if (!aCurrency) {
return false;
}
return isISO4217CurrencyCode(aCurrency) || isDerivedCurrency(aCurrency);
}
export function isDerivedCurrency(aCurrency: string) {

4
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -20,8 +20,8 @@ export interface PortfolioSummary extends PortfolioPerformance {
fireWealth: FireWealth;
grossPerformance: number;
grossPerformanceWithCurrencyEffect: number;
interest: number;
liabilities: number;
interestInBaseCurrency: number;
liabilitiesInBaseCurrency: number;
totalBuy: number;
totalSell: number;
totalValueInBaseCurrency?: number;

2
libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts

@ -10,6 +10,7 @@ import { Tag } from '@prisma/client';
export interface PortfolioHoldingResponse {
activities: Activity[];
activitiesCount: number;
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
@ -34,6 +35,5 @@ export interface PortfolioHoldingResponse {
quantity: number;
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: number;
value: number;
}

3
libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts

@ -112,6 +112,7 @@ export const Loading: Story = {
accounts: undefined,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false,
locale: 'en-US',
showActions: false,
showAllocationInPercentage: false,
@ -128,6 +129,7 @@ export const Default: Story = {
accounts,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false,
locale: 'en-US',
showActions: false,
showAllocationInPercentage: false,
@ -147,6 +149,7 @@ export const WithoutFooter: Story = {
accounts,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false,
locale: 'en-US',
showActions: false,
showAllocationInPercentage: false,

16
libs/ui/src/lib/activities-table/activities-table.component.html

@ -105,9 +105,7 @@
</ng-container>
<ng-container matColumnDef="importStatus">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n></ng-container>
</th>
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.error) {
<div
@ -363,7 +361,11 @@
<ion-icon name="ellipsis-vertical" />
</button>
}
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<mat-menu
#activitiesMenu="matMenu"
class="no-max-width"
xPosition="before"
>
@if (hasPermissionToCreateActivity) {
<button
class="align-items-center d-flex"
@ -427,7 +429,11 @@
<ion-icon name="ellipsis-horizontal" />
</button>
}
<mat-menu #activityMenu="matMenu" xPosition="before">
<mat-menu
#activityMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onUpdateActivity(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />

2
libs/ui/src/lib/carousel/carousel.component.html

@ -13,7 +13,7 @@
<div #contentWrapper class="overflow-hidden" role="region">
<div #list class="d-flex carousel-content" role="list" tabindex="0">
<ng-content></ng-content>
<ng-content />
</div>
</div>

3
libs/ui/src/lib/fire-calculator/fire-calculator.component.html

@ -52,8 +52,7 @@
startView="multi-year"
[disabled]="hasPermissionToUpdateUserSettings !== true"
(monthSelected)="setMonthAndYear($event, datepicker)"
>
</mat-datepicker>
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">

62
libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts

@ -0,0 +1,62 @@
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfEntityLogoComponent } from '../entity-logo';
import { holdings } from '../mocks/holdings';
import { GfValueComponent } from '../value';
import { GfHoldingsTableComponent } from './holdings-table.component';
export default {
title: 'Holdings Table',
component: GfHoldingsTableComponent,
decorators: [
moduleMetadata({
imports: [
CommonModule,
GfEntityLogoComponent,
GfValueComponent,
MatButtonModule,
MatDialogModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule
]
})
]
} as Meta<GfHoldingsTableComponent>;
type Story = StoryObj<GfHoldingsTableComponent>;
export const Loading: Story = {
args: {
holdings: undefined,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false,
hasPermissionToShowQuantities: true,
hasPermissionToShowValues: true,
locale: 'en-US',
pageSize: Number.MAX_SAFE_INTEGER
}
};
export const Default: Story = {
args: {
holdings,
baseCurrency: 'USD',
deviceType: 'desktop',
hasPermissionToOpenDetails: false,
hasPermissionToShowQuantities: true,
hasPermissionToShowValues: true,
locale: 'en-US',
pageSize: Number.MAX_SAFE_INTEGER
}
};

1
libs/ui/src/lib/logo-carousel/index.ts

@ -0,0 +1 @@
export * from './logo-carousel.component';

7
libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface LogoItem {
className: string;
isMask?: boolean;
name: string;
title: string;
url: string;
}

16
libs/ui/src/lib/logo-carousel/logo-carousel.component.html

@ -0,0 +1,16 @@
<div class="logo-carousel-container overflow-hidden position-relative w-100">
<div class="align-items-center d-flex logo-carousel-track">
@for (logo of logosRepeated; track $index) {
<div class="logo-carousel-item">
<a
class="d-block logo"
target="_blank"
[attr.aria-label]="logo.name"
[class]="logo.className + (logo.isMask ? ' mask' : '')"
[href]="logo.url"
[title]="logo.title"
></a>
</div>
}
</div>
</div>

207
libs/ui/src/lib/logo-carousel/logo-carousel.component.scss

@ -0,0 +1,207 @@
:host {
display: block;
overflow: hidden;
position: relative;
width: 100%;
.logo-carousel-container {
&::before,
&::after {
content: '';
height: 100%;
pointer-events: none;
position: absolute;
top: 0;
width: 100px;
z-index: 2;
}
&::before {
background: linear-gradient(
to right,
var(--light-background) 0%,
rgba(var(--palette-background-background), 0) 100%
);
left: 0;
}
&::after {
background: linear-gradient(
to left,
var(--light-background) 0%,
rgba(var(--palette-background-background), 0) 100%
);
right: 0;
}
@media (max-width: 768px) {
&::before,
&::after {
width: 50px;
}
}
@media (max-width: 576px) {
&::before,
&::after {
width: 30px;
}
}
.logo-carousel-track {
animation: scroll 60s linear infinite;
width: fit-content;
&:hover {
animation-play-state: paused;
}
.logo-carousel-item {
flex-shrink: 0;
min-width: 200px;
padding: 0 2rem;
@media (max-width: 768px) {
min-width: 150px;
padding: 0 1.5rem;
}
@media (max-width: 576px) {
min-width: 120px;
padding: 0 1rem;
}
.logo {
height: 3rem;
transition:
opacity 0.3s ease,
transform 0.3s ease;
width: 7.5rem;
&:hover {
opacity: 0.8;
}
&.mask {
background-color: rgba(var(--dark-secondary-text));
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
@media (max-width: 768px) {
height: 2.5rem;
width: 6rem;
}
@media (max-width: 576px) {
height: 2rem;
width: 5rem;
}
}
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
}
}
}
:host-context(.theme-dark) {
.logo-carousel-container {
&::before {
background: linear-gradient(
to right,
var(--dark-background) 0%,
rgba(var(--palette-background-background-dark), 0) 100%
);
}
&::after {
background: linear-gradient(
to left,
var(--dark-background) 0%,
rgba(var(--palette-background-background-dark), 0) 100%
);
}
.logo-carousel-track {
.logo-carousel-item {
.logo {
&.mask {
background-color: rgba(var(--light-secondary-text));
}
}
}
}
}
}

13
libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { GfLogoCarouselComponent } from './logo-carousel.component';
const meta: Meta<GfLogoCarouselComponent> = {
title: 'Logo Carousel',
component: GfLogoCarouselComponent
};
export default meta;
type Story = StoryObj<GfLogoCarouselComponent>;
export const Default: Story = {};

110
libs/ui/src/lib/logo-carousel/logo-carousel.component.ts

@ -0,0 +1,110 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LogoItem } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
selector: 'gf-logo-carousel',
styleUrls: ['./logo-carousel.component.scss'],
templateUrl: './logo-carousel.component.html'
})
export class GfLogoCarouselComponent {
public readonly logos: LogoItem[] = [
{
className: 'logo-alternative-to',
isMask: true,
name: 'AlternativeTo',
title: 'AlternativeTo - Crowdsourced software recommendations',
url: 'https://alternativeto.net'
},
{
className: 'logo-awesome',
name: 'Awesome Selfhosted',
title:
'Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers',
url: 'https://github.com/awesome-selfhosted/awesome-selfhosted'
},
{
className: 'logo-dev-community',
isMask: true,
name: 'DEV Community',
title:
'DEV Community - A constructive and inclusive social network for software developers',
url: 'https://dev.to'
},
{
className: 'logo-hacker-news',
isMask: true,
name: 'Hacker News',
title: 'Hacker News',
url: 'https://news.ycombinator.com'
},
{
className: 'logo-openalternative',
isMask: true,
name: 'OpenAlternative',
title: 'OpenAlternative: Open Source Alternatives to Popular Software',
url: 'https://openalternative.co'
},
{
className: 'logo-privacy-tools',
isMask: true,
name: 'Privacy Tools',
title: 'Privacy Tools: Software Alternatives and Encryption',
url: 'https://www.privacytools.io'
},
{
className: 'logo-product-hunt',
name: 'Product Hunt',
title: 'Product Hunt – The best new products in tech.',
url: 'https://www.producthunt.com'
},
{
className: 'logo-reddit',
isMask: true,
name: 'Reddit',
title: 'Reddit - Dive into anything',
url: 'https://www.reddit.com'
},
{
className: 'logo-sackgeld',
isMask: true,
name: 'Sackgeld',
title: 'Sackgeld.com – Apps für ein höheres Sackgeld',
url: 'https://www.sackgeld.com'
},
{
className: 'logo-selfh-st',
isMask: true,
name: 'selfh.st',
title: 'selfh.st — Self-hosted content and software',
url: 'https://selfh.st'
},
{
className: 'logo-sourceforge',
isMask: true,
name: 'SourceForge',
title:
'SourceForge: The Complete Open-Source and Business Software Platform',
url: 'https://sourceforge.net'
},
{
className: 'logo-umbrel',
isMask: true,
name: 'Umbrel',
title: 'Umbrel — A personal server OS for self-hosting',
url: 'https://umbrel.com'
},
{
className: 'logo-unraid',
isMask: true,
name: 'Unraid',
title: 'Unraid | Unleash Your Hardware',
url: 'https://unraid.net'
}
];
public readonly logosRepeated = [...this.logos, ...this.logos];
}

293
libs/ui/src/lib/mocks/holdings.ts

@ -0,0 +1,293 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
export const holdings: PortfolioPosition[] = [
{
allocationInPercentage: 0.042990776363386086,
assetClass: 'EQUITY' as any,
assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any,
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO' as any,
dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 3856,
grossPerformancePercent: 0.46047289228564603,
grossPerformancePercentWithCurrencyEffect: 0.46047289228564603,
grossPerformanceWithCurrencyEffect: 3856,
holdings: [],
investment: 8374,
marketPrice: 244.6,
name: 'Apple Inc',
netPerformance: 3855,
netPerformancePercent: 0.460353475041796,
netPerformancePercentWithCurrencyEffect: 0.036440677966101696,
netPerformanceWithCurrencyEffect: 430,
quantity: 50,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'AAPL',
tags: [],
transactionCount: 1,
url: 'https://www.apple.com',
valueInBaseCurrency: 12230
},
{
allocationInPercentage: 0.02377401948293552,
assetClass: 'EQUITY' as any,
assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any,
assetSubClassLabel: 'Stock',
countries: [
{
code: 'DE',
weight: 1,
continent: 'Europe',
name: 'Germany'
}
],
currency: 'EUR',
dataSource: 'YAHOO' as any,
dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'),
dividend: 192,
grossPerformance: 2226.700251889169,
grossPerformancePercent: 0.49083842309827874,
grossPerformancePercentWithCurrencyEffect: 0.29306136948826367,
grossPerformanceWithCurrencyEffect: 1532.8272791336772,
holdings: [],
investment: 4536.523929471033,
marketPrice: 322.2,
name: 'Allianz SE',
netPerformance: 2222.2921914357685,
netPerformancePercent: 0.48986674069961134,
netPerformancePercentWithCurrencyEffect: 0.034489367670592026,
netPerformanceWithCurrencyEffect: 225.48257403052068,
quantity: 20,
sectors: [
{
name: 'Financial Services',
weight: 1
}
],
symbol: 'ALV.DE',
tags: [],
transactionCount: 2,
url: 'https://www.allianz.com',
valueInBaseCurrency: 6763.224181360202
},
{
allocationInPercentage: 0.08038536990007467,
assetClass: 'EQUITY' as any,
assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any,
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO' as any,
dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 12758.05,
grossPerformancePercent: 1.2619300787837724,
grossPerformancePercentWithCurrencyEffect: 1.2619300787837724,
grossPerformanceWithCurrencyEffect: 12758.05,
holdings: [],
investment: 10109.95,
marketPrice: 228.68,
name: 'Amazon.com, Inc.',
netPerformance: 12677.26,
netPerformancePercent: 1.253938941339967,
netPerformancePercentWithCurrencyEffect: -0.037866008722316276,
netPerformanceWithCurrencyEffect: -899.99926757812,
quantity: 100,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'AMZN',
tags: [],
transactionCount: 1,
url: 'https://www.aboutamazon.com',
valueInBaseCurrency: 22868
},
{
allocationInPercentage: 0.19216416482928922,
assetClass: 'LIQUIDITY' as any,
assetClassLabel: 'Liquidity',
assetSubClass: 'CRYPTOCURRENCY' as any,
assetSubClassLabel: 'Cryptocurrency',
countries: [],
currency: 'USD',
dataSource: 'COINGECKO' as any,
dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'),
dividend: 0,
grossPerformance: 52666.7898248,
grossPerformancePercent: 26.333394912400003,
grossPerformancePercentWithCurrencyEffect: 26.333394912400003,
grossPerformanceWithCurrencyEffect: 52666.7898248,
holdings: [],
investment: 1999.9999999999998,
marketPrice: 97364,
name: 'Bitcoin',
netPerformance: 52636.8898248,
netPerformancePercent: 26.3184449124,
netPerformancePercentWithCurrencyEffect: -0.04760906442310894,
netPerformanceWithCurrencyEffect: -2732.737808972287,
quantity: 0.5614682,
sectors: [],
symbol: 'bitcoin',
tags: [],
transactionCount: 1,
url: null,
valueInBaseCurrency: 54666.7898248
},
{
allocationInPercentage: 0.04307127421937313,
assetClass: 'EQUITY' as any,
assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any,
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO' as any,
dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'),
dividend: 0,
grossPerformance: 5065.5,
grossPerformancePercent: 0.7047750229568411,
grossPerformancePercentWithCurrencyEffect: 0.7047750229568411,
grossPerformanceWithCurrencyEffect: 5065.5,
holdings: [],
investment: 7187.4,
marketPrice: 408.43,
name: 'Microsoft Corporation',
netPerformance: 5065.5,
netPerformancePercent: 0.7047750229568411,
netPerformancePercentWithCurrencyEffect: -0.015973588391056275,
netPerformanceWithCurrencyEffect: -198.899926757814,
quantity: 30,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'MSFT',
tags: [],
transactionCount: 1,
url: 'https://www.microsoft.com',
valueInBaseCurrency: 12252.9
},
{
allocationInPercentage: 0.18762679306394897,
assetClass: 'EQUITY' as any,
assetClassLabel: 'Equity',
assetSubClass: 'STOCK' as any,
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO' as any,
dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'),
dividend: 0,
grossPerformance: 51227.500000005,
grossPerformancePercent: 23.843379101756675,
grossPerformancePercentWithCurrencyEffect: 23.843379101756675,
grossPerformanceWithCurrencyEffect: 51227.500000005,
holdings: [],
investment: 2148.499999995,
marketPrice: 355.84,
name: 'Tesla, Inc.',
netPerformance: 51197.500000005,
netPerformancePercent: 23.829415871596066,
netPerformancePercentWithCurrencyEffect: -0.12051410125545206,
netPerformanceWithCurrencyEffect: -7314.00091552734,
quantity: 150,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'TSLA',
tags: [],
transactionCount: 1,
url: 'https://www.tesla.com',
valueInBaseCurrency: 53376
},
{
allocationInPercentage: 0.053051250766657634,
assetClass: 'EQUITY' as any,
assetClassLabel: 'Equity',
assetSubClass: 'ETF' as any,
assetSubClassLabel: 'ETF',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO' as any,
dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 6845.8,
grossPerformancePercent: 1.0164758094605268,
grossPerformancePercentWithCurrencyEffect: 1.0164758094605268,
grossPerformanceWithCurrencyEffect: 6845.8,
holdings: [],
investment: 8246.2,
marketPrice: 301.84,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
netPerformance: 6746.3,
netPerformancePercent: 1.0017018833976383,
netPerformancePercentWithCurrencyEffect: 0.01085061564051406,
netPerformanceWithCurrencyEffect: 161.99969482422,
quantity: 50,
sectors: [
{
name: 'Equity',
weight: 1
}
],
symbol: 'VTI',
tags: [],
transactionCount: 5,
url: 'https://www.vanguard.com',
valueInBaseCurrency: 15092
}
];

351
libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts

@ -4,6 +4,7 @@ import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { holdings } from '../mocks/holdings';
import { GfTreemapChartComponent } from './treemap-chart.component';
export default {
@ -34,359 +35,11 @@ type Story = StoryObj<GfTreemapChartComponent>;
export const Default: Story = {
args: {
holdings,
baseCurrency: 'USD',
colorScheme: 'LIGHT',
cursor: undefined,
dateRange: 'mtd',
holdings: [
{
allocationInPercentage: 0.042990776363386086,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
countries: [],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 3856,
grossPerformancePercent: 0.46047289228564603,
grossPerformancePercentWithCurrencyEffect: 0.46047289228564603,
grossPerformanceWithCurrencyEffect: 3856,
holdings: [],
investment: 8374,
marketPrice: 244.6,
name: 'Apple Inc',
netPerformance: 3855,
netPerformancePercent: 0.460353475041796,
netPerformancePercentWithCurrencyEffect: 0.036440677966101696,
netPerformanceWithCurrencyEffect: 430,
quantity: 50,
sectors: [],
symbol: 'AAPL',
tags: [],
transactionCount: 1,
url: 'https://www.apple.com',
valueInBaseCurrency: 12230
},
{
allocationInPercentage: 0.02377401948293552,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
countries: [],
currency: 'EUR',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'),
dividend: 192,
grossPerformance: 2226.700251889169,
grossPerformancePercent: 0.49083842309827874,
grossPerformancePercentWithCurrencyEffect: 0.29306136948826367,
grossPerformanceWithCurrencyEffect: 1532.8272791336772,
holdings: [],
investment: 4536.523929471033,
marketPrice: 322.2,
name: 'Allianz SE',
netPerformance: 2222.2921914357685,
netPerformancePercent: 0.48986674069961134,
netPerformancePercentWithCurrencyEffect: 0.034489367670592026,
netPerformanceWithCurrencyEffect: 225.48257403052068,
quantity: 20,
sectors: [],
symbol: 'ALV.DE',
tags: [],
transactionCount: 2,
url: 'https://www.allianz.com',
valueInBaseCurrency: 6763.224181360202
},
{
allocationInPercentage: 0.08038536990007467,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
countries: [],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 12758.05,
grossPerformancePercent: 1.2619300787837724,
grossPerformancePercentWithCurrencyEffect: 1.2619300787837724,
grossPerformanceWithCurrencyEffect: 12758.05,
holdings: [],
investment: 10109.95,
marketPrice: 228.68,
name: 'Amazon.com, Inc.',
netPerformance: 12677.26,
netPerformancePercent: 1.253938941339967,
netPerformancePercentWithCurrencyEffect: -0.037866008722316276,
netPerformanceWithCurrencyEffect: -899.99926757812,
quantity: 100,
sectors: [],
symbol: 'AMZN',
tags: [],
transactionCount: 1,
url: 'https://www.aboutamazon.com',
valueInBaseCurrency: 22868
},
{
allocationInPercentage: 0.19216416482928922,
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
countries: [],
currency: 'USD',
dataSource: 'COINGECKO',
dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'),
dividend: 0,
grossPerformance: 52666.7898248,
grossPerformancePercent: 26.333394912400003,
grossPerformancePercentWithCurrencyEffect: 26.333394912400003,
grossPerformanceWithCurrencyEffect: 52666.7898248,
holdings: [],
investment: 1999.9999999999998,
marketPrice: 97364,
name: 'Bitcoin',
netPerformance: 52636.8898248,
netPerformancePercent: 26.3184449124,
netPerformancePercentWithCurrencyEffect: -0.04760906442310894,
netPerformanceWithCurrencyEffect: -2732.737808972287,
quantity: 0.5614682,
sectors: [],
symbol: 'bitcoin',
tags: [],
transactionCount: 1,
url: null,
valueInBaseCurrency: 54666.7898248
},
{
allocationInPercentage: 0.007378652850073097,
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
countries: [],
currency: 'EUR',
dataSource: 'MANUAL',
dateOfFirstActivity: new Date('2021-02-01T00:00:00.000Z'),
dividend: 11.45,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: -0.1247202380342517,
grossPerformanceWithCurrencyEffect: -258.2576430160448,
holdings: [],
investment: 2099.0764063811926,
marketPrice: 1,
name: 'Bondora Go & Grow',
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0.009445843828715519,
netPerformanceWithCurrencyEffect: 19.6420125363184,
quantity: 2000,
sectors: [],
symbol: 'BONDORA_GO_AND_GROW',
tags: [],
transactionCount: 5,
url: null,
valueInBaseCurrency: 2099.0764063811926
},
{
allocationInPercentage: 0.07787531695543741,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
countries: [],
currency: 'CHF',
dataSource: 'MANUAL',
dateOfFirstActivity: new Date('2021-04-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 4550.843985045582,
grossPerformancePercent: 0.3631417324494093,
grossPerformancePercentWithCurrencyEffect: 0.42037247857285137,
grossPerformanceWithCurrencyEffect: 5107.057936556927,
holdings: [],
investment: 17603.097090932337,
marketPrice: 188.22,
name: 'frankly Extreme 95 Index',
netPerformance: 4550.843985045582,
netPerformancePercent: 0.3631417324494093,
netPerformancePercentWithCurrencyEffect: 0.026190604904358043,
netPerformanceWithCurrencyEffect: 565.4165171873152,
quantity: 105.87328656807,
sectors: [],
symbol: 'FRANKLY95P',
tags: [],
transactionCount: 6,
url: 'https://www.frankly.ch',
valueInBaseCurrency: 22153.941075977917
},
{
allocationInPercentage: 0.04307127421937313,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
countries: [],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'),
dividend: 0,
grossPerformance: 5065.5,
grossPerformancePercent: 0.7047750229568411,
grossPerformancePercentWithCurrencyEffect: 0.7047750229568411,
grossPerformanceWithCurrencyEffect: 5065.5,
holdings: [],
investment: 7187.4,
marketPrice: 408.43,
name: 'Microsoft Corporation',
netPerformance: 5065.5,
netPerformancePercent: 0.7047750229568411,
netPerformancePercentWithCurrencyEffect: -0.015973588391056275,
netPerformanceWithCurrencyEffect: -198.899926757814,
quantity: 30,
sectors: [],
symbol: 'MSFT',
tags: [],
transactionCount: 1,
url: 'https://www.microsoft.com',
valueInBaseCurrency: 12252.9
},
{
allocationInPercentage: 0.18762679306394897,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
countries: [],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'),
dividend: 0,
grossPerformance: 51227.500000005,
grossPerformancePercent: 23.843379101756675,
grossPerformancePercentWithCurrencyEffect: 23.843379101756675,
grossPerformanceWithCurrencyEffect: 51227.500000005,
holdings: [],
investment: 2148.499999995,
marketPrice: 355.84,
name: 'Tesla, Inc.',
netPerformance: 51197.500000005,
netPerformancePercent: 23.829415871596066,
netPerformancePercentWithCurrencyEffect: -0.12051410125545206,
netPerformanceWithCurrencyEffect: -7314.00091552734,
quantity: 150,
sectors: [],
symbol: 'TSLA',
tags: [],
transactionCount: 1,
url: 'https://www.tesla.com',
valueInBaseCurrency: 53376
},
{
allocationInPercentage: 0.053051250766657634,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
countries: [],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 6845.8,
grossPerformancePercent: 1.0164758094605268,
grossPerformancePercentWithCurrencyEffect: 1.0164758094605268,
grossPerformanceWithCurrencyEffect: 6845.8,
holdings: [],
investment: 8246.2,
marketPrice: 301.84,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
netPerformance: 6746.3,
netPerformancePercent: 1.0017018833976383,
netPerformancePercentWithCurrencyEffect: 0.01085061564051406,
netPerformanceWithCurrencyEffect: 161.99969482422,
quantity: 50,
sectors: [],
symbol: 'VTI',
tags: [],
transactionCount: 5,
url: 'https://www.vanguard.com',
valueInBaseCurrency: 15092
},
{
allocationInPercentage: 0.0836576192450555,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
countries: [],
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2018-03-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 6462.42356864925,
grossPerformancePercent: 0.5463044783973836,
grossPerformancePercentWithCurrencyEffect: 0.6282343505275325,
grossPerformanceWithCurrencyEffect: 7121.935580698947,
holdings: [],
investment: 17336.464702612564,
marketPrice: 129.74,
name: 'Vanguard FTSE All-World UCITS ETF',
netPerformance: 6373.040578098944,
netPerformancePercent: 0.5387484388540966,
netPerformancePercentWithCurrencyEffect: 0.008409682389650015,
netPerformanceWithCurrencyEffect: 198.47200506226807,
quantity: 165,
sectors: [],
symbol: 'VWRL.SW',
tags: [],
transactionCount: 5,
url: 'https://www.vanguard.com',
valueInBaseCurrency: 23798.888271261814
},
{
allocationInPercentage: 0.03265192235898284,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
countries: [],
currency: 'EUR',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-08-19T00:00:00.000Z'),
dividend: 0,
grossPerformance: 3112.7991183879094,
grossPerformancePercent: 0.5040147846036197,
grossPerformancePercentWithCurrencyEffect: 0.3516875105542396,
grossPerformanceWithCurrencyEffect: 2416.799201046856,
holdings: [],
investment: 6176.007556675063,
marketPrice: 118.005,
name: 'Xtrackers MSCI World UCITS ETF 1C',
netPerformance: 3081.4179261125105,
netPerformancePercent: 0.4989336392216841,
netPerformancePercentWithCurrencyEffect: 0.006460676966633529,
netPerformanceWithCurrencyEffect: 59.626750161726044,
quantity: 75,
sectors: [],
symbol: 'XDWD.DE',
tags: [],
transactionCount: 1,
url: null,
valueInBaseCurrency: 9288.806675062973
},
{
allocationInPercentage: 0.17537283996478595,
assetClass: 'LIQUIDITY',
assetSubClass: 'CASH',
countries: [],
currency: 'USD',
dataSource: 'MANUAL',
dateOfFirstActivity: new Date('2021-04-01T00:00:00.000Z'),
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: 49890,
marketPrice: 0,
name: 'USD',
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: 'USD',
tags: [],
transactionCount: 0,
valueInBaseCurrency: 49890
}
],
locale: 'en-US'
}
};

8
libs/ui/src/lib/value/value.component.html

@ -4,7 +4,7 @@
</div>
}
<div class="d-flex flex-column w-100">
<ng-template #label><ng-content></ng-content></ng-template>
<ng-template #label><ng-content /></ng-template>
@if (value || value === 0 || value === null) {
<div
class="align-items-center d-flex"
@ -87,16 +87,14 @@
@if (size === 'large') {
<div class="text-truncate">
<span class="h6"
><ng-container *ngTemplateOutlet="label"></ng-container
></span>
<span class="h6"><ng-container *ngTemplateOutlet="label" /></span>
@if (subLabel) {
<span class="text-muted"> {{ subLabel }}</span>
}
</div>
} @else {
<small class="d-block text-truncate">
<ng-container *ngTemplateOutlet="label"></ng-container>
<ng-container *ngTemplateOutlet="label" />
</small>
}
</div>

83
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.207.0",
"version": "2.208.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.207.0",
"version": "2.208.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -28,7 +28,6 @@
"@dfinity/candid": "0.15.7",
"@dfinity/identity": "0.15.7",
"@dfinity/principal": "0.15.7",
"@dinero.js/currencies": "2.0.0-alpha.8",
"@internationalized/number": "3.6.3",
"@ionic/angular": "8.7.3",
"@keyv/redis": "4.4.0",
@ -44,7 +43,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.16.3",
"@prisma/client": "6.17.1",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -149,7 +148,7 @@
"nx": "21.5.1",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.16.3",
"prisma": "6.17.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",
@ -5018,12 +5017,6 @@
"ts-node": "^10.8.2"
}
},
"node_modules/@dinero.js/currencies": {
"version": "2.0.0-alpha.8",
"resolved": "https://registry.npmjs.org/@dinero.js/currencies/-/currencies-2.0.0-alpha.8.tgz",
"integrity": "sha512-zApiqtuuPwjiM9LJA5/kNcT48VSHRiz2/mktkXjIpfxrJKzthXybUAgEenExIH6dYhLDgVmsLQZtZFOsdYl0Ag==",
"license": "MIT"
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
@ -11960,9 +11953,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz",
"integrity": "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -11982,9 +11975,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz",
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.1.tgz",
"integrity": "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -11995,53 +11988,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz",
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.1.tgz",
"integrity": "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz",
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.1.tgz",
"integrity": "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/fetch-engine": "6.16.3",
"@prisma/get-platform": "6.16.3"
"@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/fetch-engine": "6.17.1",
"@prisma/get-platform": "6.17.1"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz",
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==",
"version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac.tgz",
"integrity": "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz",
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.1.tgz",
"integrity": "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/get-platform": "6.16.3"
"@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/get-platform": "6.17.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz",
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.1.tgz",
"integrity": "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.3"
"@prisma/debug": "6.17.1"
}
},
"node_modules/@redis/client": {
@ -35747,15 +35740,15 @@
}
},
"node_modules/prisma": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz",
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.1.tgz",
"integrity": "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.16.3",
"@prisma/engines": "6.16.3"
"@prisma/config": "6.17.1",
"@prisma/engines": "6.17.1"
},
"bin": {
"prisma": "build/index.js"

7
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.207.0",
"version": "2.208.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -74,7 +74,6 @@
"@dfinity/candid": "0.15.7",
"@dfinity/identity": "0.15.7",
"@dfinity/principal": "0.15.7",
"@dinero.js/currencies": "2.0.0-alpha.8",
"@internationalized/number": "3.6.3",
"@ionic/angular": "8.7.3",
"@keyv/redis": "4.4.0",
@ -90,7 +89,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.16.3",
"@prisma/client": "6.17.1",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -195,7 +194,7 @@
"nx": "21.5.1",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.16.3",
"prisma": "6.17.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",

7
test/import/ok/novn-buy-and-sell-partially.json

@ -24,5 +24,10 @@
"date": "2022-03-07T00:00:00.000Z",
"symbol": "NOVN.SW"
}
]
],
"user": {
"settings": {
"currency": "CHF"
}
}
}

7
test/import/ok/novn-buy-and-sell.json

@ -24,5 +24,10 @@
"date": "2022-03-07T00:00:00.000Z",
"symbol": "NOVN.SW"
}
]
],
"user": {
"settings": {
"currency": "CHF"
}
}
}

53
test/import/ok/penthouse-apartment.json

@ -0,0 +1,53 @@
{
"meta": {
"date": "2023-02-05T00:00:00.000Z",
"version": "dev"
},
"accounts": [],
"assetProfiles": [
{
"assetClass": null,
"assetSubClass": null,
"comment": null,
"countries": [],
"currency": "USD",
"cusip": null,
"dataSource": "MANUAL",
"figi": null,
"figiComposite": null,
"figiShareClass": null,
"holdings": [],
"isActive": true,
"isin": null,
"marketData": [],
"name": "Penthouse Apartment",
"scraperConfiguration": null,
"sectors": [],
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"symbolMapping": {},
"url": null
}
],
"platforms": [],
"tags": [],
"activities": [
{
"accountId": null,
"comment": null,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2022-01-01T00:00:00.000Z",
"fee": 0,
"quantity": 1,
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"tags": [],
"type": "BUY",
"unitPrice": 500000,
}
],
"user": {
"settings": {
"currency": "USD"
}
}
}
Loading…
Cancel
Save