Browse Source

Bugfix/date handling in time zone other than UTC (#7723)

* Fix date handling in time zone other than UTC

* Update changelog
pull/7734/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
dbe3eecd87
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 7
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  3. 9
      apps/api/src/app/symbol/symbol.controller.ts
  4. 6
      apps/api/src/app/symbol/symbol.service.ts
  5. 2
      apps/api/src/app/user/user.service.ts
  6. 13
      apps/api/src/services/data-provider/data-provider.service.ts
  7. 31
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  8. 87
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  9. 2
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  10. 4
      libs/common/src/lib/dtos/create-access.dto.ts
  11. 9
      libs/common/src/lib/dtos/update-access.dto.ts
  12. 16
      libs/common/src/lib/helper.ts

7
CHANGELOG.md

@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the language localization of the asset classes and asset sub classes in the holdings table of the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental) - Improved the language localization of the asset classes and asset sub classes in the holdings table of the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental)
- Improved the logging of the `web_fetch` tool in the `FetchService` - Improved the logging of the `web_fetch` tool in the `FetchService`
### Fixed
- Fixed the date of the exchange rates for instances running in a time zone other than UTC
- Fixed the date of the chart in the holding detail dialog for instances running in a time zone other than UTC
- Fixed the date of the historical market data gathering endpoint for a specific date for instances running in a time zone other than UTC
- Fixed the validation of the date in the historical market data gathering endpoint for a specific date
## 3.61.0 - 2026-08-25 ## 3.61.0 - 2026-08-25
### Changed ### Changed

