Compare commits

...

3 Commits

Author SHA1 Message Date
Thomas Kaul 03d69eb3bf
Feature/add emoji flags to countries on product page (#8097) 1 day ago
Thomas Kaul 8287c1c7ca
Bugfix/end date of calendar year date ranges in portfolio performance (#8095) 1 day ago
Thomas Kaul 5036353250
Bugfix/missing list numbers of top and bottom holdings in Safari (#8090) 1 day ago
  1. 2
      CHANGELOG.md
  2. 13
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  4. 16
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  5. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.scss
  6. 12
      apps/client/src/app/pages/resources/personal-finance-tools/interfaces/interfaces.ts
  7. 39
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  8. 32
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.html

2
CHANGELOG.md

@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed the missing list numbers in the top and bottom holdings on the analysis page in Safari
- Fixed the missing first data point in the portfolio evolution chart on the analysis page
- Fixed the end date of calendar year date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset
- Fixed the missing mapping for Korea in the country weightings of the _Financial Modeling Prep_ service
## 3.81.0 - 2026-10-07

13
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -1407,7 +1407,7 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalLiabilitiesWithCurrencyEffect;
}
public async getPerformance({ end, start }) {
public async getPerformance({ end, start }: { end: Date; start: Date }) {
await this.snapshotPromise;
const { historicalData } = this.snapshot;
@ -1418,14 +1418,13 @@ export abstract class PortfolioCalculator {
let netPerformanceAtStartDate: number;
let netPerformanceWithCurrencyEffectAtStartDate: number;
const startDate = isEqual(start, endOfDay(start))
? resetHours(start)
: start;
const endDate = this.toChartDate(end);
const startDate = this.toChartDate(start);
for (const historicalDataItem of historicalData) {
const date = resetHours(parseDate(historicalDataItem.date));
if (!isBefore(date, startDate) && !isAfter(date, end)) {
if (!isBefore(date, startDate) && !isAfter(date, endDate)) {
// Take the values at the start date from the first day of the date
// range
if (historicalDataItemsOfDateRange.length === 0) {
@ -1915,4 +1914,8 @@ export abstract class PortfolioCalculator {
await this.initialize(attempt + 1);
}
}
private toChartDate(aDate: Date) {
return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate;
}
}

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

@ -1,3 +1,7 @@
/**
* @jest-environment <rootDir>/jest-environment-tz.js
* @jest-environment-options {"timeZone": "America/New_York"}
*/
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -9,6 +13,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -183,4 +188,43 @@ describe('PortfolioCalculator', () => {
).toBeCloseTo(0.23, 10);
});
});
describe('get performance', () => {
it('with a calendar year date range', async () => {
const snapshot = await portfolioCalculator.getSnapshot();
snapshot.historicalData = [
getHistoricalDataItem({
date: '2016-12-31',
investment: 100,
netPerformance: 0
}),
getHistoricalDataItem({
date: '2017-12-31',
investment: 100,
netPerformance: 10
}),
getHistoricalDataItem({
date: '2018-01-01',
investment: 100,
netPerformance: 20
})
];
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2017'
});
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
expect(
chart.map(({ date }) => {
return date;
})
).toEqual(['2016-12-31', '2017-12-31']);
});
});
});

16
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -330,7 +330,7 @@
>
</mat-card-header>
<mat-card-content>
<ol class="mb-0 ml-1 pl-3">
<ol class="holdings mb-0 ml-1 pl-3">
@for (holding of top3; track holding) {
<li class="py-1">
<a
@ -342,8 +342,10 @@
}"
[routerLink]="[]"
>
<div class="flex-grow-1 mr-2 text-truncate">
{{ holding.assetProfile.name }}
<div class="flex-grow-1 mr-2 name">
<span class="align-top d-inline-block mw-100 text-truncate">
{{ holding.assetProfile.name }}
</span>
</div>
<div class="d-flex justify-content-end">
<gf-value
@ -381,7 +383,7 @@
>
</mat-card-header>
<mat-card-content>
<ol class="mb-0 ml-1 pl-3">
<ol class="holdings mb-0 ml-1 pl-3">
@for (holding of bottom3; track holding) {
<li class="py-1">
<a
@ -393,8 +395,10 @@
}"
[routerLink]="[]"
>
<div class="flex-grow-1 mr-2 text-truncate">
{{ holding.assetProfile.name }}
<div class="flex-grow-1 mr-2 name">
<span class="align-top d-inline-block mw-100 text-truncate">
{{ holding.assetProfile.name }}
</span>
</div>
<div class="d-flex justify-content-end">
<gf-value

