Skip to content

Commit

Permalink
[Web] Lock interface after polling update
Browse files Browse the repository at this point in the history
Carl was noticing that an update from another mentor checking a student
in or out could cause the interface to shift right as the user was about
to push a button, potentially causing the wrong student to be checked in
or out.

The solution is to disable the interface for 100ms after such an update
is received, to give the user time to readjust.
  • Loading branch information
lost1227 committed Jan 26, 2024
1 parent 0c60849 commit a4aab76
Show file tree
Hide file tree
Showing 6 changed files with 82 additions and 10 deletions.
@@ -1,5 +1,5 @@
import { AfterViewInit, Component, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, combineLatest, finalize, forkJoin, interval, map, Observable, of, ReplaySubject, shareReplay, startWith, Subject, Subscription, switchMap, take, tap } from 'rxjs';
import { BehaviorSubject, combineLatest, finalize, forkJoin, interval, map, Observable, of, ReplaySubject, shareReplay, startWith, Subject, Subscription, switchMap, take, tap, timer } from 'rxjs';
import { StudentsService } from 'src/app/services/students.service';
import { Student, StudentList, compareStudents } from 'src/app/models/student.model';
import { AttendanceService } from 'src/app/services/attendance.service';
Expand Down Expand Up @@ -35,6 +35,7 @@ export class AddAttendanceEventListComponent implements OnInit, AfterViewInit, O
protected mode: AttendanceEventType

protected pendingStudentIds = new BehaviorSubject<number[]>([]);
protected inUpdateLockout = new BehaviorSubject<boolean>(false);

constructor(
private pollService: PollService,
Expand Down Expand Up @@ -67,11 +68,31 @@ export class AddAttendanceEventListComponent implements OnInit, AfterViewInit, O

// Set up polling for new check-ins/check-outs
this.polling = this.pollService.getPollingObservable().subscribe(pollData => {
this.studentsService.updateStudentsInCache(pollData.updated_students);
// If we get new data, disable the UI for a configurable period (in case a user is about
// to tap an option that will move with the update)
let shouldLockout: Observable<boolean>;
if(pollData.meeting_events.length > 0) {
// TODO: Verify that the 0th meeting event is always the most recent
this.lastEndOfMeeting.next(pollData.meeting_events[0]);
shouldLockout = of(true);
} else if(pollData.updated_students.length > 0) {
shouldLockout = this.studentsService.matchesCache(pollData.updated_students).pipe(map(matches => !matches));
} else {
shouldLockout = of(false);
}

shouldLockout.subscribe(shouldLockout => {
if(shouldLockout) {
this.inUpdateLockout.next(true);
timer(environment.updateLockoutInterval).subscribe(() => {
this.inUpdateLockout.next(false);
});

this.studentsService.updateStudentsInCache(pollData.updated_students);
if(pollData.meeting_events.length > 0) {
// TODO: Verify that the 0th meeting event is always the most recent
this.lastEndOfMeeting.next(pollData.meeting_events[0]);
}
}
});
});

// Combine search, sort filters, and student roster into the final observable which
Expand Down Expand Up @@ -131,6 +152,11 @@ export class AddAttendanceEventListComponent implements OnInit, AfterViewInit, O
}

private attendance(student: Student, action: AttendanceEventType) : void {
// If we're locked-out due to a recent update, ignore the event
if(this.inUpdateLockout.getValue()) {
return;
}

this.pendingStudentIds.next(this.pendingStudentIds.getValue().concat([student.id]));

this.authService.getUser().pipe(map(user => {
Expand Down Expand Up @@ -218,9 +244,10 @@ export class AddAttendanceEventListComponent implements OnInit, AfterViewInit, O
),
isPending: this.pendingStudentIds.pipe(
map(ids => ids.includes(student.id))
)
),
inUpdateLockout: this.inUpdateLockout
}).pipe(
map(({availableActionDoesNotMatchTab, isPending}) => availableActionDoesNotMatchTab || isPending)
map(({availableActionDoesNotMatchTab, isPending, inUpdateLockout}) => availableActionDoesNotMatchTab || isPending || inUpdateLockout)
);
}

