2

I have been trying and experimenting with a load of different approaches, but I cannot seem to get it working that my template gets updated.

Currently the situation: I have an Angular 19 app, completely zoneless (so without zone.js and using provideExperimentalZonelessChangeDetection in my app.config.ts. I was also having the same issue before, in Angular 18.

I have a dashboard, that you can only reach after logging in. That part works fine. On that dashboard I want to display a small widget, showing the number of tickets sold in the last week and percentage of increase or decrease compared to the week before. Creating the widget works fine, it even adds more if I change editions (at the top, in my header, I can select an edition and it then tries to retrieve the sales data for that edition). It does create an extra widget when I do that. And the sales.service.ts shows getting the correct numbers from the API.

It's just not updating the template/website that the user is watching and my creative thinking is exhausted. I hope someone here can set me on the right track again.

Here are the relevant files:

  1. sales.service.ts:
import { Injectable, inject, DestroyRef, Signal, signal, WritableSignal } from '@angular/core';
import { Observable, of } from 'rxjs';

import { ApisService } from './apis.service';
import { Edition } from './interfaces/api-interfaces';
import { UsersService } from './users.service';
import { ToastsService } from './toasts.service';

@Injectable({
  providedIn: 'root'
})
export class SalesService {
  // First injections
  private destroyRef: DestroyRef = inject(DestroyRef);
  private toasts: ToastsService = inject(ToastsService);
  private apis: ApisService = inject(ApisService);
  private users: UsersService = inject(UsersService);
  // Then other declarations
  private _editions: WritableSignal<Edition[]> = signal([]);

  constructor() { }

  get editions (): Signal<Edition[]> {
    return this._editions;
  }

  get currentActiveEdition (): Signal<Edition> {
    return signal(this._editions().find(edition => edition.current_active === true) ?? { edition_id: 0, name: '', event_date: 0, default: 0, current_active: true });
  }

  getCurrentEditions (): void {
    if (this.users.userToken !== undefined) {
      const ticketSubscription = this.apis.ticketsEditions(this.users.userToken().userToken)
        .subscribe({
          next: (resData) => {
            if (resData.length > 0) {
              this._editions.set(resData);
              this.setCurrentActiveEdition(resData.find(edition => edition.default === 1)!.edition_id);
            } else {
              this.users.userServiceError.set(true);
              this.users.errorMsg.set({ status: 'invalid request', message: 'No editions found' });
              this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
            }
          }, error: (err) => {
            this.users.userServiceError.set(true);
            this.users.errorMsg.set(err.error);
            this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
          }
        });

      this.destroyRef.onDestroy(() => {
        ticketSubscription.unsubscribe();
      });

    } else {
      console.error(`No logged in user!! (sales.service)`);
    }
  }

  setCurrentActiveEdition (newActiveEdition: number): void {
    this._editions.update(editions => {
      return editions.map(edition => {
        return edition.edition_id === newActiveEdition ? { ...edition, current_active: true } : { ...edition, current_active: false };
      });
    });
  }

  ticketsSoldPerPeriod (start: number, end: number, editionId: number): Observable<number | null> {
    console.info(`Start date from timestamp: ${ start }`);
    console.info(`End date from timestamp: ${ end }`);
    if (this.users.userToken !== undefined) {
      const ticketSubscription = this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken)
        .subscribe({
          next: (resData) => {
            console.log(resData);
            return of(resData);
          }, error: (err) => {
            this.users.userServiceError.set(true);
            this.users.errorMsg.set(err.error);
            this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
            return of(null);
          }
        });

      this.destroyRef.onDestroy(() => {
        ticketSubscription.unsubscribe();
      });
      return of(null);
    } else {
      console.error(`No logged in user!! (sales.service)`);
      return of(null);
    }
  }
}

  1. dashboard.component.ts:
import { Component, effect, inject, OnInit, WritableSignal, Signal, signal, computed } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { bootstrapCart } from '@ng-icons/bootstrap-icons';

