Skip to content

Commit

Permalink
compute the offset between the frontend and the backend using an inte…
Browse files Browse the repository at this point in the history
…rceptor. use it in the top bar countdown
  • Loading branch information
smadbe committed Sep 12, 2024
1 parent e46f17e commit 3a0935a
Show file tree
Hide file tree
Showing 14 changed files with 172 additions and 5 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ module.exports = {
'**/store/*',
'!**/utils/store/**',
'!**/store/observation',
'!**/store/time-offset',
'!**/store/router'
],
message: 'Only store indexes can be imported'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store, createSelector } from '@ngrx/store';
import { filter, interval, map, of, switchMap } from 'rxjs';
import { filter, interval, map, of, switchMap, take } from 'rxjs';
import { fromItemContent } from 'src/app/items/store';
import { DurationAsCountdownPipe } from 'src/app/pipes/duration';
import { isInfinite, isPastDate } from 'src/app/utils/date';
import { isInfinite } from 'src/app/utils/date';
import { fromTimeOffset } from 'src/app/store/time-offset';
import { Duration } from 'src/app/utils/duration';
import { isNotUndefined } from 'src/app/utils/null-undefined-predicates';

Expand Down Expand Up @@ -49,7 +50,9 @@ export class TimeLimitedContentInfoComponent {
const timeRemaining = Duration.fromNowUntil(submissionUntil);
if (!timeRemaining.getMs()) return of(new Duration(0));
return interval(1000).pipe(
map(() => (isPastDate(submissionUntil) ? new Duration(0) : Duration.fromNowUntil(submissionUntil))),
switchMap(() => this.store.select(fromTimeOffset.selectCurrentTimeOffset).pipe(take(1))),
map(offset => Duration.fromNowUntil(submissionUntil).add(-offset)),
map(remaining => (remaining.isStrictlyPositive() ? remaining : new Duration(0))),
);
}),
), { initialValue: null }
Expand Down
29 changes: 29 additions & 0 deletions src/app/interceptors/time_offset.interceptors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { HttpEvent, HttpEventType, HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { fromTimeOffset } from '../store/time-offset';
import { isRequestToApi } from './interceptor_common';

/**
* Interceptor which measures the time difference betweeen the client and the time indicated on server response, and sends it to the store
*/
export function timeOffsetComputationInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
const store = inject(Store);
return next(req).pipe(
tap(event => {
const localTs = Date.now();
if (!isRequestToApi(req)) return;
if (event.type !== HttpEventType.Response) return;
const serverDateString = event.headers.get('Date');
if (!serverDateString) return;
const serverTs = Date.parse(serverDateString);
const offset = serverTs - localTs;
store.dispatch(fromTimeOffset.interceptorActions.reportOffset({ offset }));
})
);

}


1 change: 1 addition & 0 deletions src/app/store/time-offset/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { fromTimeOffset } from './time-offset.store';
9 changes: 9 additions & 0 deletions src/app/store/time-offset/time-offset.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createActionGroup, props } from '@ngrx/store';

export const interceptorActions = createActionGroup({
source: 'Time offset interceptor',
events: {
reportOffset: props<{ offset: number }>(),
},
});

14 changes: 14 additions & 0 deletions src/app/store/time-offset/time-offset.reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createReducer, on } from '@ngrx/store';
import { State, initialState } from './time-offset.state';
import { interceptorActions } from './time-offset.actions';

export const reducer = createReducer(
initialState,

on(interceptorActions.reportOffset,
({ latestOffsets }, { offset }): State => ({
latestOffsets: [ ...latestOffsets.slice(-4), offset ], // only keep the latest 5 values at most
})
),

);
21 changes: 21 additions & 0 deletions src/app/store/time-offset/time-offset.selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MemoizedSelector, Selector, createSelector } from '@ngrx/store';
import { State } from './time-offset.state';
import { RootState } from 'src/app/utils/store/root_state';
import { median } from 'src/app/utils/array';

interface TimeOffsetSelectors<T extends RootState> {
/**
* The current time offset computed as the median of the 5 latest measured values.
* A positive value means the client is late on the server time. So you have to add the offset to the client time to get the server one.
*/
selectCurrentTimeOffset: MemoizedSelector<T, number>,
}

export function selectors<T extends RootState>(selectTimeOffsetState: Selector<T, State>): TimeOffsetSelectors<T> {
return {
selectCurrentTimeOffset: createSelector(
selectTimeOffsetState,
({ latestOffsets }) => (latestOffsets.length === 0 ? 0 : median(latestOffsets))
)
};
}
8 changes: 8 additions & 0 deletions src/app/store/time-offset/time-offset.state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

export interface State {
latestOffsets: number[], // a buffer of the latest offset values to determine current offset
}

export const initialState: State = {
latestOffsets: [],
};
11 changes: 11 additions & 0 deletions src/app/store/time-offset/time-offset.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createFeatureAlt } from 'src/app/utils/store/feature_creator';
import { reducer } from './time-offset.reducer';
import { selectors } from './time-offset.selectors';
import * as actions from './time-offset.actions';

