tutorbook
Version:
Web app connecting students with expert mentors and tutors.
242 lines (215 loc) • 8.83 kB
text/typescript
import firebase from '@tutorbook/firebase';
import 'firebase/firestore';
import { TimeUtils } from '@tutorbook/utils';
import { DAYS } from './constants';
/**
* Number representing the day of the week. Follows the ECMAScript Date
* convention where 0 denotes Sunday, 1 denotes Monday, etc.
* @see {@link https://mzl.la/34l2dN6}
*/
export type DayAlias = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/**
* Enum that makes it easier to work with the integer representations of the
* various days of the week.
* @see {@link https://www.typescriptlang.org/docs/handbook/enums.html#numeric-enums}
*/
export enum Day {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
/**
* This is a painful workaround as we then import the entire Firebase library
* definition while we only want the `Timestamp` object.
* @todo Only import the `Timestamp` definition.
* @todo Add support for the server-side `Timestamp` definition as well; right
* now, we're not even using these type definitions because the Firebase Admin
* SDK is telling us that the client-side and server-side type definitions are
* incompatible.
* @see {@link https://stackoverflow.com/a/57984831/10023158}
*/
const { Timestamp } = firebase.firestore;
type Timestamp = firebase.firestore.Timestamp;
export interface TimeslotBase<T> {
from: T;
to: T;
}
/**
* Interface that represents an availability time opening or slot. Note that
* right now, we just assume that these are recurring weekly.
*/
export type TimeslotInterface = TimeslotBase<Date>;
/**
* Interface that represents how `Timeslot`s are stored in our Firestore
* database; with `Timestamp`s instead of `Date`s (b/c they're more accurate).
*/
export type TimeslotFirestoreInterface = TimeslotBase<Timestamp>;
/**
* Interface that results from serializing the `Timeslot` object as JSON (i.e.
* running `JSON.parse(JSON.stringify(timeslot))`) where the `from` and `to`
* fields are both ISO strings.
*/
export type TimeslotJSON = TimeslotBase<string>;
export type TimeslotSearchHitInterface = TimeslotBase<number>;
/**
* Class that represents a time opening or slot where tutoring can take place
* (or where tutoring is taking place in the case of a booking). This provides
* some useful methods for comparison and a better `toString` representation
* than `[Object object]`.
*/
export class Timeslot implements TimeslotBase<Date> {
/**
* Constructor that takes advantage of Typescript's shorthand assignment.
* @see {@link https://bit.ly/2XjNmB5}
*/
public constructor(public from: Date, public to: Date) {}
/**
* Returns if this timeslot contains another timeslot (i.e. the starting time
* of the other timeslot is equal to or after the starting time of this
* timeslot **and** the ending time of the other timeslot is equal to or
* before the ending time of this timeslot).
*/
public contains(other: Timeslot): boolean {
return (
other.from.valueOf() >= this.from.valueOf() &&
other.to.valueOf() <= this.to.valueOf()
);
}
/**
* Puts the time slot into string form.
* @example
* // Where `dateAtTwoPM` and `dateAtThreePM` are on Mondays.
* const timeslot = new Timeslot(dateAtTwoPM, dateAtThreePM);
* assert(timeslot.toString() === 'Mondays from 2pm to 3pm');
* @deprecated We're going to put these into strings within the React tree so
* that we can use `react-intl` for better i18n support (e.g. we'll set the
* localization in the `pages/_app.tsx` top-level component and all children
* components will render their `Date`s properly for that locale).
*/
public toString(showDay = true): string {
let str = `${this.from.toLocaleTimeString()} - ${this.to.toLocaleTimeString()}`;
if (showDay) {
if (this.from.getDay() === this.to.getDay()) {
str = `${DAYS[this.from.getDay()]} ${str}`;
} else {
str = `${DAYS[this.from.getDay()]} ${str.split(' - ')[0]} - ${
DAYS[this.to.getDay()]
} ${str.split(' - ')[1]}`;
}
}
return str;
}
/**
* Parses an input (see below for examples) into a `Timeslot`:
* > Mondays at 3:00 PM to 4:00 PM.
* > Monday at 3:00 PM to 3:30 PM.
* This getter should only ever be called within a `try{} catch {}` sequence
* b/c it will throw an error every time if `this.state.value` isn't parsable.
* @deprecated We're going to put these into strings within the React tree so
* that we can use `react-intl` for better i18n support (e.g. we'll set the
* localization in the `pages/_app.tsx` top-level component and all children
* components will render their `Date`s properly for that locale).
*/
public static fromString(timeslot: string): Timeslot {
/* eslint-disable no-new-wrappers */
const split: string[] = timeslot.split(' ');
if (split.length !== 7) throw new Error('Invalid time string.');
const dayStr: string = split[0];
const fromStr: string = split[2];
const fromAMPM: string = split[3];
const toStr: string = split[5];
const toAMPM: string = split[6];
const day: keyof typeof Day = (dayStr.endsWith('s')
? dayStr.slice(0, -1)
: dayStr) as keyof typeof Day;
const dayNum: DayAlias = Day[day];
let fromHr: number = new Number(fromStr.split(':')[0]).valueOf();
const fromMin: number = new Number(fromStr.split(':')[1]).valueOf();
if (fromAMPM === 'PM') {
fromHr += 12;
} else if (fromAMPM !== 'AM') {
throw new Error('Invalid AM/PM format for from time.');
}
let toHr: number = new Number(toStr.split(':')[0]).valueOf();
const toMin: number = new Number(toStr.split(':')[1]).valueOf();
if (toAMPM === 'PM' || toAMPM === 'PM.') {
toHr += 12;
} else if (toAMPM !== 'AM' && toAMPM !== 'AM.') {
throw new Error('Invalid AM/PM format for to time.');
}
return new Timeslot(
TimeUtils.getDate(dayNum, fromHr, fromMin),
TimeUtils.getDate(dayNum, toHr, toMin)
);
/* eslint-enable no-new-wrappers */
}
/**
* Helper string conversion method that's **only** used by the `TimeslotInput`
* to convert it's value into a timestring.
* @todo Move the parsing logic from that input to this class.
* @todo Merge this with the `toString()` method.
* @todo Don't assume that the `from` and `to` times occur on the same day.
* @deprecated We're going to put these into strings within the React tree so
* that we can use `react-intl` for better i18n support (e.g. we'll set the
* localization in the `pages/_app.tsx` top-level component and all children
* components will render their `Date`s properly for that locale).
*/
public toParsableString(): string {
return (
`${DAYS[this.from.getDay()]}s from ${this.from.getHours()}:` +
`${`0${this.from.getMinutes()}`.slice(-2)} AM to ` +
`${this.to.getHours()}:${`0${this.to.getMinutes()}`.slice(-2)} AM`
);
}
public equalTo(timeslot: TimeslotInterface): boolean {
return (
timeslot.from.valueOf() === this.from.valueOf() &&
timeslot.to.valueOf() === this.to.valueOf()
);
}
/**
* Converts this object into a `TimeslotFirestoreInterface` (i.e. instead of
* `Date`s we use `Timestamp`s).
* @todo Right now, this isn't really doing anything besides some sketchy
* type assertions b/c the Firebase Admin Node.js SDK `Timestamp` type doesn't
* match the client-side `firebase/app` library `Timestamp` type. We want to
* somehow return a `Timestamp` type that can be used by both (but I can't
* figure out how to do this, so I'm just returning `Date`s which are
* converted into the *correct* `Timestamp` type by the Firebase SDK itself).
*/
public toFirestore(): TimeslotFirestoreInterface {
return {
from: (this.from as unknown) as Timestamp,
to: (this.to as unknown) as Timestamp,
};
}
/**
* Takes in a Firestore timeslot record and returns a new `Timeslot` object.
*/
public static fromFirestore(data: TimeslotFirestoreInterface): Timeslot {
return new Timeslot(data.from.toDate(), data.to.toDate());
}
public toJSON(): TimeslotJSON {
return { from: this.from.toJSON(), to: this.to.toJSON() };
}
public static fromJSON(json: TimeslotJSON): Timeslot {
return new Timeslot(new Date(json.from), new Date(json.to));
}
public static fromSearchHit(hit: TimeslotSearchHitInterface): Timeslot {
return new Timeslot(new Date(hit.from), new Date(hit.to));
}
public toURLParam(): string {
return encodeURIComponent(JSON.stringify(this));
}
public static fromURLParam(param: string): Timeslot {
const params: URLSearchParams = new URLSearchParams(param);
return new Timeslot(
new Date(params.get('from') as string),
new Date(params.get('to') as string)
);
}
}