Skip to content

Commit a2cbb22

Browse files
committed
feat: Enhance account management by adding current balance field and loading indicators in balance component
1 parent 01687d6 commit a2cbb22

10 files changed

Lines changed: 245 additions & 180 deletions

File tree

.github/copilot-instructions.md

Lines changed: 48 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,48 @@
1-
You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices.
2-
3-
## TypeScript Best Practices
4-
5-
- Use strict type checking
6-
- Prefer type inference when the type is obvious
7-
- Avoid the `any` type; use `unknown` when type is uncertain
8-
9-
## Angular Best Practices
10-
11-
- Always use standalone components over NgModules
12-
- Must NOT set `standalone: true` inside Angular decorators. It's the default.
13-
- Use signals for state management
14-
- Implement lazy loading for feature routes
15-
- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
16-
- Use `NgOptimizedImage` for all static images.
17-
- `NgOptimizedImage` does not work for inline base64 images.
18-
19-
## Components
20-
21-
- Keep components small and focused on a single responsibility
22-
- Use `input()` and `output()` functions instead of decorators
23-
- Use `computed()` for derived state
24-
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
25-
- Prefer inline templates for small components
26-
- Prefer Reactive forms instead of Template-driven ones
27-
- Do NOT use `ngClass`, use `class` bindings instead
28-
- Do NOT use `ngStyle`, use `style` bindings instead
29-
- Use Angular Material components, avoid custom UI components when possible
30-
- Avoid CSS when a custom component is provided by Angular Material or us (e.g., app-flex)
31-
32-
## State Management
33-
34-
- Use signals for local component state
35-
- Use `computed()` for derived state
36-
- Keep state transformations pure and predictable
37-
- Do NOT use `mutate` on signals, use `update` or `set` instead
38-
39-
## Templates
40-
41-
- Keep templates simple and avoid complex logic
42-
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
43-
- Use the async pipe to handle observables
44-
45-
## Services
46-
47-
- Design services around a single responsibility
48-
- Use the `providedIn: 'root'` option for singleton services
49-
- Use the `inject()` function instead of constructor injection
50-
51-
## Styling
52-
- Use Angular's built-in styling capabilities (e.g., component styles)
53-
- Prefer Angular Material CSS variables for theming
54-
- Avoid global styles; scope styles to components
1+
You are GitHub Copilot working in the BankTracker GraphQL monorepo. Follow the patterns already in the codebase before introducing new approaches.
2+
3+
Keep in mind the basics of programming like
4+
5+
YAGNI, DRY2, NEVER RESORT TO USE SOLID
6+
7+
## Architecture
8+
- GraphQL backend (`PhantomDave.BankTracking.Api`) exposes accounts and finance records via HotChocolate; schema is composed from `Types/Queries|Mutations|Inputs|ObjectTypes` with `ExtendObjectType` partials.
9+
- Data layer (`PhantomDave.BankTracking.Data`) wraps EF Core against PostgreSQL with a repository + unit-of-work abstraction registered through `AddDataAccess`.
10+
- Domain models live in `PhantomDave.BankTracking.Library` and are shared by API, data, and background jobs.
11+
- Angular 20 frontend (`frontend/`) uses Apollo Angular with generated GQL services, Angular Material, and signals; state is mostly managed inside `models/*-service.ts` files.
12+
- A hosted service (`RecurringFinanceRecordService`) materializes recurring finance records by cloning templates on a timed loop, so new recurrence features must remain idempotent.
13+
14+
## Backend (ASP.NET + HotChocolate)
15+
- Prefer throwing `GraphQLException` built via `ErrorBuilder` with explicit `SetCode` values; clients rely on codes like `BAD_USER_INPUT`, `UNAUTHENTICATED`, and `NOT_FOUND` for UX.
16+
- Retrieve the authenticated account id by calling `httpContextAccessor.GetAccountIdFromContext()`; the JWT must contain `ClaimTypes.NameIdentifier` or mutations will fail.
17+
- Keep service logic inside `Services/*Service.cs` and reuse the injected `IUnitOfWork`; call `SaveChangesAsync` on the unit of work to commit changes.
18+
- Normalize and validate inputs (e.g., currency via `NormalizeCurrency`, description length trimming) before persistence to keep recurring job assumptions intact.
19+
- New GraphQL types should extend the root via `[ExtendObjectType(OperationTypeNames.Query|Mutation)]` and convert entities using `From*` factory helpers in `Types/ObjectTypes`.
20+
- Database migrations live in `PhantomDave.BankTracking.Data/Migrations`; ensure Postgres is running (`docker compose -f compose.dev.yaml up -d database`) before applying or updating schema.
21+
22+
## Frontend (Angular + Apollo)
23+
- App uses `provideZonelessChangeDetection` and signals; prefer `signal()`, `computed()`, and `effect()` over RxJS state unless bridging to Apollo streams.
24+
- Components are standalone by default—do not add `standalone: true` manually—but always list dependencies in `imports` and lean on Angular Material before custom UI.
25+
- Use the generated Apollo classes (e.g., `CreateAccountGQL`, `GetFinanceRecordsGQL`) with `firstValueFrom` or `.watch().valueChanges`; match operation names with `refetchQueries` strings (`'getFinanceRecords'`).
26+
- Local auth state is stored in `localStorage` as `sessionData`; guards and the `unauthorizedInterceptor` expect `SessionData` to stay in sync with backend `verifyToken` responses.
27+
- Prefer signals over component-level Observables; when you must bridge, update signals in a `tap` and remember to set/reset `_loading` and `_error` signals like existing services do.
28+
- UI feedback goes through `SnackbarService`; reuse `snackbar.success/error` rather than opening `MatSnackBar` manually for consistent styling.
29+
30+
## GraphQL Workflow
31+
- Add operations as `.graphql` files near the feature (see `frontend/src/app/models/finance-record/gql/*.graphql`); keep operation names unique and PascalCase or camelCase that matches existing usage.
32+
- Run `npm run codegen` (auto-runs on `npm start` via the `prestart` script) to refresh `src/generated/graphql.ts` and `schema.graphql`; the script reads the backend JWT secret from `appsettings.Development.json` or environment variables.
33+
- If the backend schema changes, run `./update-schema.sh` after starting the API (`cd PhantomDave.BankTracking.Api && dotnet run`) so the codegen script can reach `http://localhost:5095/graphql`.
34+
- When adding scalar fields, update the `scalars` mapping in `frontend/codegen.ts` only if the backend type is new; clients currently assume `DateTime``string` converted to `Date` in services.
35+
36+
## Developer Workflow
37+
- Backend: `dotnet run` (or VS Code task `build`) from `PhantomDave.BankTracking.Api`; the app auto-migrates the database on startup.
38+
- Frontend: `npm run start` in `frontend/` (triggers codegen). Fix the failing command first if schema generation fails—usually missing backend or JWT secret.
39+
- Tests: there are no automated tests yet; verify critical flows manually (login, recurring creation) after impactful changes.
40+
- Docker: the root `Dockerfile` builds the API only; run `docker compose -f compose.dev.yaml up -d database` for a local Postgres instance.
41+
42+
## Conventions & Pitfalls
43+
- Stick to ASCII in code and messages; existing Italian copy in snackbars is unintentional, translate them to english.
44+
- Avoid `@HostBinding`/`@HostListener`; register host styles via the `host` object (see `FlexComponent`).
45+
- Signals should not call `.mutate`; use `.set`/`.update` as in `AccountService` and `FinanceRecordService` to keep change detection predictable without zones.
46+
- Background service creates non-recurring instances by copying template records; new fields must populate both the recurring template and cloned instance to avoid drift.
47+
- Currency values are persisted as `numeric(18,2)`; always send numbers (not strings) from the frontend and uppercase 3-letter codes to satisfy backend validation.
48+
- Keep GraphQL error codes stable—UI interceptors look specifically for `UNAUTHENTICATED` to trigger logout and route redirects.

PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public async Task<AccountType> CreateAccount(
2929
.SetExtension("field", "email")
3030
.SetExtension("reason", "required")
3131
.Build());
32+
3233
}
3334

3435
if (string.IsNullOrWhiteSpace(password))
@@ -90,6 +91,7 @@ public async Task<AccountType> CreateAccount(
9091
.SetExtension("field", "email")
9192
.SetExtension("reason", "invalid_credentials")
9293
.Build());
94+
9395
return AccountType.FromAccount(account);
9496
}
9597

PhantomDave.BankTracking.Api/Types/ObjectTypes/AccountType.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using HotChocolate.Types;
12
using PhantomDave.BankTracking.Library.Models;
23

34
namespace PhantomDave.BankTracking.Api.Types.ObjectTypes;
@@ -7,6 +8,8 @@ public class AccountType
78
public int Id { get; set; }
89

910
public string Email { get; set; } = string.Empty;
11+
12+
[GraphQLType(typeof(DecimalType))]
1013
public decimal CurrentBalance { get; set; }
1114

1215
public DateTime CreatedAt { get; set; }
@@ -20,7 +23,7 @@ public class AccountType
2023
{
2124
Id = account.Id,
2225
Email = account.Email,
23-
CurrentBalance = account.CurrentBalance ?? 0,
26+
CurrentBalance = account.CurrentBalance ?? 0,
2427
CreatedAt = account.CreatedAt,
2528
UpdatedAt = account.UpdatedAt,
2629
};