import { Breadcrumb, BreadcrumbsComponent } from 'src/app/core/header/breadcrumbs/breadcrumbs.component';
import { UsersService } from 'src/app/core/users.service';
import { SalesService } from 'src/app/core/sales.service';
import { WidgetData, DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component';

@Component({
  selector: 'app-dashboard',
  imports: [BreadcrumbsComponent, NgbModule, DashboardWidgetComponent, NgIconComponent],
  providers: [provideIcons({ bootstrapCart })],
  templateUrl: './dashboard.component.html',
  styleUrl: './dashboard.component.scss'
})
export class DashboardComponent implements OnInit {
  // First injections
  private users: UsersService = inject(UsersService);
  private sales: SalesService = inject(SalesService);
  // Then other declarations
  protected breadcrumbs: Breadcrumb[] = [{ linkId: 1, linkable: false, text: 'Dashboard' }];
  protected widgetInputData: WritableSignal<WidgetData[]> = signal([]);
  protected ticketsLastWeek: WritableSignal<number | null> = signal(null);
  protected ticketsPreviousWeek: WritableSignal<number | null> = signal(null);
  private currentActiveEditionId: number = 0;

  constructor() {
    effect(() => {
      if (this.currentActiveEditionId = this.sales.currentActiveEdition().edition_id) {
        console.warn(`Current active edition: ${ this.currentActiveEditionId }`);
        this.sales.ticketsSoldPerPeriod(Math.floor((Date.now() / 1000)) - 7 * (24 * 3600), Math.floor(Date.now() / 1000), this.currentActiveEditionId).subscribe({
          next: (quantity) => {
            this.ticketsLastWeek.set(quantity);
            this.sales.ticketsSoldPerPeriod(Math.floor((Date.now() / 1000)) - 14 * (24 * 3600), Math.floor(Date.now() / 1000) - 7 * (24 * 3600), this.currentActiveEditionId).subscribe({
              next: (quantity) => {
                this.ticketsPreviousWeek.set(quantity);
                console.warn(`hier dan?`);
                this.widgetInputData.update(widgetData => this.updateWidgetData(widgetData));
              }
            });
          }
        });
      }
    });
  }

  ngOnInit (): void { }

  private updateWidgetData (widgetData: WidgetData[]): WidgetData[] {
    console.warn(this.ticketsLastWeek());
    console.warn(this.ticketsPreviousWeek());

    let widgetDataNew = [...widgetData, {
      widgetId: widgetData.length + 1,
      header: 'Tickets sold',
      subTitle: 'Sold in the last week',
      iconName: 'bootstrapCart',
      data: this.ticketsLastWeek()?.toString() + ' tickets',
      deviationPercentage: (((this.ticketsLastWeek() ?? 1) / (this.ticketsPreviousWeek() ?? 1)) - 1)
    }];
    console.log(widgetDataNew);
    return widgetDataNew;
  }
};
  1. dashboard.component.html:
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
<div class="container">
  <div class="row justify-content-center">
    @for (widget of widgetInputData(); track widget.widgetId; let idx = $index) {
    <div class="col-xxl-3 col-md-3">
      <div class="card border-info mb-3">
        <div class="card-header bg-info text-white">
          <h3>{{ widget.header }}</h3>
        </div>
        <div class="card-body">
          <h6 class="card-subtitle text-muted">{{ widget.subTitle }}</h6>
        </div>
        <div class="d-flex align-items-center card-body">
          <div class="card-icon rounded-circle d-flex align-items-center justify-content-center dashboard-widget">
            <ng-icon name="bootstrapCart" size="1em"></ng-icon>
          </div>
          <div class="ps-3 card-text">
            <h6>{{ widget.data }}</h6>
            @if (widget.deviationPercentage) {
            @if (widget.deviationPercentage === 0) {

            <span class="text-info small pt-1 fw-bold"> {{ widget.deviationPercentage }}%
            </span>
            <span class="text-muted small pt-2 ps-1"> steady </span>

            } @else if (widget.deviationPercentage < 0) { <span class="text-danger small pt-1 fw-bold">
              {{ widget.deviationPercentage }}%
              </span>
              <span class="text-muted small pt-2 ps-1"> decrease </span>

              } @else if (widget.deviationPercentage > 0) {

              <span class="text-success small pt-1 fw-bold">
                {{ widget.deviationPercentage }}% </span>
              <span class="text-muted small pt-2 ps-1"> increase </span>

              }
              }
              <!-- @for (edition of editions(); track edition.edition_id) {
          <span class="text-warning fw-bold">{{ edition.name }}</span>
          } @empty {
          <span class="text-warning fw-bold">No editions found!</span>
          } -->
          </div>
        </div>
      </div>
    </div>
    }
  </div>
</div>
4
  • Is this condition a typo, or is it really in your code: this.currentActiveEditionId = this.sales.currentActiveEdition().edition_id? Should be == or ===. Other than that, there are multiple issues in this code (subscribe within subscribe), so it's bit difficult to pinpoint the cause of the issue
    – mat.hudak
    Commented Nov 25 at 13:02
  • That's actually in the code. As far as I'm aware that works as first assigning the value to this.currentActiveEditionId and then the if-statement checks if this.currentActiveEditionId equals true.
    – donald23
    Commented Nov 25 at 19:15
  • @mat.hudak not sure what you mean with the multiple issues, because perhaps I need to tackle them all. With the remark about the subscription within a subscription, I started another attempt. Removed the Observable all together, because it didn't actually change anything. In the end it keeps coming back to that the data that's coming from sales.service.ts (return resData (as it's now in my code) within ticketsSoldPerPeriod) comes after I have updated the widgetdata. And then it no longer updates the widget.
    – donald23
    Commented Nov 25 at 21:38
  • How can I get the widget to update, after the data has been returned from sales.service.ts. (I see in my console logging that it arrives later than me updating the data for any widget)
    – donald23
    Commented Nov 25 at 21:42

2 Answers 2

1

As I said in the comment, there are so many issues in the sample code, that it's difficult to tell what's the actual cause of your problem.

I strongly suspect that the issue is caused by this implementation:

ticketsSoldPerPeriod (start: number, end: number, editionId: number): Observable<number | null> {
  // Note: I would expect, that you get to this point only if you are sure, that the user is authenticated. 
  // If not, than your autentication is not handled correctly
  if (this.users.userToken !== undefined) {
    // 1. You make the ASYNC request to obtain the data, which can take some time
    const ticketSubscription = this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken)
      .subscribe(...);
    // 2. But execution of the code continues
    this.destroyRef.onDestroy(() => {
      // NOTE: sales.service.ts is provided in root - it lives as long as the applicationn does
      // So it will be destroyed only when the application is destroyed, this is useless code
      ticketSubscription.unsubscribe();
    });
    // 3. And it returns null, before ticketsSoldPerPeriod has a chance to finish
    // That way, you see that the call to the API was made, but you are returning null instead. Because of ill designed function
    return of(null);
  } else {
    console.error(`No logged in user!! (sales.service)`);
    return of(null);
  }
}

What's the point of subscribing to the API call in the service? You can return it directly. Once it's done, you'll get the result you want and you don't need to fake it with return of(null). Plus, offset day calculation is best hidden in the service, as a function to avoid writing same code multiple times - DRY principle. Just past the offset days as a parameters.

  private calculateOffset(offset: number = 0): number {
    // If offset is set to zero, nothing will be added. Negative numbers will be subtracted
    return Math.floor((Date.now() / 1000)) + (offset * (24 * 3600));
  }

  ticketsSoldPerPeriod (editionId: number, startOffset: number = 0, endOffset: number = 0): Observable<number> {
    // DRY - make a function, which takes offset as a parameter and write the calculation only once
    const start = this.calculateOffset(startOffset);
    const end = this.calculateOffset(endOffset);

    // No point of subscribing and returning the value back using of(), when you can just return it.
    return this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken).pipe(
      tap((resData) => console.log(resData)),
      take(1), // Handles unsubscribe, if you want to be sure, but HTTP client takes care of that.
      catchError(err => {
        this.users.userServiceError.set(true);
        this.users.errorMsg.set(err.error);
        this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
        return EMPTY;
      })
    )
  }

While the modification above might solve the issue, it's still worth rewriting the way it's handled in the component. I would avoid doing it in the constructors as at that time, not everything might be ready.

import { toObservable } from '@angular/core/rxjs-interop';
import { tap, filter, switchMap, forkJoin } from 'rxjs';

// Constructor can be deleted
constructor() {}

// Handle on once the component is initialized
ngOnInit (): void {
  // Make an observable out of your signal
  toObservable<number>(this.sales.currentActiveEdition()).pipe(
    // Continue only if ID is non-zero.
    filter((edition_id) => edition_id > 0),
    // Save it to the class variable
    tap(edition_id => this.currentActiveEditionId = edition_id),
    // Now switchMap it and obtain both periods at once, using forkJoin
    switchMap(editionId => forkJoin({
      // Serivce methods now expect days offset, it'll will calculate it accordingly
      quantityLastWeek: this.sales.ticketsSoldPerPeriod(editionId, -7, 0),
      quantityPreviousWeek: this.sales.ticketsSoldPerPeriod(editionId, -14, -7)
    }),
    // Use operators to handle unsubcribe
    takeUntilDestroyed(this.destroyRef)
  ).subscribe(({quantityLastWeek, quantityPreviousWeek}) => {
      // Subscribe and set the values
      this.ticketsLastWeek.set(quantityLastWeek);
      this.ticketsPreviousWeek.set(quantityPreviousWeek);
      // Updated the signal
      this.widgetInputData.update(widgetData => this.updateWidgetData(widgetData));
    })
  );
}

As for other issues, this is already a long answer, so I'm not going to deal with them. Long story short.

3
  • Thank you for this! While I didn't copy-paste your solution, it did set me on the right path! I wanted to not make an observable out of a signal, seeing that signal should be doing exactly what I needed. But giving back the observable from the ticketsSoldPerPeriod and making sure I have the right change detections, was key! I'll leave my actually used code below and mark yours as the answer that helped getting this thing to work. Just a tiny bit of feedback, you come across as very condescending. I'm just learning and know that code isn't exactly perfect. No need to kick it down.
    – donald23
    Commented Nov 26 at 21:26
  • 1
    Thanks for reminding, I'm aware that I can sound like that and I apologize for that. To return the feedback, I noticed that questions like this one, with hundreds of lines of code are rarely answered. There's just too much to process and when you don't have the means to run the code, most people will give up trying. I almost did. It's better to shorten it to bare necessity, if people miss some code, they'll ask for it.
    – mat.hudak
    Commented Nov 27 at 5:47
  • Good feedback! Thanks! I'll keep that in mind for my next question.
    – donald23
    Commented Nov 27 at 18:47
0

With the answer from @mat.hudak in mind and starting to work on that. Including giving an Observable back from my sales.service, I went in, trying to keep using signals.

These are the key parts I actually used and worked for me: sales.service.ts:

ticketsSoldPerPeriod (editionId: number, startOffset: number = 0, endOffset: number = 0): Observable<number> {
    const start = this.calculateOffset(startOffset);
    const end = this.calculateOffset(endOffset);
    if (this.users.userToken !== undefined) {
      return this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken)
        .pipe(
          tap((resData) => {
            console.log(resData);
          }),
          catchError((err) => {
            this.users.userServiceError.set(true);
            this.users.errorMsg.set(err.error);
            this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
            return EMPTY;
          })
        );
    } else {
      console.error(`No logged in user!! (sales.service)`);
      return EMPTY;
    }
  }

  private calculateOffset (offset: number = 0): number {
    // If offset is set to zero, nothing will be added. Negative numbers will be subtracted
    return Math.floor((Date.now() / 1000)) + (offset * (24 * 3600));
  }