Expand Down
9 changes: 9 additions & 0 deletions attendance-web/src/app/models/attendance-event.model.ts
Expand Up @@ -13,3 +13,12 @@ export interface AttendanceEvent {
created_at: DateTime,
updated_at: DateTime
}

export function areAttendanceEventsEqual(a: AttendanceEvent, b: AttendanceEvent) {
return a.id === b.id
&& a.student_id === b.student_id
&& a.registered_by === b.registered_by
&& a.type === b.type
&& a.created_at.equals(b.created_at)
&& a.updated_at.equals(b.updated_at);
}
26 changes: 25 additions & 1 deletion attendance-web/src/app/models/student.model.ts
@@ -1,5 +1,5 @@
import { DateTime } from "luxon"
import { AttendanceEvent } from "./attendance-event.model"
import { AttendanceEvent, areAttendanceEventsEqual } from "./attendance-event.model"

export interface Student {
id: number,
Expand All @@ -13,6 +13,30 @@ export interface Student {
last_check_out?: AttendanceEvent
}

export function areStudentsEqual(a: Student, b: Student): boolean {
const compare_optional_dates = (d1: DateTime|undefined, d2: DateTime|undefined) => {
if(d1 == undefined || d2 == undefined) {
return d1 === d2;
}
return d1.equals(d2);
};
const compare_optional_events = (e1: AttendanceEvent|undefined, e2: AttendanceEvent|undefined) => {
if(e1 == undefined || e2 == undefined) {
return e1 === e2;
}
return areAttendanceEventsEqual(e1, e2);
};
return a.id === b.id
&& a.name === b.name
&& a.graduation_year === b.graduation_year
&& a.registered_by === b.registered_by
&& a.created_at.equals(b.created_at)
&& a.updated_at.equals(b.updated_at)
&& compare_optional_dates(a.deleted_at, b.deleted_at)
&& compare_optional_events(a.last_check_in, b.last_check_in)
&& compare_optional_events(a.last_check_out, b.last_check_out);
}

export function compareStudents(a: Student, b: Student): number {
const aName = a.name.toLocaleLowerCase();
const bName = b.name.toLocaleLowerCase();
Expand Down
12 changes: 11 additions & 1 deletion attendance-web/src/app/services/students.service.ts
Expand Up @@ -4,7 +4,7 @@ import { HttpClient, HttpContext, HttpErrorResponse, HttpParams } from '@angular
import { catchError, filter, map, Observable, of, OperatorFunction, ReplaySubject, shareReplay, switchMap, take, tap } from 'rxjs';
import { environment } from 'src/environments/environment';

import { Student } from 'src/app/models/student.model';
import { areStudentsEqual, Student } from 'src/app/models/student.model';
import { DateTime } from 'luxon';
import { CATCH_ERRORS } from '../http-interceptors/error-interceptor';
import { ErrorService } from './error.service';
Expand Down Expand Up @@ -87,6 +87,16 @@ export class StudentsService {
).subscribe(student => this.updateStudentsInCache([student]));
}

public matchesCache(students: Student[]): Observable<boolean> {
return this.cachedStudents.pipe(
take(1),
map(cache => students.reduce((eqSoFar, student) => {
const cachedStudent = cache.get(student.id);
return eqSoFar && cachedStudent != undefined && areStudentsEqual(student, cachedStudent);
}, true))
);
}

public updateStudentsInCache(new_students: Student[]) {
this.cachedStudents.pipe(
take(1),
Expand Down
3 changes: 2 additions & 1 deletion attendance-web/src/environments/environment.prod.ts
Expand Up @@ -2,5 +2,6 @@ export const environment = {
production: true,
apiRoot: "/attendance/api",
authRoot: "/attendance/auth",
pollInterval: 500
pollInterval: 500,
updateLockoutInterval: 100
};
3 changes: 2 additions & 1 deletion attendance-web/src/environments/environment.ts
Expand Up @@ -6,7 +6,8 @@ export const environment = {
production: false,
apiRoot: "/api",
authRoot: "/auth",
pollInterval: 500
pollInterval: 500,
updateLockoutInterval: 100
};

/*
Expand Down

0 comments on commit a4aab76

Please sign in to comment.