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 { 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; /** * 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; /** * 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; export type TimeslotSearchHitInterface = TimeslotBase; /** * 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 { /** * 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) ); } }