diff --git a/CHANGELOG.md b/CHANGELOG.md index d21c3a39..af25c9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added a symbol profile model with additional data +- Added new pie charts: Positions by continent and country + ## 1.11.0 - 05.06.2021 ### Added diff --git a/apps/api/src/app/experimental/experimental.service.ts b/apps/api/src/app/experimental/experimental.service.ts index acc3e8d5..b0d41b86 100644 --- a/apps/api/src/app/experimental/experimental.service.ts +++ b/apps/api/src/app/experimental/experimental.service.ts @@ -44,6 +44,7 @@ export class ExperimentalService { fee: 0, id: undefined, platformId: undefined, + symbolProfileId: undefined, type: Type.BUY, updatedAt: undefined, userId: undefined diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 44c055af..ac7c6bf3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -76,7 +76,8 @@ export class PortfolioService { // Get portfolio from database const orders = await this.orderService.orders({ include: { - Account: true + Account: true, + SymbolProfile: true }, orderBy: { date: 'asc' }, where: { userId: aUserId } diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts index 7c719edb..0a741ddc 100644 --- a/apps/api/src/models/order.ts +++ b/apps/api/src/models/order.ts @@ -1,4 +1,4 @@ -import { Account, Currency, Platform } from '@prisma/client'; +import { Account, Currency, Platform, SymbolProfile } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; import { IOrder } from '../services/interfaces/interfaces'; @@ -12,6 +12,7 @@ export class Order { private id: string; private quantity: number; private symbol: string; + private symbolProfile: SymbolProfile; private total: number; private type: OrderType; private unitPrice: number; @@ -24,6 +25,7 @@ export class Order { this.id = data.id || uuidv4(); this.quantity = data.quantity; this.symbol = data.symbol; + this.symbolProfile = data.symbolProfile; this.type = data.type; this.unitPrice = data.unitPrice; @@ -58,6 +60,10 @@ export class Order { return this.symbol; } + getSymbolProfile() { + return this.symbolProfile; + } + public getTotal() { return this.total; } diff --git a/apps/api/src/models/portfolio.spec.ts b/apps/api/src/models/portfolio.spec.ts index 00c3e0ec..3741b0fc 100644 --- a/apps/api/src/models/portfolio.spec.ts +++ b/apps/api/src/models/portfolio.spec.ts @@ -189,6 +189,7 @@ describe('Portfolio', () => { id: '8d999347-dee2-46ee-88e1-26b344e71fcc', quantity: 1, symbol: 'BTCUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 49631.24, updatedAt: null, @@ -223,6 +224,7 @@ describe('Portfolio', () => { }, allocationCurrent: 1, allocationInvestment: 1, + countries: [], currency: Currency.USD, exchange: UNKNOWN_KEY, grossPerformance: 0, @@ -290,6 +292,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', quantity: 0.2, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 991.49, updatedAt: null, @@ -324,6 +327,7 @@ describe('Portfolio', () => { }, // allocationCurrent: 1, allocationInvestment: 1, + countries: [], currency: Currency.USD, exchange: UNKNOWN_KEY, // grossPerformance: 0, @@ -385,6 +389,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', quantity: 0.2, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 991.49, updatedAt: null, @@ -401,6 +406,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', quantity: 0.3, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 1050, updatedAt: null, @@ -461,6 +467,7 @@ describe('Portfolio', () => { id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475', quantity: 0.05614682, symbol: 'BTCUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 3562.089535970158, updatedAt: null, @@ -477,6 +484,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', quantity: 0.2, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 991.49, updatedAt: null, @@ -550,6 +558,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', quantity: 0.2, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 991.49, updatedAt: null, @@ -566,6 +575,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', quantity: 0.1, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.SELL, unitPrice: 1050, updatedAt: null, @@ -582,6 +592,7 @@ describe('Portfolio', () => { id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', quantity: 0.2, symbol: 'ETHUSD', + symbolProfileId: null, type: Type.BUY, unitPrice: 1050, updatedAt: null, diff --git a/apps/api/src/models/portfolio.ts b/apps/api/src/models/portfolio.ts index 5cd26ba2..4d0c057e 100644 --- a/apps/api/src/models/portfolio.ts +++ b/apps/api/src/models/portfolio.ts @@ -8,7 +8,10 @@ import { Position, UserWithSettings } from '@ghostfolio/common/interfaces'; +import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { DateRange, OrderWithAccount } from '@ghostfolio/common/types'; +import { Prisma } from '@prisma/client'; +import { continents, countries } from 'countries-list'; import { add, format, @@ -127,6 +130,7 @@ export class Portfolio implements PortfolioInterface { id, quantity, symbol, + symbolProfile, type, unitPrice }) => { @@ -139,6 +143,7 @@ export class Portfolio implements PortfolioInterface { id, quantity, symbol, + symbolProfile, type, unitPrice }) @@ -204,6 +209,7 @@ export class Portfolio implements PortfolioInterface { symbols.forEach((symbol) => { const accounts: PortfolioPosition['accounts'] = {}; + let countriesOfSymbol: Country[]; const [portfolioItem] = portfolioItems; const ordersBySymbol = this.getOrders().filter((order) => { @@ -243,6 +249,21 @@ export class Portfolio implements PortfolioInterface { original: originalValueOfSymbol }; } + + countriesOfSymbol = ( + (orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ?? + [] + ).map((country) => { + const { code, weight } = country as Prisma.JsonObject; + + return { + code: code as string, + continent: + continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, + name: countries[code as string]?.name ?? UNKNOWN_KEY, + weight: weight as number + }; + }); }); let now = portfolioItemsNow.positions[symbol].marketPrice; @@ -289,6 +310,7 @@ export class Portfolio implements PortfolioInterface { ) / value, allocationInvestment: portfolioItem.positions[symbol].investment / investment, + countries: countriesOfSymbol, grossPerformance: roundTo( portfolioItemsNow.positions[symbol].quantity * (now - before), 2 @@ -296,7 +318,12 @@ export class Portfolio implements PortfolioInterface { grossPerformancePercent: roundTo((now - before) / before, 4), investment: portfolioItem.positions[symbol].investment, quantity: portfolioItem.positions[symbol].quantity, - transactionCount: portfolioItem.positions[symbol].transactionCount + transactionCount: portfolioItem.positions[symbol].transactionCount, + value: this.exchangeRateDataService.toCurrency( + portfolioItem.positions[symbol].quantity * now, + data[symbol]?.currency, + this.user.Settings.currency + ) }; }); @@ -544,6 +571,7 @@ export class Portfolio implements PortfolioInterface { fee: order.fee, quantity: order.quantity, symbol: order.symbol, + symbolProfile: order.SymbolProfile, type: order.type, unitPrice: order.unitPrice }) diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index bea21738..0547471a 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -1,5 +1,5 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { Account, Currency, DataSource } from '@prisma/client'; +import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client'; import { OrderType } from '../../models/order-type'; @@ -41,6 +41,7 @@ export interface IOrder { id?: string; quantity: number; symbol: string; + symbolProfile: SymbolProfile; type: OrderType; unitPrice: number; } diff --git a/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts b/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts index 541f7cff..a01097f0 100644 --- a/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts @@ -3,6 +3,7 @@ import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/to import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { PortfolioItem, PortfolioPosition, @@ -21,6 +22,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public accounts: { [symbol: string]: Pick & { value: number }; }; + public continents: { + [code: string]: { name: string; value: number }; + }; + public countries: { + [code: string]: { name: string; value: number }; + }; public deviceType: string; public period = 'current'; public periodOptions: ToggleOption[] = [ @@ -97,6 +104,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { aPeriod: string ) { this.accounts = {}; + this.continents = { + [UNKNOWN_KEY]: { + name: UNKNOWN_KEY, + value: 0 + } + }; + this.countries = { + [UNKNOWN_KEY]: { + name: UNKNOWN_KEY, + value: 0 + } + }; this.positions = {}; this.positionsArray = []; @@ -122,11 +141,53 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { aPeriod === 'original' ? original : current; } else { this.accounts[account] = { - value: aPeriod === 'original' ? original : current, - name: account + name: account, + value: aPeriod === 'original' ? original : current }; } } + + if (position.countries.length > 0) { + for (const country of position.countries) { + const { code, continent, name, weight } = country; + + if (this.continents[continent]?.value) { + this.continents[continent].value += weight * position.value; + } else { + this.continents[continent] = { + name: continent, + value: + weight * + (aPeriod === 'original' + ? this.portfolioPositions[symbol].investment + : this.portfolioPositions[symbol].value) + }; + } + + if (this.countries[code]?.value) { + this.countries[code].value += weight * position.value; + } else { + this.countries[code] = { + name, + value: + weight * + (aPeriod === 'original' + ? this.portfolioPositions[symbol].investment + : this.portfolioPositions[symbol].value) + }; + } + } + } else { + this.continents[UNKNOWN_KEY].value += + aPeriod === 'original' + ? this.portfolioPositions[symbol].investment + : this.portfolioPositions[symbol].value; + + this.countries[UNKNOWN_KEY].value += + aPeriod === 'original' + ? this.portfolioPositions[symbol].investment + : this.portfolioPositions[symbol].value; + } } } diff --git a/apps/client/src/app/pages/tools/analysis/analysis-page.html b/apps/client/src/app/pages/tools/analysis/analysis-page.html index fe219b28..0b0ff231 100644 --- a/apps/client/src/app/pages/tools/analysis/analysis-page.html +++ b/apps/client/src/app/pages/tools/analysis/analysis-page.html @@ -102,6 +102,50 @@ +
+ + + By Continent + + + + + + +
+
+ + + By Country + + + + + + +
diff --git a/libs/common/src/lib/interfaces/country.interface.ts b/libs/common/src/lib/interfaces/country.interface.ts new file mode 100644 index 00000000..4119d91e --- /dev/null +++ b/libs/common/src/lib/interfaces/country.interface.ts @@ -0,0 +1,6 @@ +export interface Country { + code: string; + continent: string; + name: string; + weight: number; +} diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index 5184d188..ad120cbf 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -1,12 +1,15 @@ import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { Currency } from '@prisma/client'; +import { Country } from './country.interface'; + export interface PortfolioPosition { accounts: { [name: string]: { current: number; original: number }; }; allocationCurrent: number; allocationInvestment: number; + countries: Country[]; currency: Currency; exchange?: string; grossPerformance: number; @@ -24,4 +27,5 @@ export interface PortfolioPosition { symbol: string; type?: string; url?: string; + value: number; } diff --git a/libs/common/src/lib/types/order-with-account.type.ts b/libs/common/src/lib/types/order-with-account.type.ts index db14b6df..12556d38 100644 --- a/libs/common/src/lib/types/order-with-account.type.ts +++ b/libs/common/src/lib/types/order-with-account.type.ts @@ -1,5 +1,8 @@ -import { Account, Order, Platform } from '@prisma/client'; +import { Account, Order, Platform, SymbolProfile } from '@prisma/client'; type AccountWithPlatform = Account & { Platform?: Platform }; -export type OrderWithAccount = Order & { Account?: AccountWithPlatform }; +export type OrderWithAccount = Order & { + Account?: AccountWithPlatform; + SymbolProfile?: SymbolProfile; +}; diff --git a/package.json b/package.json index 24524a2c..870e98cf 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "cheerio": "1.0.0-rc.6", "class-transformer": "0.3.2", "class-validator": "0.13.1", + "countries-list": "2.6.1", "countup.js": "2.0.7", "cryptocurrencies": "7.0.0", "date-fns": "2.19.0", diff --git a/prisma/migrations/20210605161257_added_symbol_profile/migration.sql b/prisma/migrations/20210605161257_added_symbol_profile/migration.sql new file mode 100644 index 00000000..07c3f5ce --- /dev/null +++ b/prisma/migrations/20210605161257_added_symbol_profile/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "symbolProfileId" TEXT; + +-- CreateTable +CREATE TABLE "SymbolProfile" ( + "countries" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dataSource" "DataSource" NOT NULL, + "id" TEXT NOT NULL, + "name" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + "symbol" TEXT NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SymbolProfile.dataSource_symbol_unique" ON "SymbolProfile"("dataSource", "symbol"); + +-- AddForeignKey +ALTER TABLE "Order" ADD FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c5f756a..7cf7c2fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,22 +59,24 @@ model MarketData { } model Order { - Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId]) - accountId String? - accountUserId String? - createdAt DateTime @default(now()) - currency Currency - dataSource DataSource @default(YAHOO) - date DateTime - fee Float - id String @default(uuid()) - quantity Float - symbol String - type Type - unitPrice Float - updatedAt DateTime @updatedAt - User User @relation(fields: [userId], references: [id]) - userId String + Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId]) + accountId String? + accountUserId String? + createdAt DateTime @default(now()) + currency Currency + dataSource DataSource @default(YAHOO) + date DateTime + fee Float + id String @default(uuid()) + quantity Float + symbol String + SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id]) + symbolProfileId String? + type Type + unitPrice Float + updatedAt DateTime @updatedAt + User User @relation(fields: [userId], references: [id]) + userId String @@id([id, userId]) } @@ -99,6 +101,19 @@ model Settings { userId String @id } +model SymbolProfile { + countries Json? + createdAt DateTime @default(now()) + dataSource DataSource + id String @id @default(uuid()) + name String? + Order Order[] + updatedAt DateTime @updatedAt + symbol String + + @@unique([dataSource, symbol]) +} + model Subscription { createdAt DateTime @default(now()) expiresAt DateTime diff --git a/prisma/seed.ts b/prisma/seed.ts index 9d425c70..f445a9ad 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,6 +1,7 @@ import { AccountType, Currency, + DataSource, PrismaClient, Role, Type @@ -135,17 +136,47 @@ async function main() { where: { id: '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f' } }); + await prisma.symbolProfile.createMany({ + data: [ + { + countries: [{ code: 'US', weight: 1 }], + dataSource: DataSource.YAHOO, + id: '2bd26362-136e-411c-b578-334084b4cdcc', + symbol: 'AMZN' + }, + { + countries: [{ code: 'US', weight: 1 }], + dataSource: DataSource.YAHOO, + id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e', + symbol: 'TSLA' + }, + { + countries: [ + { code: 'US', weight: 0.9886789999999981 }, + { code: 'NL', weight: 0.000203 }, + { code: 'CA', weight: 0.000362 } + ], + dataSource: DataSource.YAHOO, + id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', + symbol: 'VTI' + } + ], + skipDuplicates: true + }); + await prisma.order.createMany({ data: [ { accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)), fee: 30, id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1', quantity: 50, symbol: 'TSLA', + symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e', type: Type.BUY, unitPrice: 42.97, userId: userDemo.id @@ -154,6 +185,7 @@ async function main() { accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)), fee: 29.9, id: 'a1c5d73a-8631-44e5-ac44-356827a5212c', @@ -167,11 +199,13 @@ async function main() { accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)), fee: 80.79, id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b', quantity: 5, symbol: 'AMZN', + symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc', type: Type.BUY, unitPrice: 2021.99, userId: userDemo.id @@ -180,11 +214,13 @@ async function main() { accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)), fee: 19.9, id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e', quantity: 10, symbol: 'VTI', + symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', type: Type.BUY, unitPrice: 144.38, userId: userDemo.id @@ -193,11 +229,13 @@ async function main() { accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)), fee: 19.9, id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e', quantity: 10, symbol: 'VTI', + symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', type: Type.BUY, unitPrice: 147.99, userId: userDemo.id @@ -206,11 +244,13 @@ async function main() { accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)), fee: 19.9, id: '347b0430-a84f-4031-a0f9-390399066ad6', quantity: 10, symbol: 'VTI', + symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', type: Type.BUY, unitPrice: 151.41, userId: userDemo.id @@ -219,11 +259,13 @@ async function main() { accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)), fee: 19.9, id: '67ec3f47-3189-4b63-ba05-60d3a06b302f', quantity: 10, symbol: 'VTI', + symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', type: Type.BUY, unitPrice: 177.69, userId: userDemo.id @@ -232,11 +274,13 @@ async function main() { accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', accountUserId: userDemo.id, currency: Currency.USD, + dataSource: DataSource.YAHOO, date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)), fee: 19.9, id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2', quantity: 10, symbol: 'VTI', + symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', type: Type.BUY, unitPrice: 203.15, userId: userDemo.id diff --git a/yarn.lock b/yarn.lock index 08e785ae..99efb34e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4698,6 +4698,11 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +countries-list@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/countries-list/-/countries-list-2.6.1.tgz#d479757ac873b1e596ccea0a925962d20396c0cb" + integrity sha512-jXM1Nv3U56dPQ1DsUSsEaGmLHburo4fnB7m+1yhWDUVvx5gXCd1ok/y3gXCjXzhqyawG+igcPYcAl4qjkvopaQ== + countup.js@2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.0.7.tgz#56b72a87fc0ee3cadb38356c246ccac88fb0a8cc"