Timer across multiple pages

I am new to Ionic so I may not be using all the terms correctly. I’m making a quiz app that uses a countdown timer to alert the user that they should stop working. I was able to find a timer on the forums, but I am having some issues maintaining it across multiple pages. The questions are rendered on a page and the timer is a component in the title bar of the page template. There is a button at the bottom of the page that renders the next question. Because of how it is set up, the timer restarts every time a new question is rendered. I was wondering how I could set up the timer such that it would work across all the question pages.

I tried adding the timer function to a service, but it did not render the timer.

Here are the important code snippets:

timer.component.html

<div class="timer" *ngIf="countdownDisplay">
  <div>
    <p>{{countdownDisplay}}</p>
  </div>
</div>

timer.component.ts

import { Component, OnInit } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { map, takeUntil, takeWhile } from 'rxjs/operators';
import { addSeconds, format } from 'date-fns';
import { FullPaperServiceService } from '../full-paper-service.service';

@Component({
  selector: 'app-timer',
  templateUrl: './timer.component.html',
  styleUrls: ['./timer.component.scss'],
})
export class TimerComponent implements OnInit {
  secs = 10;
  countdownDisplay?: string;
  starter$ = new Subject<void>();
  timerStarted: boolean = false;

  constructor(
    private FPService: FullPaperServiceService,
  ) { }

  ngOnInit() {

    if(this.FPService.timerStarted === true) {
      this.startCountdown();
    }
  }

  startCountdown(): void {
    this.starter$.next();
    let nsecs = this.secs;
    interval(1000)
      .pipe(
        takeUntil(this.starter$),
        takeWhile(countup => countup <= nsecs),
        map(countup => {
          let countdown = nsecs - countup;
          let d = new Date();
          d.setHours(0,0,0,0);
          d = addSeconds(d, countdown);
          let fmt = format(d, "HH:mm:ss");
          return fmt;
        })
      )
      .subscribe(cd => this.countdownDisplay = cd,
        (err) => console.log(err),
        () => this.FPService.endTest());
  }
}

full-paper-service.service.ts

import { Injectable } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { map, takeUntil, takeWhile } from 'rxjs/operators';
import { addSeconds, format } from 'date-fns';

@Injectable({
  providedIn: 'root'
})
export class FullPaperServiceService {
  testStarted: boolean = false;
  timerStarted: boolean = false;
  starter$ = new Subject<void>();
  secs = 10;
  countdownDisplay: string;

  constructor() { }

  startTest() {
    this.testStarted = true;
  }

  startTimer() {
    this.timerStarted = true;
  }

  endTest() {
    this.testStarted = false;
  }

  startCountdown(): void {
    this.starter$.next();
    let nsecs = this.secs;
    interval(1000)
      .pipe(
        takeUntil(this.starter$),
        takeWhile(countup => countup <= nsecs),
        map(countup => {
          let countdown = nsecs - countup;
          let d = new Date();
          d.setHours(0,0,0,0);
          d = addSeconds(d, countdown);
          let fmt = format(d, "HH:mm:ss");
          return fmt;
        })
      )
      .subscribe(cd => this.countdownDisplay = cd,
        (err) => console.log(err),
        () => this.endTest());
  }
}

Function to start the test

startTest() {
    ...

        if(this.FPService.testStarted === true) {
          this.router.navigate(['./',this.questionnums[0]], {relativeTo: this.activatedRoute});
          this.FPService.startTimer();
        }
      });
    });
  }

There are quite a lot of pages, and since I can’t show them all, if there are any that will make answering the question easier, please let me know.

This is absolutely the way to go, so can you instead share what things looked like when the timer was in the service? You can’t render directly from the service, but the actual countdown itself needs to be elevated out of an individual component.

Thank you for your reply,

This is how the page and timer looked when the countdown function was in the component.

This is how the page looked when the countdown function was in the service.

I was thinking about elevating the countdown from the component like you said, but I didn’t know where to start. Would I have to import the component to the service and work with it there? Or is there a better way to do it?

My apologies for poor English. When I said “what things looked like”, I meant “what the code was”. I didn’t mean “looked like” in a visual sense.

Oh, my mistake.

I put the countdown in the service ‘full-paper-service.service.ts’. This is the code for that:
full-paper-service.service.ts

import { Injectable } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { map, takeUntil, takeWhile } from 'rxjs/operators';
import { addSeconds, format } from 'date-fns';

@Injectable({
  providedIn: 'root'
})
export class FullPaperServiceService {
  testStarted: boolean = false;
  timerStarted: boolean = false;
  starter$ = new Subject<void>();
  secs = 10;
  countdownDisplay: string;

  constructor() { }

