Browse Source

Release 3.0.0 (#6768)

* Feature/migrate from Angular Material design 2 to 3 (#6163)

* Migrate from M2 to M3

* Update changelog

---------

Co-authored-by: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com>

* Task/upgrade to prisma 7 (#6027)

* Upgrade Prisma to version 7.3.0

* Update changelog

* Feature/restore badge style ("filter indicator") in assistant menu button of header (#6253)

* feat(styles): add badge overrides

* fix(styles): make the parent display inline-flex

* feat(ui): update styles

* Task/cleanup m3-theme.scss (#6252)

* feat(styles): cleanup m3-theme.scss

* Task/decrease horizontal padding of buttons (#6679)

Decrease horizontal padding of buttons

* Task/decrease border radius of dialogs (#6680)

Decrease border radius of dialogs

* Task/upgrade prisma to version 7.7.0 (#6749)

* Upgrade prisma to version 7.7.0

* Update changelog

* Bugfix/prisma client initialization in admin service (#6764)

* Fix prisma client initialization

* Feature/add blog post: Ghostfolio 3 (#6678)

* Add blog post: Ghostfolio 3

* Update changelog

* Task/move extended values on analysis page from experimental to general availability (#6766)

* Move extended values to general availability

* Update changelog

* Release 3.0.0

---------

Co-authored-by: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com>
pull/6769/head 3.0.0
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
65941eb62e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      .config/prisma.ts
  2. 2
      .env.dev
  3. 2
      .env.example
  4. 13
      CHANGELOG.md
  5. 4
      README.md
  6. 30
      apps/api/src/app/admin/admin.service.ts
  7. 4
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  8. 4
      apps/api/src/middlewares/html-template.middleware.ts
  9. 6
      apps/api/src/services/prisma/prisma.service.ts
  10. 2
      apps/client/src/app/app.component.ts
  11. 68
      apps/client/src/app/components/header/header.component.html
  12. 13
      apps/client/src/app/components/header/header.component.scss
  13. 21
      apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts
  14. 281
      apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html
  15. 26
      apps/client/src/app/pages/blog/blog-page.html
  16. 9
      apps/client/src/app/pages/blog/blog-page.routes.ts
  17. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  18. BIN
      apps/client/src/assets/images/blog/ghostfolio-3.jpg
  19. 83
      apps/client/src/styles.scss
  20. 498
      apps/client/src/styles/theme.scss
  21. 1
      libs/common/jest.config.ts
  22. 3
      libs/common/src/test-setup.ts
  23. 867
      package-lock.json
  24. 7
      package.json
  25. 1
      prisma/schema.prisma
  26. 7
      prisma/seed.mts

3
.config/prisma.ts

@ -6,6 +6,9 @@ import { join } from 'node:path';
expand(config({ quiet: true }));
export default defineConfig({
datasource: {
url: process.env.DATABASE_URL ?? ''
},
migrations: {
path: join(__dirname, '..', 'prisma', 'migrations'),
seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}`

2
.env.dev

@ -12,7 +12,7 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# DEVELOPMENT

2
.env.example

@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

13
CHANGELOG.md

@ -5,16 +5,27 @@ 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
## 3.0.0 - 2026-04-23
### Added
- Added a blog post: _Announcing Ghostfolio 3.0_
### Changed
- Migrated from _Material Design_ 2 to _Material Design_ 3
- Moved the total amount, change and performance with currency effects on the analysis page from experimental to general availability
- Refreshed the cryptocurrencies list
- Upgraded `countup.js` from version `2.9.0` to `2.10.0`
- Upgraded `jsonpath` from version `1.2.1` to `1.3.0`
- Upgraded `nestjs` from version `11.1.14` to `11.1.19`
- Upgraded `ngx-markdown` from version `21.1.0` to `21.2.0`
- Upgraded `Nx` from version `22.6.4` to `22.6.5`
- Upgraded `prisma` from version `6.19.0` to `7.7.0`
### Todo
- **Breaking Change**: The `sslmode=prefer` parameter in `DATABASE_URL` is no longer supported. Please update your environment variables (see `.env`) to use `sslmode=require` if _SSL_ is enabled or remove the `sslmode` parameter entirely if _SSL_ is not used.
## 2.255.0 - 2026-04-20

4
README.md

@ -86,11 +86,11 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |

30
apps/api/src/app/admin/admin.service.ts

@ -36,7 +36,6 @@ import {
BadRequestException,
HttpException,
Injectable,
Logger,
NotFoundException
} from '@nestjs/common';
import {
@ -44,7 +43,6 @@ import {
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
} from '@prisma/client';
@ -280,7 +278,6 @@ export class AdminService {
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
@ -392,9 +389,8 @@ export class AdminService {
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
(SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
@ -404,8 +400,8 @@ export class AdminService {
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
@ -417,20 +413,19 @@ export class AdminService {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
currency,
dataSource,
id,
isActive,
lastMarketPrice,
name,
symbol,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
}
@ -455,11 +450,6 @@ export class AdminService {
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
}
public async getMarketDataBySymbol({
@ -704,8 +694,6 @@ export class AdminService {
}
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
@ -746,7 +734,7 @@ export class AdminService {
});
});
return new PrismaClient().$extends(symbolProfileExtension);
return this.prismaService.$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {

4
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -120,6 +120,10 @@ export class SitemapService {
{
languageCode: 'en',
routerLink: ['2025', '11', 'black-weeks-2025']
},
{
languageCode: 'en',
routerLink: ['2026', '04', 'ghostfolio-3']
}
]
.map(({ languageCode, routerLink }) => {

4
apps/api/src/middlewares/html-template.middleware.ts

@ -83,6 +83,10 @@ const locales = {
'/en/blog/2025/11/black-weeks-2025': {
featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg',
title: `Black Weeks 2025 - ${title}`
},
'/en/blog/2026/04/ghostfolio-3': {
featureGraphicPath: 'assets/images/blog/ghostfolio-3.jpg',
title: `Announcing Ghostfolio 3.0 - ${title}`
}
};

6
apps/api/src/services/prisma/prisma.service.ts

@ -6,6 +6,7 @@ import {
OnModuleInit
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from '@prisma/client';
@Injectable()
@ -14,6 +15,10 @@ export class PrismaService
implements OnModuleInit, OnModuleDestroy
{
public constructor(configService: ConfigService) {
const adapter = new PrismaPg({
connectionString: configService.get<string>('DATABASE_URL')
});
let customLogLevels: LogLevel[];
try {
@ -28,6 +33,7 @@ export class PrismaService
: [];
super({
adapter,
log,
errorFormat: 'colorless'
});

2
apps/client/src/app/app.component.ts

@ -336,7 +336,9 @@ export class GfAppComponent implements OnInit {
if (isDarkTheme) {
this.document.body.classList.add('theme-dark');
this.document.body.classList.remove('theme-light');
} else {
this.document.body.classList.add('theme-light');
this.document.body.classList.remove('theme-dark');
}

68
apps/client/src/app/components/header/header.component.html

@ -2,7 +2,7 @@
@if (user) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a
class="align-items-center justify-content-start rounded-0"
class="align-items-center h-100 justify-content-start rounded-0"
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
@ -15,9 +15,9 @@
<ul class="align-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.home.path ||
@ -32,9 +32,9 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path,
'text-decoration-underline':
@ -46,9 +46,9 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.accounts.path,
'text-decoration-underline':
@ -61,9 +61,9 @@
@if (hasPermissionToAccessAdminControl) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.adminControl.path,
@ -77,9 +77,9 @@
}
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === routeResources
@ -93,8 +93,8 @@
) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
mat-flat-button
class="d-none d-sm-block rounded"
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
@ -112,9 +112,9 @@
}
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
@ -124,15 +124,15 @@
>
</li>
@if (hasPermissionToAccessAssistant) {
<li class="list-inline-item">
<li class="d-inline-flex list-inline-item">
<button
#assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2"
mat-button
matBadge="✓"
matBadge="&NoBreak;"
matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu"
matButton
[matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters"
[matMenuTriggerFor]="assistantMenu"
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
>
@ -164,7 +164,7 @@
<li class="list-inline-item">
<button
class="no-min-width px-1"
mat-flat-button
mat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
@ -334,7 +334,7 @@
@if (user === null) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a
class="align-items-center justify-content-start rounded-0"
class="align-items-center h-100 justify-content-start rounded-0"
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
@ -350,9 +350,9 @@
<ul class="align-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatures
@ -363,9 +363,9 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
@ -377,8 +377,8 @@
@if (hasPermissionForSubscription) {
<li class="list-inline-item">
<a
class="d-sm-block"
mat-flat-button
class="d-sm-block rounded"
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
@ -397,9 +397,9 @@
@if (hasPermissionToAccessFearAndGreedIndex) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeMarkets,
'text-decoration-underline': currentRoute === routeMarkets
@ -413,19 +413,23 @@
<a
class="d-none d-sm-block no-min-width p-1"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
mat-button
><ion-icon name="logo-github"
/></a>
</li>
<li class="list-inline-item">
<button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">
<button
class="d-sm-block rounded"
mat-button
(click)="openLoginDialog()"
>
<ng-container i18n>Sign in</ng-container>
</button>
</li>
@if (currentRoute !== 'register' && hasPermissionToCreateUser) {
<li class="list-inline-item ml-1">
<a
class="d-none d-sm-block"
class="d-none d-sm-block px-3 rounded"
color="primary"
i18n
mat-flat-button

13
apps/client/src/app/components/header/header.component.scss

@ -24,7 +24,7 @@
}
.mdc-button {
height: 100%;
color: var(--mdc-text-button-label-text-color);
&:not(.mat-primary) {
background-color: transparent;
@ -32,17 +32,6 @@
text-underline-offset: 0.25rem;
}
&.mat-badge {
::ng-deep {
.mat-badge-content {
--mat-badge-small-size-container-overlap-offset: -0.9rem;
--mat-badge-small-size-text-size: 0;
transform: scale(0.45);
}
}
}
ion-icon {
font-size: 1.5rem;

21
apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts

@ -0,0 +1,21 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-3-page',
templateUrl: './ghostfolio-3-page.html'
})
export class Ghostfolio3PageComponent {
public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
}

281
apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html

@ -0,0 +1,281 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio 3.0</h1>
<div class="mb-3 text-muted"><small>2026-04-23</small></div>
<img
alt="Ghostfolio 3.0 Teaser"
class="border rounded w-100"
src="../assets/images/blog/ghostfolio-3.jpg"
title="Announcing Ghostfolio 3.0"
/>
</div>
<section class="mb-4">
<p>
Since the
<a href="../en/blog/2023/09/ghostfolio-2">last major version</a>
of <a [routerLink]="routerLinkAbout">Ghostfolio</a>, we have shipped
over 250 releases. The project now counts 275+ contributors from
around the globe and has surpassed 2’300’000 pulls on Docker Hub.
These milestones reflect steady adoption and our focus on
simplifying investment tracking while prioritizing user privacy.
</p>
<p>
Today’s release marks the next major version in our Open Source
Software (OSS) journey.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Introducing Ghostfolio 3.0</h2>
<p>
Ghostfolio 3.0 is the evolution of our
<a [routerLink]="routerLinkFeatures"
>open source wealth management software</a
>, with meaningful improvements for both users and developers. We
have refreshed the user interface, expanded analytics, improved
stability, added more languages, and updated the technology stack to
support these changes. Here is a closer look at a selection of what
you can expect from this
<a [routerLink]="routerLinkAboutChangelog">release</a>, alongside
many smaller additions and enhancements.
</p>
<h3 class="h5">Refreshed User Interface</h3>
<p>
Ghostfolio 3.0 comes with a refreshed user interface that modernizes
the visual appearance of the application. The updated design is
cleaner, with refined components and improved consistency across the
platform.
</p>
<h3 class="h5">Comprehensive Analytics</h3>
<p>
This release provides a broader set of tools to help you understand
your portfolio. Ghostfolio X-ray uses static analysis to highlight
potential issues and risks, with rules that can now be customized to
match your investment strategy.
</p>
<h3 class="h5">Extended Multilanguage Support</h3>
<p>
Largely driven by contributions from the community, Ghostfolio now
supports more languages than ever. The application is available in a
growing number of languages, making it accessible to a broader
audience of investors around the world.
</p>
<h3 class="h5">Reliable Stability</h3>
<p>
A wealth management platform needs to be reliable. With Ghostfolio
3.0, we have further strengthened the robustness of our
architecture, so you can rely on Ghostfolio across different
<a [routerLink]="routerLinkMarkets">market conditions</a>.
</p>
<h3 class="h5">Empowering Self-Hosting</h3>
<p>
This release simplifies and extends the self-hosting experience. A
major addition is that self-hosters can now fully benefit from
<a target="_blank" [href]="pricingUrl">Ghostfolio Premium</a> to
make use of a professional data provider. This gives you full
control over your infrastructure while still having access to
high-quality market data for portfolio analytics.
</p>
<h3 class="h5">Updated Technology Stack</h3>
<p>
Under the hood, Ghostfolio 3.0 has been upgraded to
<a href="https://angular.io" target="_blank">Angular 21</a>,
<a href="https://nestjs.com" target="_blank">Nest.js 11</a>,
<a href="https://www.prisma.io" target="_blank">Prisma 7</a>, and
<a href="https://nx.dev" target="_blank">Nx 22</a>. Keeping the
technology stack up to date helps us provide a solid foundation for
users and developers.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Thriving Ghostfolio Community</h2>
<p>
Ghostfolio is built in public, and its community plays a central
role in shaping the open source project. Here are some highlights of
the community growth:
</p>
<ul>
<li>
Ghostfolio has accumulated <strong>8’000+ stars</strong> on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>, reflecting the growing interest and trust in the project.
</li>
<li>
The
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community has grown to over <strong>1’250 members</strong>, where
investors exchange ideas and help each other.
</li>
<li>
Over
<strong>700 investors and personal finance enthusiasts</strong>
follow Ghostfolio on
<a href="https://x.com/ghostfolio_" target="_blank">X</a>
(formerly Twitter) for updates and discussions.
</li>
</ul>
<p>
There is much more to come. If you are not part of the community
yet, we would love to have you on board.
</p>
<p>
<strong>Join our Slack community</strong>: Connect with fellow
investors, share insights, and stay updated by joining our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community.
</p>
<p>
<strong>Follow us on X</strong>: For release updates and market
insights, follow
<a href="https://x.com/ghostfolio_" target="_blank"
>Ghostfolio on X</a
>
to stay informed.
</p>
<p>
<strong>Give us a Star</strong>: If Ghostfolio has been useful to
you, please consider giving us a star on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>. Your support helps us continue improving Ghostfolio.
</p>
<p>
<strong>Become a contributor</strong>: Interested in getting
involved? We welcome contributions from developers who are willing
to dive into open source and personal finance topics.
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
>Join our developer community</a
>
and help shape the future of Ghostfolio.
</p>
</section>
<section>
<p>
Ghostfolio 3.0 is the result of countless contributions, feedback,
and shared passion for open source and personal finance. Whether you
have been with us from the start or are just discovering the
project, thank you for being part of this community.
</p>
<p>Thomas from Ghostfolio</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Announcement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Collaboration</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Contribution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Evolution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio 3.0</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio Premium</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internationalization</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nest.js</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Platform</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Release</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Self-Hosting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stack</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="routerLinkBlog">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Announcing Ghostfolio 3.0
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

26
apps/client/src/app/pages/blog/blog-page.html

@ -8,6 +8,32 @@
finance</small
>
</h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden p-3 w-100"
href="../en/blog/2026/04/ghostfolio-3"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Announcing Ghostfolio 3.0
</div>
<div class="d-flex text-muted">2026-04-03</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
@if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">

9
apps/client/src/app/pages/blog/blog-page.routes.ts

@ -218,5 +218,14 @@ export const routes: Routes = [
(c) => c.BlackWeeks2025PageComponent
),
title: 'Black Weeks 2025'
},
{
canActivate: [AuthGuard],
path: '2026/04/ghostfolio-3',
loadComponent: () =>
import('./2026/04/ghostfolio-3/ghostfolio-3-page.component').then(
(c) => c.Ghostfolio3PageComponent
),
title: 'Ghostfolio 3.0'
}
];

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

@ -75,7 +75,6 @@
</div>
}
@if (user?.settings?.isExperimentalFeatures) {
<div class="mb-5 row">
<div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined">
@ -138,7 +137,6 @@
</mat-card>
</div>
</div>
}
<div class="mb-5 row">
<div class="col-lg">

BIN
apps/client/src/assets/images/blog/ghostfolio-3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

83
apps/client/src/styles.scss

@ -1,3 +1,5 @@
@use '@angular/material' as mat;
@import './styles/bootstrap';
@import './styles/table';
@import './styles/variables';
@ -256,46 +258,10 @@ body {
}
}
.mat-mdc-card {
--mat-card-elevated-container-color: var(--dark-background);
--mat-card-outlined-container-color: var(--dark-background);
}
.mat-mdc-fab {
&.mat-primary {
--mat-fab-icon-color: rgba(var(--dark-primary-text));
--mat-mdc-fab-color: rgba(var(--dark-primary-text));
}
}
.mat-mdc-paginator {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
}
.mat-mdc-slide-toggle {
.mdc-switch__track {
--mat-slide-toggle-selected-focus-track-color: rgba(
255,
255,
255,
0.12
);
--mat-slide-toggle-selected-hover-track-color: rgba(
255,
255,
255,
0.12
);
--mat-slide-toggle-selected-pressed-track-color: rgba(
255,
255,
255,
0.12
);
--mat-slide-toggle-selected-track-color: rgba(255, 255, 255, 0.12);
}
}
.mdc-button {
&.mat-accent,
&.mat-primary {
@ -305,10 +271,6 @@ body {
.page {
&.has-tabs {
.mat-mdc-tab-nav-bar {
--mat-tab-inactive-label-text-color: rgba(var(--light-primary-text));
}
@media (min-width: 576px) {
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
@ -443,8 +405,6 @@ ngx-skeleton-loader {
.mat-mdc-card {
.mat-mdc-card-title {
--mat-card-title-text-line-height: 1.2;
margin-bottom: 0.5rem;
}
}
@ -468,15 +428,6 @@ ngx-skeleton-loader {
}
}
.mat-mdc-fab {
color: var(--mat-mdc-fab-color, inherit) !important;
&.mat-primary {
--mat-fab-icon-color: rgba(var(--light-primary-text));
--mat-mdc-fab-color: rgba(var(--light-primary-text));
}
}
.mat-mdc-form-field {
&.without-hint {
.mat-mdc-form-field-subscript-wrapper {
@ -517,15 +468,6 @@ ngx-skeleton-loader {
}
}
.mat-mdc-slide-toggle {
.mdc-switch__track {
--mat-slide-toggle-selected-focus-track-color: rgba(0, 0, 0, 0.12);
--mat-slide-toggle-selected-hover-track-color: rgba(0, 0, 0, 0.12);
--mat-slide-toggle-selected-pressed-track-color: rgba(0, 0, 0, 0.12);
--mat-slide-toggle-selected-track-color: rgba(0, 0, 0, 0.12);
}
}
.mat-stepper-vertical,
.mat-stepper-horizontal {
background: transparent !important;
@ -587,32 +529,23 @@ ngx-skeleton-loader {
}
}
.mat-mdc-tab-nav-bar {
--mat-tab-active-focus-indicator-color: transparent;
--mat-tab-active-hover-indicator-color: transparent;
--mat-tab-inactive-label-text-color: rgba(var(--dark-primary-text));
--mat-tab-active-indicator-color: transparent;
}
.mat-mdc-tab-nav-panel {
padding: 2rem 0;
}
@media (max-width: 575.98px) {
.mat-mdc-tab-link {
--mat-tab-container-height: 3rem;
}
}
@media (min-width: 576px) {
flex-direction: row-reverse;
@include mat.tabs-overrides(
(
divider-height: 0
)
);
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base), 0.02);
padding: 2rem 0;
width: 14rem;
--mat-tab-label-text-tracking: normal;
--mat-tab-container-height: 2rem;
.mat-mdc-tab-links {
flex-direction: column;

498
apps/client/src/styles/theme.scss

@ -1,109 +1,437 @@
@use '@angular/material' as mat;
@use 'sass:map';
@use './variables.scss' as variables;
$gf-primary: (
50: var(--gf-theme-primary-50),
100: var(--gf-theme-primary-100),
200: var(--gf-theme-primary-200),
300: var(--gf-theme-primary-300),
400: var(--gf-theme-primary-400),
500: var(--gf-theme-primary-500),
600: var(--gf-theme-primary-600),
700: var(--gf-theme-primary-700),
800: var(--gf-theme-primary-800),
900: var(--gf-theme-primary-900),
A100: var(--gf-theme-primary-A100),
A200: var(--gf-theme-primary-A200),
A400: var(--gf-theme-primary-A400),
A700: var(--gf-theme-primary-A700),
contrast: (
50: variables.$dark-primary-text,
100: variables.$dark-primary-text,
200: variables.$dark-primary-text,
300: variables.$light-primary-text,
400: variables.$light-primary-text,
500: variables.$light-primary-text,
600: variables.$light-primary-text,
700: variables.$light-primary-text,
800: variables.$light-primary-text,
900: variables.$light-primary-text,
A100: variables.$dark-primary-text,
A200: variables.$light-primary-text,
A400: variables.$light-primary-text,
A700: variables.$light-primary-text
$dark-primary-text: rgba(black, 0.87);
$light-primary-text: white;
$_palettes: (
primary: (
0: #000000,
10: #00201f,
20: #003736,
25: #004342,
30: #00504e,
35: #005d5b,
40: #006a68,
50: #008583,
60: #00a19f,
70: #11bebc,
80: #47dbd7,
90: #6bf7f4,
95: #affffc,
98: #e3fffd,
99: #f2fffe,
100: #ffffff
),
secondary: (
0: #000000,
10: #001d36,
20: #003258,
25: #003d6a,
30: #00497c,
35: #00558f,
40: #0061a3,
50: #267bc3,
60: #4895df,
70: #66b0fb,
80: #9ecaff,
90: #d1e4ff,
95: #e9f1ff,
98: #f8f9ff,
99: #fdfcff,
100: #ffffff
),
tertiary: (
0: #000000,
10: #031d35,
20: #1b324b,
25: #273d57,
30: #324863,
35: #3e546f,
40: #4a607b,
50: #637995,
60: #7c92b0,
70: #97adcc,
80: #b2c8e8,
90: #d2e4ff,
95: #eaf1ff,
98: #f8f9ff,
99: #fdfcff,
100: #ffffff
),
neutral: (
0: #000000,
10: #191c1c,
20: #2d3131,
25: #383c3c,
30: #444747,
35: #4f5353,
40: #5b5f5e,
50: #747877,
60: #8e9191,
70: #a9acab,
80: #c4c7c6,
90: #e0e3e2,
95: #eff1f0,
98: #f7faf9,
99: #fafdfc,
100: #ffffff,
4: #0b0f0f,
6: #101414,
12: #1d2020,
17: #272b2a,
22: #323535,
24: #363a39,
87: #d8dada,
92: #e6e9e8,
94: #eceeed,
96: #f2f4f3
),
neutral-variant: (
0: #000000,
10: #141d1d,
20: #293232,
25: #343d3d,
30: #3f4948,
35: #4a5454,
40: #566060,
50: #6f7978,
60: #889392,
70: #a3adac,
80: #bec9c7,
90: #dae5e3,
95: #e8f3f2,
98: #f1fbfa,
99: #f4fefd,
100: #ffffff
),
error: (
0: #000000,
10: #410002,
20: #690005,
25: #7e0007,
30: #93000a,
35: #a80710,
40: #ba1a1a,
50: #de3730,
60: #ff5449,
70: #ff897d,
80: #ffb4ab,
90: #ffdad6,
95: #ffedea,
98: #fff8f7,
99: #fffbff,
100: #ffffff
)
);
$gf-secondary: (
50: var(--gf-theme-secondary-50),
100: var(--gf-theme-secondary-100),
200: var(--gf-theme-secondary-200),
300: var(--gf-theme-secondary-300),
400: var(--gf-theme-secondary-400),
500: var(--gf-theme-secondary-500),
600: var(--gf-theme-secondary-600),
700: var(--gf-theme-secondary-700),
800: var(--gf-theme-secondary-800),
900: var(--gf-theme-secondary-900),
A100: var(--gf-theme-secondary-A100),
A200: var(--gf-theme-secondary-A200),
A400: var(--gf-theme-secondary-A400),
A700: var(--gf-theme-secondary-A700),
contrast: (
50: variables.$dark-primary-text,
100: variables.$dark-primary-text,
200: variables.$dark-primary-text,
300: variables.$light-primary-text,
400: variables.$light-primary-text,
500: variables.$light-primary-text,
600: variables.$light-primary-text,
700: variables.$light-primary-text,
800: variables.$light-primary-text,
900: variables.$light-primary-text,
A100: variables.$dark-primary-text,
A200: variables.$light-primary-text,
A400: variables.$light-primary-text,
A700: variables.$light-primary-text
)
$_rest: (
secondary: map.get($_palettes, secondary),
neutral: map.get($_palettes, neutral),
neutral-variant: map.get($_palettes, neutral-variant),
error: map.get($_palettes, error)
);
$_primary: map.merge(map.get($_palettes, primary), $_rest);
$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest);
@include mat.app-background();
@include mat.button-density(0);
@include mat.elevation-classes();
@include mat.table-density(-1);
$gf-typography: mat.m2-define-typography-config();
// $gf-typography: mat.m2-define-typography-config();
// Create default theme
$gf-theme-default: mat.m2-define-light-theme(
.theme-light {
$gf-theme-default: mat.define-theme(
(
color: (
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
primary: mat.m2-define-palette($gf-primary)
primary: $_primary,
theme-type: light,
tertiary: $_tertiary
),
density: -3,
typography: $gf-typography
density: (
scale: -3
),
// typography: $gf-typography
)
);
);
@include mat.all-component-themes($gf-theme-default);
@include mat.autocomplete-overrides(
(
background-color: var(--light-background)
)
);
@include mat.button-overrides(
(
outlined-label-text-color: var(--dark-primary-text),
text-label-text-color: var(--dark-primary-text)
)
);
@include mat.button-toggle-overrides(
(
selected-state-background-color: rgba(var(--dark-dividers)),
selected-state-text-color: var(--dark-primary-text)
)
);
@include mat.card-overrides(
(
outlined-container-color: var(--light-background),
outlined-outline-color: rgba(var(--dark-dividers)),
title-text-line-height: 1.2
)
);
@include mat.datepicker-overrides(
(
calendar-container-background-color: var(--light-background)
)
);
@include mat.dialog-overrides(
(
container-color: var(--light-background)
)
);
@include mat.menu-overrides(
(
container-color: var(--light-background)
)
);
@include mat.select-overrides(
(
panel-background-color: var(--light-background)
)
);
@include mat.all-component-themes($gf-theme-default);
@include mat.slide-toggle-overrides(
(
selected-track-outline-color: rgba(var(--dark-dividers)),
track-outline-color: rgba(var(--dark-dividers))
)
);
@include mat.table-overrides(
(
row-item-outline-color: rgba(var(--dark-dividers))
)
);
}
// Create dark theme
$gf-theme-dark: mat.m2-define-dark-theme(
.theme-dark {
$gf-theme-dark: mat.define-theme(
(
color: (
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
primary: mat.m2-define-palette($gf-primary)
primary: $_primary,
theme-type: dark,
tertiary: $_tertiary
),
density: (
scale: -3
),
density: -3,
typography: $gf-typography
// typography: $gf-typography
)
);
);
.theme-dark {
@include mat.all-component-colors($gf-theme-dark);
@include mat.all-component-themes($gf-theme-dark);
@include mat.button-overrides(
(
outlined-label-text-color: var(--light-primary-text),
text-label-text-color: var(--light-primary-text)
)
);
@include mat.button-toggle-overrides(
(
selected-state-background-color: rgba(var(--light-dividers)),
selected-state-text-color: var(--light-primary-text)
)
);
@include mat.card-overrides(
(
outlined-container-color: var(--dark-background),
outlined-outline-color: rgba(var(--light-dividers)),
title-text-line-height: 1.2
)
);
@include mat.slide-toggle-overrides(
(
selected-track-outline-color: rgba(var(--light-dividers)),
track-outline-color: rgba(var(--light-dividers))
)
);
@include mat.table-overrides(
(
row-item-outline-color: rgba(var(--light-dividers))
)
);
}
@include mat.button-density(0);
@include mat.elevation-classes();
@include mat.app-background();
@include mat.table-density(-1);
.theme-dark,
.theme-light {
@media (max-width: 575.98px) {
@include mat.dialog-overrides(
(
container-shape: 4px
)
);
.page.has-tabs {
@include mat.tabs-overrides(
(
container-height: 3rem
)
);
}
}
@media (min-width: 576px) {
.page.has-tabs {
@include mat.tabs-overrides(
(
container-height: 2rem
)
);
}
}
@include mat.badge-overrides(
(
background-color: var(--gf-theme-primary-500)
)
);
@include mat.button-overrides(
(
filled-container-color: var(--gf-theme-primary-500),
filled-horizontal-padding: 1rem,
outlined-horizontal-padding: 1rem,
text-horizontal-padding: 1rem
)
);
@include mat.checkbox-overrides(
(
selected-icon-color: var(--gf-theme-primary-500)
)
);
@include mat.datepicker-overrides(
(
calendar-container-elevation-shadow: var(
--mat-select-container-elevation-shadow
),
calendar-date-selected-state-background-color: var(--gf-theme-primary-500),
calendar-date-today-selected-state-outline-color: var(
--gf-theme-primary-500
)
)
);
@include mat.dialog-overrides(
(
container-max-width: 80vw,
container-shape: 8px,
container-small-max-width: 96vw
)
);
@include mat.fab-overrides(
(
container-color: var(--gf-theme-primary-500)
)
);
@include mat.form-field-overrides(
(
outlined-focus-label-text-color: var(--gf-theme-primary-500),
outlined-focus-outline-color: var(--gf-theme-primary-500)
)
);
@include mat.progress-bar-overrides(
(
active-indicator-color: var(--gf-theme-primary-500)
)
);
@include mat.slide-toggle-overrides(
(
selected-focus-handle-color: var(--gf-theme-primary-500),
selected-focus-track-color: transparent,
selected-handle-color: var(--gf-theme-primary-500),
selected-hover-handle-color: var(--gf-theme-primary-500),
selected-hover-track-color: transparent,
selected-pressed-handle-color: var(--gf-theme-primary-500),
selected-pressed-track-color: transparent,
selected-track-color: transparent,
unselected-hover-track-color: transparent,
unselected-track-color: transparent
)
);
@include mat.slider-overrides(
(
active-track-color: var(--gf-theme-primary-500),
focus-handle-color: var(--gf-theme-primary-500),
handle-color: var(--gf-theme-primary-500)
)
);
@include mat.stepper-overrides(
(
header-selected-state-icon-background-color: var(--gf-theme-primary-500)
)
);
@include mat.tabs-overrides(
(
active-focus-label-text-color: var(--gf-theme-primary-500),
active-hover-label-text-color: var(--gf-theme-primary-500),
active-label-text-color: var(--gf-theme-primary-500),
active-ripple-color: var(--gf-theme-primary-500),
inactive-ripple-color: var(--gf-theme-primary-500)
)
);
.mat-accent {
@include mat.button-overrides(
(
filled-container-color: var(--gf-theme-secondary-500),
outlined-label-text-color: var(--gf-theme-secondary-500)
)
);
}
.mat-warn {
@include mat.button-overrides(
(
filled-container-color: #f44336,
filled-label-text-color: white,
outlined-label-text-color: #f44336
)
);
}
.page.has-tabs {
@include mat.tabs-overrides(
(
active-indicator-height: 0,
label-text-tracking: normal
)
);
}
}
:root {
--gf-theme-alpha-hover: 0.04;

1
libs/common/jest.config.ts

@ -2,6 +2,7 @@
export default {
displayName: 'common',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
transform: {
'^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }]

3
libs/common/src/test-setup.ts

@ -0,0 +1,3 @@
import { TextDecoder, TextEncoder } from 'node:util';
Object.assign(global, { TextDecoder, TextEncoder });

867
package-lock.json

File diff suppressed because it is too large

7
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.255.0",
"version": "3.0.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -86,7 +86,8 @@
"@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.19.3",
"@prisma/adapter-pg": "7.7.0",
"@prisma/client": "7.7.0",
"@simplewebauthn/browser": "13.2.2",
"@simplewebauthn/server": "13.2.2",
"ai": "4.3.16",
@ -195,7 +196,7 @@
"nx": "22.6.5",
"prettier": "3.8.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.3",
"prisma": "7.7.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.4.0",

1
prisma/schema.prisma

@ -6,7 +6,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Access {

7
prisma/seed.mts

@ -1,6 +1,11 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
});
const prisma = new PrismaClient({ adapter });
async function main() {
await prisma.tag.createMany({

Loading…
Cancel
Save