dashboard.component.ts:

import { Component, effect, inject, WritableSignal, signal, untracked } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { provideIcons } from '@ng-icons/core';

import { Breadcrumb, BreadcrumbsComponent } from 'src/app/core/header/breadcrumbs/breadcrumbs.component';
import { UsersService } from 'src/app/core/users.service';
import { SalesService } from 'src/app/core/sales.service';
import { WidgetData } from 'src/app/core/interfaces/core-interfaces';
import { WidgetSalesLastWeekComponent } from './widget-sales-last-week/widget-sales-last-week.component';

@Component({
  selector: 'app-dashboard',
  imports: [BreadcrumbsComponent, NgbModule, WidgetSalesLastWeekComponent],
  providers: [provideIcons({})],
  templateUrl: './dashboard.component.html',
  styleUrl: './dashboard.component.scss'
})
export class DashboardComponent {
  // First injections
  private users: UsersService = inject(UsersService);
  private sales: SalesService = inject(SalesService);
  // Then other declarations
  protected breadcrumbs: Breadcrumb[] = [{ linkId: 1, linkable: false, text: 'Dashboard' }];
  protected salesLastWeekData: WritableSignal<WidgetData[]> = signal([]);
  protected ticketsLastWeek: WritableSignal<number> = signal(0);
  protected ticketsPreviousWeek: WritableSignal<number> = signal(0);
  private currentActiveEditionId: number = 0;