  startTest() {
    this.testStarted = true;
  }

  startTimer() {
    this.timerStarted = true;
  }

  endTest() {
    this.testStarted = false;
  }

  startCountdown(): void {
    this.starter$.next();
    let nsecs = this.secs;
    interval(1000)
      .pipe(
        takeUntil(this.starter$),
        takeWhile(countup => countup <= nsecs),
        map(countup => {
          let countdown = nsecs - countup;
          let d = new Date();
          d.setHours(0,0,0,0);
          d = addSeconds(d, countdown);
          let fmt = format(d, "HH:mm:ss");
          return fmt;
        })
      )
      .subscribe(cd => this.countdownDisplay = cd,
        (err) => console.log(err),
        () => this.endTest());
  }
}

As you can see, I have not used any of the parts of the components in the service because I am not exactly sure how to do it. I did try making the function in the service use parameters that I could use in the component, but that didn’t seem to work either.

That would be impossible. It only works the other way around - you can access service things from inside a component, but not component things from within a service.

I generally find myself having to redesign any situation in which I am tempted to subscribe to an Observable in the same place as it is created, as you are doing in startCountdown.

What I would do instead is look at this from the POV of the timer display component. The easiest thing for it to consume would be an Observable<string> whose payload is the exact thing it is supposed to display. So let’s rearchitect TimerService to expose that.

You have a thing that you call starter$ that looks to me more like a stopper than a starter, because it aborts the countdown. I’m taking that out for simplicity, but you could readd it if it’s used elsewhere.

timer.service.ts


@Injectable()
export class TimerService {
  // this is a second-order Observable to make things look seamless from the outside
  private countdowns$ = new BehaviorSubject<Observable<number>>(EMPTY);

  watchDisplay(): Observable<string> {
    return this.countdowns$.pipe(
      switchMap(cd$ => cd$),
      map(secs => this.secsToDisplay(secs)));
  }

  // the priming with concat is because interval doesn't emit immediately
  // the +1s and -1s deal with the fenceposting caused by that
  startCountdown(totalSecs: number): void {
    this.countdowns$.next(concat(of(-1), interval(1000)).pipe(
      take(totalSecs+1),
      map(n => totalSecs - (n+1)),
    ));
  }

  // leaving this bit intact from your original code, but split out to get it out of the way
  // of the new structure
  private secsToDisplay(secs: number): string {
    let d = new Date();
    d.setHours(0, 0, 0, 0);
    d = addSeconds(d, secs);
    let fmt = format(d, "HH:mm:ss");
    return fmt;
  }
}

timer.component.ts

// this directive comes from @ngneat/until-destroy and prevents subscription leaks
@UntilDestroy()
@Component({
  selector: "app-timer",
  template: "<div>countdown: {{countdownDisplay}}</div>",
})
export class TimerComponent {
  countdownDisplay = "";

  constructor(private timer: TimerService) {
    timer.watchDisplay()
      .pipe(untilDestroyed(this))
      .subscribe(cd => this.countdownDisplay = cd);
  }
}

home.page.ts

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  nsecs = 10; // just a default

  constructor(private timerer: TimerService) {}

  startCountdown(): void {
    this.timerer.startCountdown(this.nsecs);
  }
}

home.component.html

<ion-content [fullscreen]="true">
  <div>
      <app-timer></app-timer>
  </div>

  <div>
    <ion-item>
      <ion-label>seconds to count down:</ion-label>
    </ion-item>
    <ion-item>
      <ion-input type="number" [(ngModel)]="nsecs"></ion-input>
    </ion-item>
    <ion-item button color="primary" (click)="startCountdown()">start countdown</ion-item>
  </div>
</ion-content>

You should be able to freely add an <app-timer> to any page you want, and it should do what you’re expecting. I hope this gives you some ideas you can use in your app. I verified that it at least runs, but it hasn’t been tested extensively.

Thank you very much. It works almost perfectly… except for one small issue.

On the page where I use the timer, I have a function that gets the questions for the user:
question.page.ts

...
if (this.nextquestionnum !== undefined) {
          this.router.navigate(['../',this.nextquestionnum], {relativeTo: this.activatedRoute});
    }
...

where this.nextquestionnum is a page in an array of pages. Unfortunately, every time a new page is obtained, the timer restarts from 10, rather than continuing from whatever value it was on before switching. I tried to solve this using an if-statement to run the timer service only once, but it did not fix the problem. I would appreciate it if you could help with this as well.

Thanks.

The design is intended so that the only way the timer restarts is if you call startCountdown. I guess there could be a bug where that isn’t the case, but can you please double-check that you aren’t calling startCountdown accidentally (via a lifecycle event hook or something)?

Thank you, the issue was as you described.