8
apps/client/src/app/pages/portfolio/analysis/analysis-page.scss

@ -4,4 +4,12 @@
.chart-container {
aspect-ratio: 16 / 9;
}
.holdings {
.name {
// Safari puts the list marker into this element, so the truncation is
// on the inner element (a hidden overflow here hides the marker)
min-width: 0;
}
}
}

12
apps/client/src/app/pages/resources/personal-finance-tools/interfaces/interfaces.ts

@ -1,6 +1,16 @@
import type { Product } from '@ghostfolio/common/interfaces';
export type ResolvedProduct = Omit<Product, 'categories' | 'platforms'> & {
export interface ResolvedRegion {
emojiFlag?: string;
name: string;
}
export type ResolvedProduct = Omit<
Product,
'categories' | 'origin' | 'platforms' | 'regions'
> & {
categories?: string[];
origin?: ResolvedRegion;
platforms?: string[];
regions?: ResolvedRegion[];
};

39
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -1,4 +1,4 @@
import { getCountryName } from '@ghostfolio/common/helper';
import { getCountryName, getEmojiFlag } from '@ghostfolio/common/helper';
import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
@ -14,7 +14,7 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ResolvedProduct } from './interfaces/interfaces';
import { ResolvedProduct, ResolvedRegion } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -58,9 +58,9 @@ export class GfProductPageComponent {
// 'Ukrainian (Українська)'
],
name: 'Ghostfolio',
origin: getCountryName({ code: 'CH' }),
origin: this.getResolvedCountry('CH'),
platforms: this.getSortedTranslations(['ANDROID', 'WEB']),
regions: [$localize`Global`],
regions: [{ name: $localize`Global` }],
slogan: 'Open Source Wealth Management',
useAnonymously: true
}));
@ -75,20 +75,16 @@ export class GfProductPageComponent {
name: product?.name ?? '',
...product,
categories: this.getSortedTranslations(product?.categories),
platforms: this.getSortedTranslations(product?.platforms)
};
if (mappedProduct.origin) {
mappedProduct.origin = getCountryName({ code: mappedProduct.origin });
}
if (mappedProduct.regions) {
mappedProduct.regions = mappedProduct.regions.map((region) => {
origin: product?.origin
? this.getResolvedCountry(product.origin)
: undefined,
platforms: this.getSortedTranslations(product?.platforms),
regions: product?.regions?.map((region) => {
return region === 'Global'
? translate(region)
: getCountryName({ code: region });
});
}
? { name: translate(region) }
: this.getResolvedCountry(region);
})
};
return mappedProduct;
});
@ -122,7 +118,7 @@ export class GfProductPageComponent {
...(categories ?? []),
...(platforms ?? []),
name,
origin
origin?.name
];
}
),
@ -175,6 +171,13 @@ export class GfProductPageComponent {
});
}
private getResolvedCountry(code: string): ResolvedRegion {
return {
emojiFlag: getEmojiFlag(code),
name: getCountryName({ code })
};
}
private getSortedTranslations(values?: string[]) {
return values
?.map((value) => {

32
apps/client/src/app/pages/resources/personal-finance-tools/product-page.html

@ -100,27 +100,47 @@
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Origin</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().origin }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().origin }}</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product1().origin; as origin) {
<span aria-hidden="true">{{ origin.emojiFlag }}</span
>&nbsp;{{ origin.name }}
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product2().origin; as origin) {
<span aria-hidden="true">{{ origin.emojiFlag }}</span
>&nbsp;{{ origin.name }}
}
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
region of product1().regions;
track region;
track region.name;
let isLast = $last
) {
{{ region }}{{ isLast ? '' : ', ' }}
@if (region.emojiFlag) {
<span aria-hidden="true">{{ region.emojiFlag }}</span
>&nbsp;{{ region.name }}{{ isLast ? '' : ', ' }}
} @else {
{{ region.name }}{{ isLast ? '' : ', ' }}
}
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
region of product2().regions;
track region;
track region.name;
let isLast = $last
) {
{{ region }}{{ isLast ? '' : ', ' }}
@if (region.emojiFlag) {
<span aria-hidden="true">{{ region.emojiFlag }}</span
>&nbsp;{{ region.name }}{{ isLast ? '' : ', ' }}
} @else {
{{ region.name }}{{ isLast ? '' : ', ' }}
}
}
</td>
</tr>

Loading…
Cancel
Save