diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e73e73ba..e161aa19e 100644 --- a/CHANGELOG.md +++ b/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 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 ### Changed diff --git a/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts b/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts index b42ae3594..9d4b40593 100644 --- a/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts +++ b/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts @@ -1,10 +1,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DATE_FORMAT, - getYesterday, + getStartOfUtcDateOfYesterday, interpolate } from '@ghostfolio/common/helper'; +import { utc } from '@date-fns/utc'; import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { format } from 'date-fns'; import { Response } from 'express'; @@ -32,7 +33,9 @@ export class SitemapController { @Get() @Version(VERSION_NEUTRAL) 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.send( diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts index a1351dbed..d28362175 100644 --- a/apps/api/src/app/symbol/symbol.controller.ts +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -8,6 +8,7 @@ import { } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; +import { utc } from '@date-fns/utc'; import { Controller, Get, @@ -22,9 +23,9 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; -import { parseISO } from 'date-fns'; +import { isValid, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { isDate, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import { SymbolService } from './symbol.service'; @@ -103,9 +104,9 @@ export class SymbolController { @Param('dateString') dateString: string, @Param('symbol') symbol: string ): Promise { - const date = parseISO(dateString); + const date = parseISO(dateString, { in: utc }); - if (!isDate(date)) { + if (!isValid(date)) { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), StatusCodes.BAD_REQUEST diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 9cf940208..69d9d54f3 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -19,6 +19,7 @@ import { } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; +import { utc } from '@date-fns/utc'; import { Injectable, Logger } from '@nestjs/common'; import { format, subDays } from 'date-fns'; @@ -123,8 +124,9 @@ export class SymbolService { return { marketPrice: - historicalData?.[assetProfileIdentifier]?.[format(date, DATE_FORMAT)] - ?.marketPrice + historicalData?.[assetProfileIdentifier]?.[ + format(date, DATE_FORMAT, { in: utc }) + ]?.marketPrice }; } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index ac997a874..082aeb779 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -138,7 +138,7 @@ export class UserService { user: true }, orderBy: { alias: 'asc' }, - where: { expiresAt: { gt: new Date() }, granteeUserId: id } + where: { granteeUserId: id } }), this.prismaService.account.findMany({ include: { platform: true }, diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 00d6b74bc..0d0924f80 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -393,10 +393,11 @@ export class DataProviderService implements OnModuleInit { const rangeQuery = from && to - ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format( - to, - DATE_FORMAT - )}::timestamp` + ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, { + in: utc + })}::timestamp AND date <= ${format(to, DATE_FORMAT, { + in: utc + })}::timestamp` : Prisma.empty; const dataSources = aItems.map(({ dataSource }) => { @@ -430,7 +431,9 @@ export class DataProviderService implements OnModuleInit { r[assetProfileIdentifier] = {}; } - r[assetProfileIdentifier][format(new Date(date), DATE_FORMAT)] = { + r[assetProfileIdentifier][ + format(new Date(date), DATE_FORMAT, { in: utc }) + ] = { marketPrice }; diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 375a40173..0d7e98e2e 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -12,11 +12,12 @@ import { import { DATE_FORMAT, getAssetProfileIdentifier, - getYesterday, + getStartOfUtcDateOfYesterday, resetHours } from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; +import { utc } from '@date-fns/utc'; import { Injectable, Logger } from '@nestjs/common'; import { eachDayOfInterval, @@ -164,11 +165,19 @@ export class ExchangeRateDataService { } public async loadCurrencies() { + const startOfUtcDateOfYesterday = getStartOfUtcDateOfYesterday(); + + const dateStringOfYesterday = format( + startOfUtcDateOfYesterday, + DATE_FORMAT, + { in: utc } + ); + const historicalData = await this.dataProviderService.getHistorical( this.currencyPairs, 'day', - getYesterday(), - getYesterday() + startOfUtcDateOfYesterday, + startOfUtcDateOfYesterday ); const quotes = await this.dataProviderService.getQuotes({ @@ -196,7 +205,7 @@ export class ExchangeRateDataService { if (isNumber(quote?.marketPrice)) { result[symbol] = { - [format(getYesterday(), DATE_FORMAT)]: { + [dateStringOfYesterday]: { marketPrice: quote.marketPrice } }; @@ -219,17 +228,19 @@ export class ExchangeRateDataService { for (const symbol of Object.keys(resultExtended)) { 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]) { // Not found, calculate indirectly via base currency this.exchangeRates[symbol] = - resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date] - ?.marketPrice * - resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date] - ?.marketPrice; + resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[ + dateStringOfYesterday + ]?.marketPrice * + resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[ + dateStringOfYesterday + ]?.marketPrice; // Calculate the opposite direction this.exchangeRates[`${currency2}${currency1}`] = diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 1757d20f8..b7ad09553 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/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 { MatInputModule } from '@angular/material/input'; 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 { EMPTY, catchError } from 'rxjs'; @@ -84,6 +84,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { public tags: Filter[] = []; protected accessForm: FormGroup; + protected minExpiresAt: Date; protected readonly mode: 'create' | 'update'; 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.accessForm.get('expiresAt')?.markAsTouched(); - } + this.minExpiresAt = + access?.expiresAt && isBefore(new Date(access.expiresAt), this.today) + ? startOfDay(new Date(access.expiresAt)) + : this.today; 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() { - const filters = this.buildFilters(); + const filters = this.getFilters(); const access: CreateAccessDto = { alias: this.accessForm.get('alias')?.value, - expiresAt: this.buildExpiresAt(), + expiresAt: this.getExpiresAt(), filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, - scopes: this.buildScopes(), + scopes: this.getScopes(), 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() { this.dataService .fetchPortfolioHoldings() @@ -310,15 +323,15 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { return; } - const filters = this.buildFilters(); + const filters = this.getFilters(); const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, - expiresAt: this.buildExpiresAt(), + expiresAt: this.getExpiresAt(), filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, id: accessId, - scopes: this.buildScopes() + scopes: this.getScopes() }; try { diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index ba5b9661e..a802b2291 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/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" matInput [matDatepicker]="expiresAt" - [min]="today" + [min]="minExpiresAt" />