frontend/schema.graphql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ directive @cost(
1515

1616
type AccountType {
1717
createdAt: DateTime!
18-
currentBalance: Decimal!
18+
currentBalance: Decimal
1919
email: String!
2020
id: Int!
2121
updatedAt: DateTime

frontend/src/app/balance/balance-component/balance-component.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
.balance-card {
22
display: block;
3+
position: relative;
4+
}
5+
6+
.loading-overlay {
7+
position: absolute;
8+
inset: 0;
9+
display: flex;
10+
align-items: center;
11+
justify-content: center;
12+
gap: 12px;
13+
background-color: rgba(0, 0, 0, 0.05);
14+
z-index: 1;
15+
pointer-events: all;
16+
}
17+
18+
.loading-text {
19+
font: var(--mat-sys-title-small);
20+
color: var(--mat-sys-on-surface);
321
}
422

523
[mat-card-avatar] {

frontend/src/app/balance/balance-component/balance-component.html

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1-
<mat-card appearance="outlined" class="balance-card" aria-label="Account balance summary">
1+
<mat-card
2+
appearance="outlined"
3+
class="balance-card"
4+
aria-label="Account balance summary"
5+
[attr.aria-busy]="loading()"
6+
>
7+
<div *ngIf="loading()" class="loading-overlay" aria-live="polite">
8+
<mat-progress-spinner
9+
mode="indeterminate"
10+
diameter="48"
11+
strokeWidth="4"
12+
aria-label="Loading account balance"
13+
></mat-progress-spinner>
14+
<span class="loading-text">Loading balance...</span>
15+
</div>
16+
217
<mat-card-header>
318
<mat-icon mat-card-avatar aria-hidden="true">account_balance_wallet</mat-icon>
419
<mat-card-title>Account Balance</mat-card-title>
Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,82 @@
1-
import { CurrencyPipe, DatePipe } from '@angular/common';
2-
import { Component, computed, effect, inject, OnInit, signal } from '@angular/core';
1+
import { CurrencyPipe, DatePipe, NgIf } from '@angular/common';
2+
import {
3+
ChangeDetectionStrategy,
4+
Component,
5+
computed,
6+
effect,
7+
inject,
8+
OnInit,
9+
signal,
10+
} from '@angular/core';
311
import { MatCardModule } from '@angular/material/card';
4-
import { MatIconModule } from '@angular/material/icon';
512
import { MatDividerModule } from '@angular/material/divider';
6-
import { FinanceRecordService } from '../../models/finance-record/finance-record-service';
13+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
14+
import { MatIconModule } from '@angular/material/icon';
15+
import { AccountService } from '../../models/account/account-service';
716
import { FinanceRecord } from '../../models/finance-record/finance-record';
17+
import { FinanceRecordService } from '../../models/finance-record/finance-record-service';
818

919
@Component({
1020
selector: 'app-balance-component',
11-
imports: [CurrencyPipe, DatePipe, MatCardModule, MatIconModule, MatDividerModule],
21+
imports: [
22+
CurrencyPipe,
23+
DatePipe,
24+
NgIf,
25+
MatCardModule,
26+
MatIconModule,
27+
MatDividerModule,
28+
MatProgressSpinnerModule,
29+
],
1230
templateUrl: './balance-component.html',
1331
styleUrl: './balance-component.css',
32+
changeDetection: ChangeDetectionStrategy.OnPush,
1433
})
1534
export class BalanceComponent implements OnInit {
1635
private readonly financeRecordService = inject(FinanceRecordService);
17-
private readonly records = signal<readonly FinanceRecord[]>(
36+
private readonly accountService = inject(AccountService);
37+
private readonly records = computed<readonly FinanceRecord[]>(() =>
1838
this.financeRecordService.financeRecords(),
1939
);
40+
private readonly currentMonthRecords = computed(() => {
41+
const today = this.today;
42+
return this.records().filter((record) => {
43+
const recordDate = new Date(record.date);
44+
return (
45+
recordDate.getMonth() === today.getMonth() &&
46+
recordDate.getFullYear() === today.getFullYear()
47+
);
48+
});
49+
});
50+
51+
readonly loading = computed(
52+
() => this.financeRecordService.loading() || this.accountService.loading(),
53+
);
54+
private readonly account = computed(() => this.accountService.selectedAccount());
2055

2156
readonly defaultCurrency = 'EUR';
2257

23-
readonly balance = computed(() => {
24-
const records = this.records();
25-
const total = records.reduce((sum, record) => sum + record.amount, 0);
26-
return total;
27-
});
58+
readonly balance = computed(() => this.account()?.currentBalance ?? 0);
2859

29-
today = new Date();
30-
daysPassed = this.today.getDate();
60+
readonly today = new Date();
61+
private readonly daysInMonth = new Date(
62+
this.today.getFullYear(),
63+
this.today.getMonth() + 1,
64+
0,
65+
).getDate();
66+
private readonly daysElapsed = Math.max(1, Math.min(this.daysInMonth, this.today.getDate()));
3167

3268
readonly averageDailyExpense = computed(() => {
33-
return (
34-
this.records()
35-
.filter((record) => {
36-
const recordDate = new Date(record.date);
37-
return (
38-
recordDate.getMonth() === this.today.getMonth() &&
39-
recordDate.getFullYear() === this.today.getFullYear()
40-
);
41-
})
42-
.reduce((sum, record) => {
43-
if (record.amount < 0) return sum + record.amount;
44-
return sum;
45-
}, 0) / this.daysPassed
46-
);
69+
const totalExpenses = this.currentMonthRecords()
70+
.filter((record) => record.amount < 0)
71+
.reduce((sum, record) => sum + record.amount, 0);
72+
return totalExpenses / this.daysElapsed;
4773
});
4874

4975
readonly averageDailyIncome = computed(() => {
50-
return (
51-
this.records()
52-
.filter((record) => {
53-
const recordDate = new Date(record.date);
54-
return (
55-
recordDate.getMonth() === this.today.getMonth() &&
56-
recordDate.getFullYear() === this.today.getFullYear()
57-
);
58-
})
59-
.reduce((sum, record) => {
60-
if (record.amount > 0) return sum + record.amount;
61-
return sum;
62-
}, 0) / 31
63-
);
76+
const totalIncome = this.currentMonthRecords()
77+
.filter((record) => record.amount > 0)
78+
.reduce((sum, record) => sum + record.amount, 0);
79+
return totalIncome / this.daysElapsed;
6480
});
6581

6682
readonly totalRecurringExpenses = computed(() => {
@@ -73,17 +89,20 @@ export class BalanceComponent implements OnInit {
7389

7490
constructor() {
7591
effect(() => {
92+
this.records();
93+
this.account();
7694
this.lastUpdated.set(new Date());
7795
});
7896
}
7997

8098
readonly endOfMonthPrediction = computed(() => {
81-
const daysInMonth = new Date(this.today.getFullYear(), this.today.getMonth() + 1, 0).getDate();
82-
const remainingDays = daysInMonth - this.daysPassed;
83-
return this.averageDailyExpense() * remainingDays + this.balance();
99+
const remainingDays = Math.max(0, this.daysInMonth - this.daysElapsed);
100+
const projectedDailyNet = this.averageDailyIncome() + this.averageDailyExpense();
101+
return this.balance() + projectedDailyNet * remainingDays;
84102
});
85103

86104
async ngOnInit(): Promise<void> {
87105
await this.financeRecordService.getFinanceRecords();
106+
await this.accountService.getUserAccount();
88107
}
89108
}

frontend/src/app/components/settings-component/settings-component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
@let emailControl = accountForm.controls.email;
3232
<mat-form-field appearance="outline" class="form-field">
3333
<mat-label>Email</mat-label>
34-
<input matInput type="email" formControlName="email" required autocomplete="email" />
34+
<input matInput type="email" formControlName="email" required autocomplete="email" [value]="account()?.email"/>
3535
@if (emailControl.hasError('required') && (emailControl.dirty || emailControl.touched)) {
3636
<mat-error>Email is required.</mat-error>
3737
}
@@ -62,7 +62,7 @@
6262
mat-flat-button
6363
color="primary"
6464
type="submit"
65-
[disabled]="accountForm.invalid || loading()"
65+
[disabled]="loading()"
6666
>
6767
Save changes
6868
</button>

0 commit comments

Comments
 (0)