Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 | |||
3794a61d2d | |||
c1d1ea9dde |
10
.github/workflows/build-code.yml
vendored
10
.github/workflows/build-code.yml
vendored
@ -24,16 +24,16 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: yarn format:check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: yarn test
|
||||
run: npm test
|
||||
|
||||
- name: Build application
|
||||
run: yarn build:production
|
||||
run: npm run build:production
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,8 +5,8 @@
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
@ -34,10 +34,8 @@
|
||||
/coverage
|
||||
/dist
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
27
CHANGELOG.md
27
CHANGELOG.md
@ -5,6 +5,33 @@ 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).
|
||||
|
||||
## 2.101.0 - 2024-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
|
||||
|
||||
## 2.100.0 - 2024-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to manage tags of holdings in the holding detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||
- Persisted the view mode of the holdings tab on the home page (experimental)
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
|
||||
## 2.99.0 - 2024-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the usage of `yarn` to `npm`
|
||||
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
|
||||
- Downgraded `marked` from version `13.0.0` to `12.0.2`
|
||||
|
||||
## 2.98.0 - 2024-07-27
|
||||
|
||||
### Added
|
||||
|
@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||
1. Run `npx nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||
1. Run `npx nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Access database via GUI
|
||||
|
||||
Run `yarn database:gui`
|
||||
Run `npm run database:gui`
|
||||
|
||||
https://www.prisma.io/studio
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `yarn database:push`
|
||||
Run `npm run database:push`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
|
||||
#### Create schema migration
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
Run `npm run prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||
|
43
Dockerfile
43
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./.yarnrc .yarnrc
|
||||
COPY ./package-lock.json package-lock.json
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN yarn install
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
@ -33,34 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
|
||||
RUN yarn build:production
|
||||
RUN npm run build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
RUN yarn
|
||||
RUN npm install
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:20-slim
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
RUN chown -R node:node /ghostfolio
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
USER node
|
||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||
|
17
README.md
17
README.md
@ -150,15 +150,14 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `npm install`
|
||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema
|
||||
1. Run `npm run database:setup` to initialize the database schema
|
||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open https://localhost:4200/en in your browser
|
||||
@ -168,31 +167,31 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
Run `npm run start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client` and open https://localhost:4200/en in your browser
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
Run `yarn start:storybook`
|
||||
Run `npm run start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
yarn database:push
|
||||
npm run database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
Run `npm test`
|
||||
|
||||
## Public API
|
||||
|
||||
|
@ -46,6 +46,39 @@ export class OrderService {
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async assignTags({
|
||||
dataSource,
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: { tags: Tag[]; userId: string } & UniqueAsset) {
|
||||
const orders = await this.prismaService.order.findMany({
|
||||
where: {
|
||||
userId,
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
orders.map(({ id }) =>
|
||||
this.prismaService.order.update({
|
||||
data: {
|
||||
tags: {
|
||||
// The set operation replaces all existing connections with the provided ones
|
||||
set: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
},
|
||||
where: { id }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
@ -29,7 +30,8 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
isRestrictedView
|
||||
isRestrictedView,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
DateRange,
|
||||
@ -38,12 +40,14 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@ -51,12 +55,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
@ -566,23 +571,23 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPosition(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const position = await this.portfolioService.getPosition(
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
return position;
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
return holding;
|
||||
}
|
||||
|
||||
@Get('report')
|
||||
@ -605,4 +610,36 @@ export class PortfolioController {
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put('position/:dataSource/:symbol/tags')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateHoldingTags(
|
||||
@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.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,78 +0,0 @@
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
let portfolioService: PortfolioService;
|
||||
|
||||
beforeAll(async () => {
|
||||
portfolioService = new PortfolioService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercentage: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercentage: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercentage: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
@ -58,7 +59,8 @@ import {
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma
|
||||
Prisma,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import {
|
||||
@ -70,7 +72,7 @@ import {
|
||||
parseISO,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
||||
import {
|
||||
@ -206,24 +208,6 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercentage: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
activities,
|
||||
groupBy
|
||||
@ -713,7 +697,7 @@ export class PortfolioService {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
||||
const dividendYieldPercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
@ -721,7 +705,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const dividendYieldPercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||
0
|
||||
@ -1321,6 +1305,24 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async updateTags({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
impersonationId: string;
|
||||
symbol: string;
|
||||
tags: Tag[];
|
||||
userId: string;
|
||||
}) {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
|
||||
}
|
||||
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
@ -1724,13 +1726,13 @@ export class PortfolioService {
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
||||
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||
})?.toNumber();
|
||||
|
||||
const annualizedPerformancePercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(
|
||||
netPerformancePercentageWithCurrencyEffect
|
||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Tag } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
|
||||
export class UpdateHoldingTagsDto {
|
||||
@IsArray()
|
||||
tags: Tag[];
|
||||
}
|
@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
HoldingsViewMode,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
||||
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||
@IsOptional()
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
@ -190,7 +190,7 @@ export class UserService {
|
||||
(user.Settings.settings as UserSettings).dateRange =
|
||||
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||
? 'max'
|
||||
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
||||
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||
|
||||
// Set default value for view mode
|
||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||
@ -243,6 +243,9 @@ export class UserService {
|
||||
|
||||
// Reset benchmark
|
||||
user.Settings.settings.benchmark = undefined;
|
||||
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
|
@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.user?.permissions,
|
||||
permissions.reportDataGlitch
|
||||
),
|
||||
hasPermissionToUpdateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||
!user?.settings?.isRestrictedView,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -19,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import {
|
||||
MatAutocompleteModule,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import {
|
||||
@ -36,14 +44,15 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
GfLineChartComponent,
|
||||
GfPortfolioProportionChartComponent,
|
||||
GfValueComponent,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatTabsModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'holding-detail-dialog.html'
|
||||
})
|
||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
public accounts: Account[];
|
||||
public activities: Activity[];
|
||||
public assetClass: string;
|
||||
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public dividendInBaseCurrencyPrecision = 2;
|
||||
public dividendYieldPercentWithCurrencyEffect: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public firstBuyDate: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public investment: number;
|
||||
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public SymbolProfile: EnhancedSymbolProfile;
|
||||
public tags: Tag[];
|
||||
public tagsAvailable: Tag[];
|
||||
public totalItems: number;
|
||||
public transactionCount: number;
|
||||
public user: User;
|
||||
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
||||
private formBuilder: FormBuilder,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { tags } = this.dataService.fetchInfo();
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
tags: <string[]>[]
|
||||
});
|
||||
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
};
|
||||
});
|
||||
|
||||
this.activityForm
|
||||
.get('tags')
|
||||
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((tags) => {
|
||||
this.dataService
|
||||
.putHoldingTags({
|
||||
tags,
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchHoldingDetail({
|
||||
dataSource: this.data.dataSource,
|
||||
@ -248,12 +293,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||
this.sectors = {};
|
||||
this.SymbolProfile = SymbolProfile;
|
||||
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
};
|
||||
});
|
||||
|
||||
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
|
||||
|
||||
this.filteredTagsObservable = this.activityForm.controls[
|
||||
'tags'
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags
|
||||
? this.filterTags(aTags)
|
||||
: this.tagsAvailable.slice();
|
||||
})
|
||||
);
|
||||
|
||||
this.transactionCount = transactionCount;
|
||||
this.totalItems = transactionCount;
|
||||
this.value = value;
|
||||
@ -353,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -377,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onRemoveTag(aTag: Tag) {
|
||||
this.activityForm.get('tags').setValue(
|
||||
this.activityForm.get('tags').value.filter(({ id }) => {
|
||||
return id !== aTag.id;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -325,7 +325,7 @@
|
||||
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
class="mb-3"
|
||||
class="mb-5"
|
||||
[mat-stretch-tabs]="false"
|
||||
[ngClass]="{ 'd-none': !activities?.length }"
|
||||
>
|
||||
@ -375,7 +375,49 @@
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
@if (tags?.length > 0) {
|
||||
<div
|
||||
class="row"
|
||||
[ngClass]="{
|
||||
'd-none': !data.hasPermissionToUpdateOrder
|
||||
}"
|
||||
>
|
||||
<div class="col">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-grid #tagsChipList>
|
||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||
<mat-chip-row
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
(removed)="onRemoveTag(tag)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
||||
</mat-chip-row>
|
||||
}
|
||||
<input
|
||||
#tagInput
|
||||
name="close-outline"
|
||||
[matAutocomplete]="autocompleteTags"
|
||||
[matChipInputFor]="tagsChipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
/>
|
||||
</mat-chip-grid>
|
||||
<mat-autocomplete
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="h5" i18n>Tags</div>
|
||||
|
@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToReportDataGlitch: boolean;
|
||||
hasPermissionToUpdateOrder: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
HoldingType,
|
||||
HoldingViewMode,
|
||||
HoldingsViewMode,
|
||||
ToggleOption
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
@ -18,7 +18,7 @@ import { FormControl } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { skip, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-home-holdings',
|
||||
@ -26,6 +26,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './home-holdings.html'
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
|
||||
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToAccessHoldingsChart: boolean;
|
||||
@ -37,7 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
{ label: $localize`Closed`, value: 'CLOSED' }
|
||||
];
|
||||
public user: User;
|
||||
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
|
||||
public viewModeFormControl = new FormControl<HoldingsViewMode>(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
);
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -81,6 +85,21 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.viewModeFormControl.valueChanges
|
||||
.pipe(
|
||||
// Skip inizialization: "new FormControl"
|
||||
skip(1),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe((holdingsViewMode) => {
|
||||
this.dataService
|
||||
.putUserSetting({ holdingsViewMode })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onChangeHoldingType(aHoldingType: HoldingType) {
|
||||
@ -122,9 +141,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.hasPermissionToAccessHoldingsChart &&
|
||||
this.holdingType === 'ACTIVE'
|
||||
) {
|
||||
this.viewModeFormControl.enable();
|
||||
this.viewModeFormControl.enable({ emitEvent: false });
|
||||
|
||||
this.viewModeFormControl.setValue(
|
||||
this.deviceType === 'mobile'
|
||||
? HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
: this.user?.settings?.holdingsViewMode ||
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
} else if (this.holdingType === 'CLOSED') {
|
||||
this.viewModeFormControl.setValue('TABLE');
|
||||
this.viewModeFormControl.setValue(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
|
||||
this.holdings = undefined;
|
||||
|
@ -70,16 +70,16 @@
|
||||
"
|
||||
>
|
||||
<mat-option [value]="null" />
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<!--
|
||||
<mat-option value="de"
|
||||
<mat-option value="ca"
|
||||
>Català (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option value="zh"
|
||||
>Chinese (<ng-container i18n>Community</ng-container
|
||||
|
@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public isToday = isToday;
|
||||
public mode: 'create' | 'update';
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public tagsAvailable: Tag[] = [];
|
||||
public total = 0;
|
||||
public typesTranslationMap = new Map<Type, string>();
|
||||
public Validators = Validators;
|
||||
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.currencies = currencies;
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
this.platforms = platforms;
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags ? this.filterTags(aTags) : this.tags.slice();
|
||||
return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
|
||||
})
|
||||
);
|
||||
|
||||
@ -441,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tags.find(({ id }) => {
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map((tag) => {
|
||||
return tag.id;
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tags.filter((tag) => {
|
||||
return !tagIds.includes(tag.id);
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -378,11 +378,11 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }">
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-grid #tagsChipList>
|
||||
@for (tag of activityForm.get('tags')?.value; track tag) {
|
||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||
<mat-chip-row
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
@ -404,7 +404,7 @@
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag) {
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
|
@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
|
||||
import {
|
||||
AccountBalance,
|
||||
DataSource,
|
||||
Order as OrderModel
|
||||
Order as OrderModel,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
||||
@ -649,6 +650,17 @@ export class DataService {
|
||||
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
|
||||
}
|
||||
|
||||
public putHoldingTags({
|
||||
dataSource,
|
||||
symbol,
|
||||
tags
|
||||
}: { tags: Tag[] } & UniqueAsset) {
|
||||
return this.http.put<void>(
|
||||
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
|
||||
{ tags }
|
||||
);
|
||||
}
|
||||
|
||||
public putOrder(aOrder: UpdateOrderDto) {
|
||||
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -3652,7 +3652,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="f799e268a8ec6ceb427287d7aa53fa9cc790a085" datatype="html">
|
||||
<source>Fully managed Ghostfolio cloud offering.</source>
|
||||
<target state="new">Fully managed Ghostfolio cloud offering.</target>
|
||||
<target state="translated">Oferta en la nube de Ghostfolio totalmente administrada.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||
<context context-type="linenumber">152</context>
|
||||
@ -3664,7 +3664,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="5afce9034d974ae1e794d437885bf17b0ebf5a0c" datatype="html">
|
||||
<source> For ambitious investors who need the full picture of their financial assets. </source>
|
||||
<target state="new"> For ambitious investors who need the full picture of their financial assets. </target>
|
||||
<target state="translated"> Para inversores ambiciosos que necesitan una visión completa de sus activos financieros </target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||
<context context-type="linenumber">184</context>
|
||||
@ -3672,7 +3672,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="a9eaad1a5cb1b7a8d154ecb75fc613e3bd5adbf3" datatype="html">
|
||||
<source>One-time payment, no auto-renewal.</source>
|
||||
<target state="new">One-time payment, no auto-renewal.</target>
|
||||
<target state="translated">Pago único, sin renovación automática.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
|
||||
<context context-type="linenumber">280</context>
|
||||
@ -3780,7 +3780,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="3298117765569632011" datatype="html">
|
||||
<source>Switch to Ghostfolio Premium or Ghostfolio Open Source easily</source>
|
||||
<target state="new">Switch to Ghostfolio Premium or Ghostfolio Open Source easily</target>
|
||||
<target state="translated">Cambie a Ghostfolio Premium o Ghostfolio Open Source fácilmente</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
||||
<context context-type="linenumber">10</context>
|
||||
@ -3788,7 +3788,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="1921273115613254799" datatype="html">
|
||||
<source>Switch to Ghostfolio Open Source or Ghostfolio Basic easily</source>
|
||||
<target state="new">Switch to Ghostfolio Open Source or Ghostfolio Basic easily</target>
|
||||
<target state="translated">Cambie a Ghostfolio Open Source o Ghostfolio Basic fácilmente</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
|
||||
<context context-type="linenumber">12</context>
|
||||
@ -3796,7 +3796,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="b74406fd93207c23bb840732ad2760ce0efaa2c5" datatype="html">
|
||||
<source>Market data provided by</source>
|
||||
<target state="new">Market data provided by</target>
|
||||
<target state="translated">Datos de mercado proporcionados por</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
|
@ -73,40 +73,40 @@ $gf-secondary: (
|
||||
|
||||
$gf-typography: mat.m2-define-typography-config();
|
||||
|
||||
@include mat.core();
|
||||
|
||||
// Create default theme
|
||||
$gf-theme-default: mat.m2-define-light-theme(
|
||||
(
|
||||
color: (
|
||||
primary: mat.m2-define-palette($gf-primary),
|
||||
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100)
|
||||
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
|
||||
primary: mat.m2-define-palette($gf-primary)
|
||||
),
|
||||
density: -3,
|
||||
typography: $gf-typography
|
||||
)
|
||||
);
|
||||
|
||||
@include mat.all-component-themes($gf-theme-default);
|
||||
@include mat.button-density(0);
|
||||
@include mat.table-density(-1);
|
||||
|
||||
// Create dark theme
|
||||
$gf-theme-dark: mat.m2-define-dark-theme(
|
||||
(
|
||||
color: (
|
||||
primary: mat.m2-define-palette($gf-primary),
|
||||
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100)
|
||||
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
|
||||
primary: mat.m2-define-palette($gf-primary)
|
||||
),
|
||||
density: -3,
|
||||
typography: $gf-typography
|
||||
)
|
||||
);
|
||||
|
||||
.is-dark-theme {
|
||||
@include mat.all-component-colors($gf-theme-dark);
|
||||
@include mat.button-density(0);
|
||||
@include mat.table-density(-1);
|
||||
}
|
||||
|
||||
@include mat.button-density(0);
|
||||
@include mat.core();
|
||||
@include mat.table-density(-1);
|
||||
|
||||
:root {
|
||||
--gf-theme-alpha-hover: 0.04;
|
||||
--gf-theme-primary-500: #36cfcc;
|
||||
|
@ -6,7 +6,6 @@ services:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
NODE_ENV: production
|
||||
REDIS_HOST: redis
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
ports:
|
||||
@ -21,8 +20,9 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: docker.io/library/postgres:15
|
||||
env_file:
|
||||
- ../.env
|
||||
healthcheck:
|
||||
@ -32,8 +32,9 @@ services:
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: docker.io/library/redis:alpine
|
||||
env_file:
|
||||
- ../.env
|
||||
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
||||
|
@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: docker.io/library/postgres:15
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@ -9,8 +9,9 @@ services:
|
||||
- ${POSTGRES_PORT:-5432}:5432
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: docker.io/library/redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
@ -1,12 +1,16 @@
|
||||
services:
|
||||
ghostfolio:
|
||||
image: ghostfolio/ghostfolio:latest
|
||||
image: docker.io/ghostfolio/ghostfolio:latest
|
||||
init: true
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
NODE_ENV: production
|
||||
REDIS_HOST: redis
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
ports:
|
||||
@ -21,8 +25,19 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: docker.io/library/postgres:15
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- DAC_READ_SEARCH
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
env_file:
|
||||
- ../.env
|
||||
healthcheck:
|
||||
@ -32,8 +47,14 @@ services:
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: docker.io/library/redis:alpine
|
||||
user: '999:1000'
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
env_file:
|
||||
- ../.env
|
||||
command: ['redis-server', '--requirepass', $REDIS_PASSWORD]
|
||||
|
@ -1,11 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Will check if "yarn format" is run before executing.
|
||||
# Will check if "npm run format" is run before executing.
|
||||
# Called by "git commit" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message if
|
||||
# it wants to stop the commit.
|
||||
|
||||
echo "Running yarn format"
|
||||
echo "Running npm run format"
|
||||
|
||||
# Run the command and loop over its output
|
||||
FILES_TO_STAGE=""
|
||||
@ -14,13 +14,13 @@ while IFS= read -r line; do
|
||||
# Process each line here
|
||||
((i++))
|
||||
if [ $i -le 2 ]; then
|
||||
continue
|
||||
continue
|
||||
fi
|
||||
if [[ $line == Done* ]]; then
|
||||
break
|
||||
break
|
||||
fi
|
||||
FILES_TO_STAGE="$FILES_TO_STAGE $line"
|
||||
|
||||
done < <(yarn format )
|
||||
done < <(npm run format)
|
||||
git add $FILES_TO_STAGE
|
||||
echo "Files formatted. Committing..."
|
||||
|
50
libs/common/src/lib/calculation-helper.spec.ts
Normal file
50
libs/common/src/lib/calculation-helper.spec.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { getAnnualizedPerformancePercent } from './calculation-helper';
|
||||
|
||||
describe('CalculationHelper', () => {
|
||||
describe('annualized performance percentage', () => {
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercentage: new Big(0)
|
||||
}).toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercentage: new Big(0)
|
||||
}).toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercentage: new Big(0.1025)
|
||||
}).toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercentage: new Big(0.05)
|
||||
}).toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercentage: new Big(0.2374)
|
||||
}).toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
20
libs/common/src/lib/calculation-helper.ts
Normal file
20
libs/common/src/lib/calculation-helper.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Big } from 'big.js';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
export function getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercentage: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
@ -1,4 +1,9 @@
|
||||
import { ColorScheme, DateRange, ViewMode } from '@ghostfolio/common/types';
|
||||
import {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
HoldingsViewMode,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
export interface UserSettings {
|
||||
annualInterestRate?: number;
|
||||
@ -9,6 +14,7 @@ export interface UserSettings {
|
||||
emergencyFund?: number;
|
||||
'filters.accounts'?: string[];
|
||||
'filters.tags'?: string[];
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
isExperimentalFeatures?: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
language?: string;
|
||||
|
@ -1 +0,0 @@
|
||||
export type HoldingViewMode = 'CHART' | 'TABLE';
|
1
libs/common/src/lib/types/holdings-view-mode.type.ts
Normal file
1
libs/common/src/lib/types/holdings-view-mode.type.ts
Normal file
@ -0,0 +1 @@
|
||||
export type HoldingsViewMode = 'CHART' | 'TABLE';
|
@ -8,7 +8,7 @@ import type { DateRange } from './date-range.type';
|
||||
import type { Granularity } from './granularity.type';
|
||||
import type { GroupBy } from './group-by.type';
|
||||
import type { HoldingType } from './holding-type.type';
|
||||
import type { HoldingViewMode } from './holding-view-mode.type';
|
||||
import type { HoldingsViewMode } from './holdings-view-mode.type';
|
||||
import type { MarketAdvanced } from './market-advanced.type';
|
||||
import type { MarketDataPreset } from './market-data-preset.type';
|
||||
import type { MarketState } from './market-state.type';
|
||||
@ -31,7 +31,7 @@ export type {
|
||||
Granularity,
|
||||
GroupBy,
|
||||
HoldingType,
|
||||
HoldingViewMode,
|
||||
HoldingsViewMode,
|
||||
Market,
|
||||
MarketAdvanced,
|
||||
MarketDataPreset,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
@ -14,10 +15,12 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { ChartConfiguration } from 'chart.js';
|
||||
import { LinearScale } from 'chart.js';
|
||||
import { Chart } from 'chart.js';
|
||||
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { orderBy } from 'lodash';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -41,6 +44,8 @@ export class GfTreemapChartComponent
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
public static readonly HEAT_MULTIPLIER = 5;
|
||||
|
||||
public chart: Chart<'treemap'>;
|
||||
public isLoading = true;
|
||||
|
||||
@ -71,24 +76,52 @@ export class GfTreemapChartComponent
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor(ctx) {
|
||||
const netPerformancePercentWithCurrencyEffect =
|
||||
ctx.raw._data.netPerformancePercentWithCurrencyEffect;
|
||||
const annualizedNetPerformancePercentWithCurrencyEffect =
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(
|
||||
new Date(),
|
||||
ctx.raw._data.dateOfFirstActivity
|
||||
),
|
||||
netPerformancePercentage: new Big(
|
||||
ctx.raw._data.netPerformancePercentWithCurrencyEffect
|
||||
)
|
||||
}).toNumber();
|
||||
|
||||
if (netPerformancePercentWithCurrencyEffect > 0.03) {
|
||||
if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return green[9];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > 0.02) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return green[7];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > 0.01) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return green[5];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > 0) {
|
||||
} else if (annualizedNetPerformancePercentWithCurrencyEffect > 0) {
|
||||
return green[3];
|
||||
} else if (netPerformancePercentWithCurrencyEffect === 0) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect === 0
|
||||
) {
|
||||
return gray[3];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > -0.01) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
-0.01 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return red[3];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > -0.02) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
-0.02 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return red[5];
|
||||
} else if (netPerformancePercentWithCurrencyEffect > -0.03) {
|
||||
} else if (
|
||||
annualizedNetPerformancePercentWithCurrencyEffect >
|
||||
-0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
|
||||
) {
|
||||
return red[7];
|
||||
} else {
|
||||
return red[9];
|
||||
|
34039
package-lock.json
generated
Normal file
34039
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "2.98.0",
|
||||
"version": "2.101.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||
@ -15,7 +15,7 @@
|
||||
"affected:test": "nx affected:test",
|
||||
"analyze:client": "nx run client:build:production --stats-json && webpack-bundle-analyzer -p 1234 dist/apps/client/en/stats.json",
|
||||
"angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng",
|
||||
"build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && yarn replace-placeholders-in-build",
|
||||
"build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && npm run replace-placeholders-in-build",
|
||||
"build:storybook": "nx run ui:build-storybook",
|
||||
"database:format-schema": "prisma format",
|
||||
"database:generate-typings": "prisma generate",
|
||||
@ -24,7 +24,7 @@
|
||||
"database:migrate": "prisma migrate deploy",
|
||||
"database:push": "prisma db push",
|
||||
"database:seed": "prisma db seed",
|
||||
"database:setup": "yarn database:push && yarn database:seed",
|
||||
"database:setup": "npm run database:push && npm run database:seed",
|
||||
"database:validate-schema": "prisma validate",
|
||||
"dep-graph": "nx dep-graph",
|
||||
"e2e": "ng e2e",
|
||||
@ -40,10 +40,10 @@
|
||||
"replace-placeholders-in-build": "node ./replace.build.js",
|
||||
"start": "node dist/apps/api/main",
|
||||
"start:client": "nx run client:copy-assets && nx run client:serve --configuration=development-en --hmr -o",
|
||||
"start:production": "yarn database:migrate && yarn database:seed && node main",
|
||||
"start:production": "npm run database:migrate && npm run database:seed && node main",
|
||||
"start:server": "nx run api:copy-assets && nx run api:serve --watch",
|
||||
"start:storybook": "nx run ui:storybook",
|
||||
"test": "yarn test:api && yarn test:common",
|
||||
"test": "npm run test:api && npm run test:common",
|
||||
"test:api": "npx dotenv-cli -e .env.example -- nx test api",
|
||||
"test:common": "npx dotenv-cli -e .env.example -- nx test common",
|
||||
"test:single": "nx run api:test --test-file portfolio-calculator-novn-buy-and-sell.spec.ts",
|
||||
@ -116,7 +116,7 @@
|
||||
"ionicons": "7.4.0",
|
||||
"jsonpath": "1.1.1",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "13.0.0",
|
||||
"marked": "12.0.2",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"ng-extract-i18n-merge": "2.12.0",
|
||||
"ngx-device-detector": "8.0.0",
|
||||
@ -164,9 +164,9 @@
|
||||
"@nx/workspace": "19.5.1",
|
||||
"@schematics/angular": "18.1.1",
|
||||
"@simplewebauthn/types": "9.0.1",
|
||||
"@storybook/addon-essentials": "7.6.5",
|
||||
"@storybook/angular": "7.6.5",
|
||||
"@storybook/core-server": "7.6.5",
|
||||
"@storybook/addon-essentials": "8.2.6",
|
||||
"@storybook/angular": "8.2.6",
|
||||
"@storybook/core-server": "8.2.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "4.3.0",
|
||||
"@types/big.js": "6.2.2",
|
||||
"@types/body-parser": "1.19.5",
|
||||
@ -182,13 +182,13 @@
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"codelyzer": "6.0.1",
|
||||
"cypress": "6.2.1",
|
||||
"eslint": "8.56.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-cypress": "2.15.1",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-storybook": "0.6.15",
|
||||
"jest": "29.4.3",
|
||||
"jest-environment-jsdom": "29.4.3",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest-preset-angular": "14.1.0",
|
||||
"nx": "19.5.1",
|
||||
"prettier": "3.3.3",
|
||||
@ -197,7 +197,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"replace-in-file": "7.0.1",
|
||||
"shx": "0.3.4",
|
||||
"storybook": "7.0.9",
|
||||
"storybook": "8.2.6",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
"tslib": "2.6.0",
|
||||
|
Reference in New Issue
Block a user