export const fromTimeOffset = createFeatureAlt({
name: 'timeOffset',
reducer,
extraSelectors: ({ selectTimeOffsetState }) => ({ ...selectors(selectTimeOffsetState) }),
actionGroups: actions
});
25 changes: 25 additions & 0 deletions src/app/utils/array.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { median } from './array';

describe('median', () => {
it('should throw error if the list is empty', () => {
expect(() => median([])).toThrowError();
});
it('should work with a single element list', () => {
expect(median([5])).toEqual(5);
});
it('should work with a odd number of values', () => {
expect(median([2, 4, 8])).toEqual(4);
expect(median([4, 2, 8])).toEqual(4);
expect(median([8, 2, 4])).toEqual(4);
});
it('should work (avg the 2 medians) with an even number of values', () => {
expect(median([2, 4, 5, 10])).toEqual(4.5);
expect(median([5, 4, 2, 10])).toEqual(4.5);
expect(median([4, 2, 10, 5])).toEqual(4.5);
});
it('should not modify the input list', () => {
const list = [4, 2, 3];
median(list);
expect(list).toEqual([4, 2, 3]);
});
});
12 changes: 12 additions & 0 deletions src/app/utils/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,15 @@ export function groupBy<K, V>(list: Array<V>, keyGetter: (input: V) => K): Map<K
});
return map;
}

/**
* Return the median of the array. If there is an even number of values, use the average of the 2 median values.
* Do not modify the input list.
* @param list a non-empty array
*/
export function median(list: number[]): number {
if (list.length === 0) throw new Error('cannot compute median of an empty list');
const sorted = [ ...list ].sort();
if (list.length % 2 === 1) return sorted[(list.length-1)/2]!;
return (sorted[list.length / 2]! + sorted[list.length / 2 + 1]!) / 2;
}
22 changes: 22 additions & 0 deletions src/app/utils/duration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,26 @@ describe('duration', () => {
expect(Duration.fromHMS(0, 0, 0).toCountdown()).toEqual('0s');
});
});

describe('add', () => {
it('should add ms when positive', () => {
expect(Duration.fromHMS(50, 30, 10).add(3500).getHMS()).toEqual([ '50', '30', '13']);
});
it('should substract ms when negative', () => {
expect(Duration.fromHMS(50, 30, 10).add(-3500).getHMS()).toEqual([ '50', '30', '6']);
});

});

fdescribe('isStrictlyPositive', () => {
it('should return true when positive', () => {
expect(Duration.fromSeconds(10).isStrictlyPositive()).toBeTrue();
});
it('should return false when negative', () => {
expect(Duration.fromSeconds(-10).isStrictlyPositive()).toBeFalse();
});
it('should return false when 0', () => {
expect(Duration.fromSeconds(0).isStrictlyPositive()).toBeFalse();
});
});
});
8 changes: 8 additions & 0 deletions src/app/utils/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,12 @@ export class Duration {
return !isNaN(this.ms);
}

isStrictlyPositive(): boolean {
return this.ms > 0;
}

add(ms: number): Duration {
return new Duration(this.ms + ms);
}

}
7 changes: 5 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { AlgErrorHandler } from './app/utils/error-handling/error-handler';
import { WithCredentialsInterceptor } from './app/interceptors/with_credentials.interceptor';
import { AuthenticationInterceptor } from './app/interceptors/authentication.interceptor';
import { TimeoutInterceptor } from './app/interceptors/timeout.interceptor';
import { HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient } from '@angular/common/http';
import { HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient, withInterceptors } from '@angular/common/http';
import { NgScrollbarOptions } from 'ngx-scrollbar/lib/ng-scrollbar.model';
import { NG_SCROLLBAR_OPTIONS, NgScrollbarModule } from 'ngx-scrollbar';
import { ConfirmationService, MessageService } from 'primeng/api';
Expand All @@ -40,6 +40,8 @@ import { fromObservation, observationEffects } from './app/store/observation';
import { fromRouter, RouterSerializer } from './app/store/router';
import { fromUserContent, groupStoreEffects } from './app/groups/store';
import { fromItemContent, itemStoreEffects } from './app/items/store';
import { timeOffsetComputationInterceptor } from './app/interceptors/time_offset.interceptors';
import { fromTimeOffset } from './app/store/time-offset';

const DEFAULT_SCROLLBAR_OPTIONS: NgScrollbarOptions = {
visibility: 'hover',
Expand Down Expand Up @@ -142,9 +144,10 @@ bootstrapApplication(AppComponent, {
provideState(fromForum),
provideState(fromUserContent),
provideState(fromItemContent),
provideState(fromTimeOffset),
provideEffects(observationEffects, forumEffects(), groupStoreEffects(), itemStoreEffects()),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() , connectInZone: true }),
provideAnimations(),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClient(withInterceptorsFromDi(), withInterceptors([ timeOffsetComputationInterceptor ])),
]
}).catch(err => console.error(err));

0 comments on commit 3a0935a

Please sign in to comment.