7
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -1,10 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getYesterday, getStartOfUtcDateOfYesterday,
interpolate interpolate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { utc } from '@date-fns/utc';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Response } from 'express'; import { Response } from 'express';
@ -32,7 +33,9 @@ export class SitemapController {
@Get() @Get()
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public getSitemapXml(@Res() response: Response) { public getSitemapXml(@Res() response: Response) {
const currentDate = format(getYesterday(), DATE_FORMAT); const currentDate = format(getStartOfUtcDateOfYesterday(), DATE_FORMAT, {
in: utc
});
response.setHeader('content-type', 'application/xml'); response.setHeader('content-type', 'application/xml');
response.send( response.send(

9
apps/api/src/app/symbol/symbol.controller.ts

@ -8,6 +8,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { utc } from '@date-fns/utc';
import { import {
Controller, Controller,
Get, Get,
@ -22,9 +23,9 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { parseISO } from 'date-fns'; import { isValid, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@ -103,9 +104,9 @@ export class SymbolController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<DataProviderHistoricalResponse> { ): Promise<DataProviderHistoricalResponse> {
const date = parseISO(dateString); const date = parseISO(dateString, { in: utc });
if (!isDate(date)) { if (!isValid(date)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST

6
apps/api/src/app/symbol/symbol.service.ts

@ -19,6 +19,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { utc } from '@date-fns/utc';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
@ -123,8 +124,9 @@ export class SymbolService {
return { return {
marketPrice: marketPrice:
historicalData?.[assetProfileIdentifier]?.[format(date, DATE_FORMAT)] historicalData?.[assetProfileIdentifier]?.[
?.marketPrice format(date, DATE_FORMAT, { in: utc })
]?.marketPrice
}; };
} }

2
apps/api/src/app/user/user.service.ts

@ -138,7 +138,7 @@ export class UserService {
user: true user: true
}, },
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { expiresAt: { gt: new Date() }, granteeUserId: id } where: { granteeUserId: id }
}), }),
this.prismaService.account.findMany({ this.prismaService.account.findMany({
include: { platform: true }, include: { platform: true },

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

@ -393,10 +393,11 @@ export class DataProviderService implements OnModuleInit {
const rangeQuery = const rangeQuery =
from && to from && to
? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format( ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, {
to, in: utc
DATE_FORMAT })}::timestamp AND date <= ${format(to, DATE_FORMAT, {
)}::timestamp` in: utc
})}::timestamp`
: Prisma.empty; : Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => { const dataSources = aItems.map(({ dataSource }) => {
@ -430,7 +431,9 @@ export class DataProviderService implements OnModuleInit {
r[assetProfileIdentifier] = {}; r[assetProfileIdentifier] = {};
} }
r[assetProfileIdentifier][format(new Date(date), DATE_FORMAT)] = { r[assetProfileIdentifier][
format(new Date(date), DATE_FORMAT, { in: utc })
] = {
marketPrice marketPrice
}; };

31
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -12,11 +12,12 @@ import {
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getYesterday, getStartOfUtcDateOfYesterday,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import { utc } from '@date-fns/utc';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
eachDayOfInterval, eachDayOfInterval,
@ -164,11 +165,19 @@ export class ExchangeRateDataService {
} }
public async loadCurrencies() { public async loadCurrencies() {
const startOfUtcDateOfYesterday = getStartOfUtcDateOfYesterday();
const dateStringOfYesterday = format(
startOfUtcDateOfYesterday,
DATE_FORMAT,
{ in: utc }
);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
this.currencyPairs, this.currencyPairs,
'day', 'day',
getYesterday(), startOfUtcDateOfYesterday,
getYesterday() startOfUtcDateOfYesterday
); );
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
@ -196,7 +205,7 @@ export class ExchangeRateDataService {
if (isNumber(quote?.marketPrice)) { if (isNumber(quote?.marketPrice)) {
result[symbol] = { result[symbol] = {
[format(getYesterday(), DATE_FORMAT)]: { [dateStringOfYesterday]: {
marketPrice: quote.marketPrice marketPrice: quote.marketPrice
} }
}; };
@ -219,17 +228,19 @@ export class ExchangeRateDataService {
for (const symbol of Object.keys(resultExtended)) { for (const symbol of Object.keys(resultExtended)) {
const [currency1, currency2] = symbol.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const date = format(getYesterday(), DATE_FORMAT);
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice; this.exchangeRates[symbol] =
resultExtended[symbol]?.[dateStringOfYesterday]?.marketPrice;
if (!this.exchangeRates[symbol]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via base currency // Not found, calculate indirectly via base currency
this.exchangeRates[symbol] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date] resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[
?.marketPrice * dateStringOfYesterday
resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date] ]?.marketPrice *
?.marketPrice; resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[
dateStringOfYesterday
]?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
this.exchangeRates[`${currency2}${currency1}`] = this.exchangeRates[`${currency2}${currency1}`] =

87
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -52,7 +52,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { addYears, endOfDay, isBefore, startOfDay } from 'date-fns'; import { addYears, endOfDay, isBefore, isValid, startOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs'; import { EMPTY, catchError } from 'rxjs';
@ -84,6 +84,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public tags: Filter[] = []; public tags: Filter[] = [];
protected accessForm: FormGroup; protected accessForm: FormGroup;
protected minExpiresAt: Date;
protected readonly mode: 'create' | 'update'; protected readonly mode: 'create' | 'update';
protected readonly today = startOfDay(new Date()); protected readonly today = startOfDay(new Date());
@ -153,9 +154,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
] ]
}); });
if (access?.expiresAt && isBefore(new Date(access.expiresAt), this.today)) { this.minExpiresAt =
this.accessForm.get('expiresAt')?.markAsTouched(); access?.expiresAt && isBefore(new Date(access.expiresAt), this.today)
} ? startOfDay(new Date(access.expiresAt))
: this.today;
this.assetClasses = getAssetClassFilters(); this.assetClasses = getAssetClassFilters();
@ -222,42 +224,15 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
} }
private buildExpiresAt() {
const expiresAt = this.accessForm.get('expiresAt')?.value as Date;
return endOfDay(expiresAt).toISOString();
}
private buildFilters(): Filter[] {
return getFiltersFromPortfolioFilterFormValue(
this.accessForm.get('filters')?.value
);
}
private buildScopes(): Scope[] {
const scopesOfAccess = this.data.access?.scopes ?? [];
if (
scopesOfAccess.length > 0 &&
this.accessLevel === getAccessLevel(scopesOfAccess)
) {
return Object.values(scopes).filter((scope) => {
return hasScope(scopesOfAccess, scope);
});
}
return getScopesOfAccessLevel(this.accessLevel);
}
private async createAccess() { private async createAccess() {
const filters = this.buildFilters(); const filters = this.getFilters();
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
expiresAt: this.buildExpiresAt(), expiresAt: this.getExpiresAt(),
filters: filters.length > 0 ? filters : undefined, filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
scopes: this.buildScopes(), scopes: this.getScopes(),
type: this.accessForm.get('type')?.value type: this.accessForm.get('type')?.value
}; };
@ -290,6 +265,44 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
} }
private getExpiresAt() {
const expiresAtControl = this.accessForm.get('expiresAt');
const expiresAtOfAccess = this.data.access?.expiresAt;
if (
this.mode === 'update' &&
!expiresAtControl?.dirty &&
expiresAtOfAccess
) {
return new Date(expiresAtOfAccess).toISOString();
}
const expiresAt = expiresAtControl?.value as Date;
return isValid(expiresAt) ? endOfDay(expiresAt).toISOString() : '';
}
private getFilters(): Filter[] {
return getFiltersFromPortfolioFilterFormValue(
this.accessForm.get('filters')?.value
);
}
private getScopes(): Scope[] {
const scopesOfAccess = this.data.access?.scopes ?? [];
if (
scopesOfAccess.length > 0 &&
this.accessLevel === getAccessLevel(scopesOfAccess)
) {
return Object.values(scopes).filter((scope) => {
return hasScope(scopesOfAccess, scope);
});
}
return getScopesOfAccessLevel(this.accessLevel);
}
private loadHoldings() { private loadHoldings() {
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings()
@ -310,15 +323,15 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return; return;
} }
const filters = this.buildFilters(); const filters = this.getFilters();
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
expiresAt: this.buildExpiresAt(), expiresAt: this.getExpiresAt(),
filters: filters.length > 0 ? filters : undefined, filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: accessId, id: accessId,
scopes: this.buildScopes() scopes: this.getScopes()
}; };
try { try {

2
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -52,7 +52,7 @@
formControlName="expiresAt" formControlName="expiresAt"
matInput matInput
[matDatepicker]="expiresAt" [matDatepicker]="expiresAt"
[min]="today" [min]="minExpiresAt"
/> />
<mat-datepicker-toggle class="mr-2" matSuffix [for]="expiresAt"> <mat-datepicker-toggle class="mr-2" matSuffix [for]="expiresAt">
<ion-icon <ion-icon

4
libs/common/src/lib/dtos/create-access.dto.ts

@ -5,9 +5,9 @@ import { IsInTheFutureConstraint } from '@ghostfolio/common/validator-constraint
import { AccessType } from '@prisma/client'; import { AccessType } from '@prisma/client';
import { import {
IsArray, IsArray,
IsDateString,
IsEnum, IsEnum,
IsIn, IsIn,
IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
@ -19,7 +19,7 @@ export class CreateAccessDto {
@IsString() @IsString()
alias?: string; alias?: string;
@IsDateString() @IsISO8601()
@Validate(IsInTheFutureConstraint) @Validate(IsInTheFutureConstraint)
expiresAt: string; expiresAt: string;

9
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,15 +1,13 @@
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Scope, scopes } from '@ghostfolio/common/scopes'; import { Scope, scopes } from '@ghostfolio/common/scopes';
import { IsInTheFutureConstraint } from '@ghostfolio/common/validator-constraints/is-in-the-future';
import { import {
IsArray, IsArray,
IsDateString,
IsIn, IsIn,
IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID
Validate
} from 'class-validator'; } from 'class-validator';
export class UpdateAccessDto { export class UpdateAccessDto {
@ -17,8 +15,7 @@ export class UpdateAccessDto {
@IsString() @IsString()
alias?: string; alias?: string;
@IsDateString() @IsISO8601()
@Validate(IsInTheFutureConstraint)
expiresAt: string; expiresAt: string;
@IsArray() @IsArray()

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

@ -511,14 +511,6 @@ export function getTextColor(aColorScheme: ColorScheme) {
return `${r}, ${g}, ${b}`; return `${r}, ${g}, ${b}`;
} }
export function getToday() {
const year = getYear(new Date());
const month = getMonth(new Date());
const day = getDate(new Date());
return new Date(Date.UTC(year, month, day));
}
export function getUtc(aDateString: string) { export function getUtc(aDateString: string) {
const [yearString, monthString, dayString] = aDateString.split('-'); const [yearString, monthString, dayString] = aDateString.split('-');
@ -531,14 +523,6 @@ export function getUtc(aDateString: string) {
); );
} }
export function getYesterday() {
const year = getYear(new Date());
const month = getMonth(new Date());
const day = getDate(new Date());
return subDays(new Date(Date.UTC(year, month, day)), 1);
}
export function hasGhostfolioPrefix(aSymbol: string) { export function hasGhostfolioPrefix(aSymbol: string) {
if (!aSymbol) { if (!aSymbol) {
return false; return false;

Loading…
Cancel
Save