From cb3c8c0d18088cfee579bbe694435f316c78e276 Mon Sep 17 00:00:00 2001 From: ThompsonNye Date: Sun, 22 Jun 2025 11:51:38 +0200 Subject: [PATCH] Include necessary info directly in get consumption entries response dto --- .../api/consumptions/consumption-client.ts | 12 +++-- .../app/api/consumptions/consumption-entry.ts | 2 +- .../get-consumption-entries-entry.ts | 10 ++++ .../get-consumption-entries-response.ts | 8 +-- .../src/app/api/models/consumption.ts | 9 ---- .../entry-card/entry-card.component.ts | 18 +++---- .../entries/entries/entries.component.ts | 33 ++++++------ .../services/entries-overview.service.ts | 50 ------------------- .../Consumptions/GetConsumptions.cs | 47 ++++++++++++++--- .../Consumptions/GetConsumptionsTests.cs | 32 ++++++++---- 10 files changed, 114 insertions(+), 107 deletions(-) create mode 100644 src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-entry.ts delete mode 100644 src/Vegasco-Web/src/app/api/models/consumption.ts delete mode 100644 src/Vegasco-Web/src/app/modules/entries/entries/services/entries-overview.service.ts diff --git a/src/Vegasco-Web/src/app/api/consumptions/consumption-client.ts b/src/Vegasco-Web/src/app/api/consumptions/consumption-client.ts index 1646cba..fa533ec 100644 --- a/src/Vegasco-Web/src/app/api/consumptions/consumption-client.ts +++ b/src/Vegasco-Web/src/app/api/consumptions/consumption-client.ts @@ -1,14 +1,16 @@ -import {inject, Injectable} from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {API_BASE_PATH} from '../api-base-path'; -import {map, Observable} from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { GetConsumptionEntriesResponse } from '@vegasco-web/api/consumptions/get-consumption-entries-response'; +import { map, Observable } from 'rxjs'; +import { API_BASE_PATH } from '../api-base-path'; +import { ConsumptionEntry } from './consumption-entry'; @Injectable({ providedIn: 'root', }) export class ConsumptionClient { private readonly http = inject(HttpClient); - private readonly apiBasePath = inject(API_BASE_PATH, {optional: true}); + private readonly apiBasePath = inject(API_BASE_PATH, { optional: true }); getAll(): Observable { return this.http.get(`${this.apiBasePath}/v1/consumptions`); diff --git a/src/Vegasco-Web/src/app/api/consumptions/consumption-entry.ts b/src/Vegasco-Web/src/app/api/consumptions/consumption-entry.ts index 89774b5..cff0df0 100644 --- a/src/Vegasco-Web/src/app/api/consumptions/consumption-entry.ts +++ b/src/Vegasco-Web/src/app/api/consumptions/consumption-entry.ts @@ -1,4 +1,4 @@ -interface ConsumptionEntry { +export interface ConsumptionEntry { id: string; dateTime: string; distance: number; diff --git a/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-entry.ts b/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-entry.ts new file mode 100644 index 0000000..1fcd163 --- /dev/null +++ b/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-entry.ts @@ -0,0 +1,10 @@ +export interface GetConsumptionEntriesEntry { + id: string; + dateTime: string; + distance: number; + amount: number; + car: { + id: string; + name: string; + }; +} diff --git a/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-response.ts b/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-response.ts index c4099c4..56e2f15 100644 --- a/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-response.ts +++ b/src/Vegasco-Web/src/app/api/consumptions/get-consumption-entries-response.ts @@ -1,3 +1,5 @@ -interface GetConsumptionEntriesResponse { - consumptions: ConsumptionEntry[]; -} \ No newline at end of file +import { GetConsumptionEntriesEntry } from "./get-consumption-entries-entry"; + +export interface GetConsumptionEntriesResponse { + consumptions: GetConsumptionEntriesEntry[]; +} diff --git a/src/Vegasco-Web/src/app/api/models/consumption.ts b/src/Vegasco-Web/src/app/api/models/consumption.ts deleted file mode 100644 index 0462c83..0000000 --- a/src/Vegasco-Web/src/app/api/models/consumption.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface Consumption { - id: string; - dateTime: string; - distance: number; - amount: number; - ignoreInCalculation: boolean; - carId: string; - car: Car; -} \ No newline at end of file diff --git a/src/Vegasco-Web/src/app/modules/entries/entries/components/entry-card/entry-card.component.ts b/src/Vegasco-Web/src/app/modules/entries/entries/components/entry-card/entry-card.component.ts index 6945d4f..59d3ffc 100644 --- a/src/Vegasco-Web/src/app/modules/entries/entries/components/entry-card/entry-card.component.ts +++ b/src/Vegasco-Web/src/app/modules/entries/entries/components/entry-card/entry-card.component.ts @@ -3,17 +3,17 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component, DestroyRef, inject, input, output } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; -import { - matCalendarMonthSharp, - matDeleteSharp, - matStraightenSharp, - matLocalGasStationSharp, -} from '@ng-icons/material-icons/sharp'; import { matDirectionsCarOutline, } from '@ng-icons/material-icons/outline'; +import { + matCalendarMonthSharp, + matDeleteSharp, + matLocalGasStationSharp, + matStraightenSharp, +} from '@ng-icons/material-icons/sharp'; import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client'; -import { Consumption } from '@vegasco-web/api/models/consumption'; +import { GetConsumptionEntriesEntry } from '@vegasco-web/api/consumptions/get-consumption-entries-entry'; import { RoutingService } from '@vegasco-web/services/routing.service'; import { ConfirmationService, MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -45,9 +45,9 @@ import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs'; styleUrl: './entry-card.component.scss' }) export class EntryCardComponent { - readonly entry = input.required(); + readonly entry = input.required(); - readonly entryDeleted = output(); + readonly entryDeleted = output(); private readonly routingService = inject(RoutingService); private readonly consumptionClient = inject(ConsumptionClient); diff --git a/src/Vegasco-Web/src/app/modules/entries/entries/entries.component.ts b/src/Vegasco-Web/src/app/modules/entries/entries/entries.component.ts index 08e33e9..ffc0f62 100644 --- a/src/Vegasco-Web/src/app/modules/entries/entries/entries.component.ts +++ b/src/Vegasco-Web/src/app/modules/entries/entries/entries.component.ts @@ -1,8 +1,16 @@ import { AsyncPipe, CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; import { Component, DestroyRef, inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { + matAddSharp, +} from '@ng-icons/material-icons/sharp'; +import { CarClient } from '@vegasco-web/api/cars/car-client'; +import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client'; +import { GetConsumptionEntriesEntry } from '@vegasco-web/api/consumptions/get-consumption-entries-entry'; import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { DataViewModule } from 'primeng/dataview'; @@ -20,14 +28,9 @@ import { tap, throwError } from 'rxjs'; -import { EntriesOverviewService } from './services/entries-overview.service'; -import { EntryCardComponent } from './components/entry-card/entry-card.component'; -import { - matAddSharp, -} from '@ng-icons/material-icons/sharp'; -import { NgIconComponent, provideIcons } from '@ng-icons/core'; -import { HttpErrorResponse } from '@angular/common/http'; import { SelectedCarService } from '../services/selected-car.service'; +import { EntryCardComponent } from './components/entry-card/entry-card.component'; +import { ConsumptionEntry } from '@vegasco-web/api/consumptions/consumption-entry'; @Component({ selector: 'app-entries', @@ -48,18 +51,18 @@ import { SelectedCarService } from '../services/selected-car.service'; provideIcons({ matAddSharp, }), - EntriesOverviewService, ], templateUrl: './entries.component.html', styleUrl: './entries.component.scss' }) export class EntriesComponent implements OnInit { - private readonly entriesOverviewService = inject(EntriesOverviewService); + private readonly carClient = inject(CarClient); + private readonly consumptionClient = inject(ConsumptionClient); private readonly messageService = inject(MessageService); private readonly selectedCarService = inject(SelectedCarService); private readonly destroyRef = inject(DestroyRef); - protected readonly consumptionEntries$: Observable; + protected readonly consumptionEntries$: Observable; protected readonly cars$: Observable; protected readonly skeletonsIterationSource = Array(10).fill(0); @@ -69,9 +72,10 @@ export class EntriesComponent implements OnInit { private readonly deletedEntries$ = new BehaviorSubject([]); constructor() { - const entries = this.entriesOverviewService.getEntries() + const entries = this.consumptionClient.getAll() .pipe( takeUntilDestroyed(), + map(response => response.consumptions), catchError((error) => this.handleGetEntriesError(error)) ); @@ -92,13 +96,14 @@ export class EntriesComponent implements OnInit { return nonDeletedEntries; } - return nonDeletedEntries.filter(entry => entry.carId === selectedCar.id); + return nonDeletedEntries.filter(entry => entry.car.id === selectedCar.id); }) ); - this.cars$ = this.entriesOverviewService.getCars() + this.cars$ = this.carClient.getAll() .pipe( takeUntilDestroyed(), + map(response => response.cars), map((cars) => cars .sort((a, b) => a.name.localeCompare(b.name))), tap((cars) => { @@ -129,7 +134,7 @@ export class EntriesComponent implements OnInit { .subscribe(); } - onEntryDeleted(entry: ConsumptionEntry): void { + onEntryDeleted(entry: GetConsumptionEntriesEntry): void { this.deletedEntries$.next([...this.deletedEntries$.value, entry.id]); this.messageService.add({ severity: 'success', diff --git a/src/Vegasco-Web/src/app/modules/entries/entries/services/entries-overview.service.ts b/src/Vegasco-Web/src/app/modules/entries/entries/services/entries-overview.service.ts deleted file mode 100644 index c026496..0000000 --- a/src/Vegasco-Web/src/app/modules/entries/entries/services/entries-overview.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { inject, Injectable } from "@angular/core"; -import { CarClient } from "@vegasco-web/api/cars/car-client"; -import { ConsumptionClient } from "@vegasco-web/api/consumptions/consumption-client"; -import { Consumption } from "@vegasco-web/api/models/consumption"; -import { RoutingService } from "@vegasco-web/services/routing.service"; -import { combineLatest, map, Observable, shareReplay } from "rxjs"; - -@Injectable() -export class EntriesOverviewService { - private readonly carClient = inject(CarClient); - private readonly consumptionClient = inject(ConsumptionClient); - - private cachedCars$: Observable | null = null; - - private ensureCarsAreCached(): void { - if (this.cachedCars$ !== null) { - return; - } - - this.cachedCars$ = this.carClient.getAll() - .pipe( - map(response => response.cars), - shareReplay(1) - ); - } - - getEntries(): Observable { - this.ensureCarsAreCached(); - - const entries$ = this.consumptionClient.getAll() - .pipe(map(response => response.consumptions)); - - return combineLatest([this.cachedCars$!, entries$]) - .pipe( - map(([cars, entries]) => { - return entries - .sort((a, b) => b.dateTime.localeCompare(a.dateTime)) - .map((entry): Consumption => ({ - ...entry, - car: cars.find(car => car.id === entry.carId)! - })); - }) - ) - } - - getCars(): Observable { - this.ensureCarsAreCached(); - return this.cachedCars$!; - } -} \ No newline at end of file diff --git a/src/Vegasco.Server.Api/Consumptions/GetConsumptions.cs b/src/Vegasco.Server.Api/Consumptions/GetConsumptions.cs index 1e07449..6f59411 100644 --- a/src/Vegasco.Server.Api/Consumptions/GetConsumptions.cs +++ b/src/Vegasco.Server.Api/Consumptions/GetConsumptions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Vegasco.Server.Api.Cars; using Vegasco.Server.Api.Persistence; namespace Vegasco.Server.Api.Consumptions; @@ -17,7 +18,18 @@ public static class GetConsumptions DateTimeOffset DateTime, double Distance, double Amount, - Guid CarId); + CarDto Car, + double? LiterPer100Km); + + public record CarDto( + Guid Id, + string Name) + { + public static CarDto FromCar(Car car) + { + return new CarDto(car.Id.Value, car.Name); + } + } public class Request { @@ -39,16 +51,37 @@ public static class GetConsumptions ApplicationDbContext dbContext, CancellationToken cancellationToken) { - List consumptions = await dbContext.Consumptions + List consumptions = await dbContext.Consumptions .OrderByDescending(x => x.DateTime) - .Select(x => - new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.CarId.Value)) + .Include(x => x.Car) .ToListAsync(cancellationToken); - ApiResponse apiResponse = new() + List responses = []; + + for (int i = 0; i < consumptions.Count; i++) { - Consumptions = consumptions - }; + Consumption consumption = consumptions[i]; + + double? literPer100Km = null; + + bool isLast = i == consumptions.Count - 1; + if (!isLast) + { + Consumption previousConsumption = consumptions[i + 1]; + double distanceDiff = consumption.Distance - previousConsumption.Distance; + literPer100Km = consumption.Amount / (distanceDiff / 100); + } + + responses.Add(new ResponseDto( + consumption.Id.Value, + consumption.DateTime, + consumption.Distance, + consumption.Amount, + CarDto.FromCar(consumption.Car), + literPer100Km)); + } + + ApiResponse apiResponse = new() { Consumptions = responses }; return TypedResults.Ok(apiResponse); } } \ No newline at end of file diff --git a/tests/Vegasco.Server.Api.Tests.Integration/Consumptions/GetConsumptionsTests.cs b/tests/Vegasco.Server.Api.Tests.Integration/Consumptions/GetConsumptionsTests.cs index f93ee0a..66ac6bb 100644 --- a/tests/Vegasco.Server.Api.Tests.Integration/Consumptions/GetConsumptionsTests.cs +++ b/tests/Vegasco.Server.Api.Tests.Integration/Consumptions/GetConsumptionsTests.cs @@ -31,7 +31,7 @@ public class GetConsumptionsTests : IAsyncLifetime // Arrange List createdConsumptions = []; const int numberOfConsumptions = 3; - for (var i = 0; i < numberOfConsumptions; i++) + for (int i = 0; i < numberOfConsumptions; i++) { CreateConsumption.Response createdConsumption = await CreateConsumptionAsync(); createdConsumptions.Add(createdConsumption); @@ -42,8 +42,16 @@ public class GetConsumptionsTests : IAsyncLifetime // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); - var apiResponse = await response.Content.ReadFromJsonAsync(); - apiResponse!.Consumptions.Should().BeEquivalentTo(createdConsumptions); + GetConsumptions.ApiResponse? apiResponse = + await response.Content.ReadFromJsonAsync(); + + apiResponse.Should().NotBeNull(); + apiResponse.Consumptions.Should().HaveCount(createdConsumptions.Count); + apiResponse.Consumptions.Should().BeEquivalentTo(createdConsumptions, o => o.ExcludingMissingMembers()); + apiResponse.Consumptions + .Select(x => x.Car.Id) + .Should() + .BeEquivalentTo(createdConsumptions, o => o.ExcludingMissingMembers()); } [Fact] @@ -56,26 +64,32 @@ public class GetConsumptionsTests : IAsyncLifetime // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); - var apiResponse = await response.Content.ReadFromJsonAsync(); + GetConsumptions.ApiResponse? apiResponse = + await response.Content.ReadFromJsonAsync(); apiResponse!.Consumptions.Should().BeEmpty(); } private async Task CreateConsumptionAsync() { CreateCar.Response createdCarResponse = await CreateCarAsync(); - CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); - using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); + CreateConsumption.Request createConsumptionRequest = + _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); + using HttpResponseMessage response = + await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); response.EnsureSuccessStatusCode(); - var createdConsumption = await response.Content.ReadFromJsonAsync(); + CreateConsumption.Response? createdConsumption = + await response.Content.ReadFromJsonAsync(); return createdConsumption!; } private async Task CreateCarAsync() { CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); - using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); + using HttpResponseMessage createCarResponse = + await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); createCarResponse.EnsureSuccessStatusCode(); - var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync(); + CreateCar.Response? createdCarResponse = + await createCarResponse.Content.ReadFromJsonAsync(); return createdCarResponse!; }