  constructor() {
    effect(() => {
      if (this.currentActiveEditionId = this.sales.currentActiveEdition().edition_id) {
        console.warn(`Current active edition: ${ this.currentActiveEditionId }`);
        let quantityPreviousWeek = this.ticketsPreviousWeek();
        untracked(() => {
          this.sales.ticketsSoldPerPeriod(this.currentActiveEditionId, -7, 0).subscribe(res => this.ticketsLastWeek.set(res));
          this.sales.ticketsSoldPerPeriod(this.currentActiveEditionId, -14, -7).subscribe(res => this.ticketsPreviousWeek.set(res));
          console.info(`hier dan? ${ this.ticketsLastWeek() } en ${ this.ticketsPreviousWeek() }`);
          this.salesLastWeekData.set([{
            widgetId: 1,
            header: 'Tickets sold',
            subTitle: 'Sold in the last week',
            iconName: 'bootstrapCart',
            data: this.ticketsLastWeek()?.toString() + ' tickets',
            deviationPercentage: parseInt(((((this.ticketsLastWeek() ?? 1) / (this.ticketsPreviousWeek() ?? 1)) - 1) * 100).toFixed(1))
          }]);
        });
        console.warn('done updating in updateWidget');
      }
    });
    console.log(this.salesLastWeekData());
  }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.