evrimfeyyaz/covid-19-api

View on GitHub
src/COVID19API.ts

Summary

Maintainability
D
2 days
Test Coverage
A
98%
import { COVID19APIError } from "./COVID19APIError";
import { DataGetter } from "./DataGetter/DataGetter";
import { FileGetter } from "./DataGetter/FileGetter";
import { GitHubGetter } from "./DataGetter/GitHubGetter";
import { DataStore, DataStoreInvalidLocationError } from "./DataStore/DataStore";
import { IndexedDBStore } from "./DataStore/IndexedDBStore";
import { MemoryStore } from "./DataStore/MemoryStore";
import { formatGlobalParsedData, formatUSParsedData } from "./format";
import { dateKeyToDate, dateToDateKey, parseCSV, ParsedCSV } from "./parse";
import { Fetch, InternalLocationData, LocationData, ValuesOnDate } from "./types";
import { US_LOCATIONS } from "./usLocations";

type LoadFromOptions = "github" | "files";
type StoreOptions = "memory" | "indexeddb";
type FilePaths = {
  globalConfirmedCSVPath: string;
  globalDeathsCSVPath: string;
  globalRecoveredCSVPath: string;
  usConfirmedCSVPath: string;
  usDeathsCSVPath: string;
};

export interface COVID19APIOptions {
  /**
   * Where to load the data from. Either the JHU CSSE GitHub repository or from CSV files.
   *
   * The default is `"github"`.
   */
  loadFrom?: LoadFromOptions;
  /**
   * Where to store the data, and query it from. Either memory or IndexedDB.
   *
   * The default is `"memory"`.
   */
  store?: StoreOptions;
  /**
   * If `"files"` is selected for the `loadFrom` option, you can optionally enter the paths to the
   * CSV files.
   *
   * If this is omitted, it is assumed that the files are in the same folder, and have the
   * following default names:
   *
   * - time_series_covid19_confirmed_global.csv
   * - time_series_covid19_deaths_global.csv
   * - time_series_covid19_recovered_global.csv
   * - time_series_covid19_confirmed_US.csv
   * - time_series_covid19_deaths_US.csv
   */
  filePaths?: FilePaths;
  /**
   * Whether to only load the US state and county data when it is requested.
   *
   * The US state and county data is much bigger than the global data, so it usually makes sense to
   * lazy load it for a better user experience.
   *
   * The default is `true`.
   */
  lazyLoadUSData?: boolean;
  /**
   * The duration in milliseconds that the data in the data store should be valid for.
   *
   * After this duration, the data is automatically re-fetched either from GitHub or reloaded from
   * local files, depending on the `loadFrom` option.
   *
   * The default is 1 hour.
   */
  dataValidityInMS?: number;
  /**
   * The `fetch` function to use, mainly needed for NodeJS. When this is not provided, it is
   * assumed that there is a `fetch` function in the global object.
   */
  fetch?: Fetch;
  /**
   * Provide a callback function to receive updates on the loading status of an API instance.
   */
  onLoadingStatusChange?: (isLoading: boolean, loadingMessage?: string) => void;
}

/**
 * Thrown when a method or property of an instance of {@link COVID19API} is called without it being
 * initialized first.
 */
export class COVID19APINotInitializedError extends COVID19APIError {
  constructor() {
    super("The COVID-19 API is not initialized. Make sure to first call the `init` method.");
    this.name = "COVID19APINotInitializedError";
    Object.setPrototypeOf(this, COVID19APINotInitializedError.prototype);
  }
}

/**
 * Thrown when `init` is called more than once.
 */
export class COVID19APIAlreadyInitializedError extends COVID19APIError {
  constructor() {
    super("The COVID-19 API is already initialized.");
    this.name = "COVID19APIAlreadyInitializedError";
    Object.setPrototypeOf(this, COVID19APIAlreadyInitializedError.prototype);
  }
}

/**
 * A class that provides a simple API for interacting with the JHU CSSE COVID-19 time series data.
 */
export class COVID19API {
  private readonly dataValidityInMS: number;
  private readonly lazyLoadUSData: boolean;
  private readonly onLoadingStatusChange:
    | ((isLoading: boolean, loadingMessage?: string) => void)
    | undefined;

  private isInitialized = false;

  private readonly dataStore: DataStore;
  private readonly dataGetter: DataGetter;

  constructor(options: COVID19APIOptions = {}) {
    const { lazyLoadUSData, dataValidityInMS, onLoadingStatusChange, fetch } = options;

    this.lazyLoadUSData = lazyLoadUSData ?? true;
    this.dataValidityInMS = dataValidityInMS ?? 60 * 60 * 1000; // 1 hour
    this.onLoadingStatusChange = onLoadingStatusChange;

    let { store, loadFrom, filePaths } = options;

    store = store ?? "memory";
    loadFrom = loadFrom ?? "github";
    filePaths = filePaths ?? {
      globalConfirmedCSVPath: "time_series_covid19_confirmed_global.csv",
      globalDeathsCSVPath: "time_series_covid19_deaths_global.csv",
      globalRecoveredCSVPath: "time_series_covid19_recovered_global.csv",
      usConfirmedCSVPath: "time_series_covid19_confirmed_US.csv",
      usDeathsCSVPath: "time_series_covid19_deaths_US.csv",
    };

    switch (store) {
      case "indexeddb":
        this.dataStore = new IndexedDBStore();
        break;
      case "memory":
        this.dataStore = new MemoryStore();
    }

    switch (loadFrom) {
      case "files":
        this.dataGetter = new FileGetter(
          filePaths.globalConfirmedCSVPath,
          filePaths.globalDeathsCSVPath,
          filePaths.globalRecoveredCSVPath,
          filePaths.usConfirmedCSVPath,
          filePaths.usDeathsCSVPath
        );
        break;
      case "github":
        this.dataGetter = new GitHubGetter(fetch);
    }
  }

  private _locations: string[] | undefined;
  /**
   * Returns the list of locations in the JHU CSSE dataset.
   *
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   */
  get locations(): string[] {
    if (this._locations == null) {
      throw new COVID19APINotInitializedError();
    }

    return [...this._locations];
  }

  private _sourceLastUpdatedAt: Date | undefined;
  /**
   * Returns the date and time the source of the data was last updated at.
   *
   * If the data getter is not able to get the date that the source was last updated on, it might
   * return `undefined`.
   *
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   */
  get sourceLastUpdatedAt(): Date | undefined {
    if (!this.isInitialized) {
      throw new COVID19APINotInitializedError();
    }

    return this._sourceLastUpdatedAt ? new Date(this._sourceLastUpdatedAt.getTime()) : undefined;
  }

  private _firstDate: Date | undefined;
  /**
   * Returns the first day of the time series data.
   *
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   */
  get firstDate(): Date {
    if (this._firstDate == null) {
      throw new COVID19APINotInitializedError();
    }

    return new Date(this._firstDate.getTime());
  }

  private _lastDate: Date | undefined;
  /**
   * Returns the last day of the time series data.
   *
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   */
  get lastDate(): Date {
    if (this._lastDate == null) {
      throw new COVID19APINotInitializedError();
    }

    return new Date(this._lastDate.getTime());
  }

  /**
   * Returns a parsed version of the given string containing comma separated values.
   *
   * @param csvPromise
   */
  private static async getParsedData(csvPromise: Promise<string>): Promise<ParsedCSV> {
    const csv = await csvPromise;

    return await parseCSV(csv);
  }

  /**
   * Initializes the API. This must be called before calling other methods.
   *
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   * @throws {@link COVID19APIAlreadyInitializedError} Thrown when this method is called more than
   *   once.
   */
  async init(): Promise<void> {
    if (this.isInitialized) {
      throw new COVID19APIAlreadyInitializedError();
    }

    this.onLoadingStatusChange?.(true, "Initializing.");
    await this.dataStore.init();

    await this.loadDataIfStoreHasNoFreshData();
    await this.setSourceLastUpdatedAt();
    await this.setLocations();
    await this.setFirstAndLastDates();

    this.isInitialized = true;
    this.onLoadingStatusChange?.(false);
  }

  /**
   * Returns the location data for the given location name.
   *
   * *If the API is initialized to lazy load the US data, calling this also automatically loads
   * the US data if the given location name is of a US county or state.*
   *
   * @param location The full name of the location, e.g. `"US (Autauga, Alabama)"`.
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   * @throws {@link DataStoreInvalidLocationError} Thrown when the given location cannot be found
   *   in the store.
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  async getDataByLocation(location: string): Promise<LocationData> {
    return (await this.getDataByLocations([location]))[0];
  }

  /**
   * Returns the location data for the given location names.
   *
   * *If the API is initialized to lazy load the US data, calling this also automatically loads
   * the US data if one of the given location names is of a US county or state.*
   *
   * @param locations An array containing the full names of the locations, e.g. `["US (Autauga,
   *   Alabama)", "Turkey"]`.
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   * @throws {@link DataStoreInvalidLocationError} Thrown when the given location cannot be found
   *   in the store.
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  async getDataByLocations(locations: string[]): Promise<LocationData[]> {
    if (!this.isInitialized) {
      throw new COVID19APINotInitializedError();
    }

    let forceLoadUSData = false;
    // Check if the user is requesting US state or county data.
    if (locations.some((location) => location !== "US" && location.includes("US"))) {
      forceLoadUSData = true;
    }
    await this.loadDataIfStoreHasNoFreshData(forceLoadUSData);

    const data = await this.dataStore.getLocationData(locations);

    return data.map(this.addCalculatedValues);
  }

  /**
   * Returns the location data for the given location name and date.
   *
   * *If the API is initialized to lazy load the US data, calling this also automatically loads
   * the US data if the given location name is of a US county or state.*
   *
   * @param location The full name of the location, e.g. `"US (Autauga, Alabama)"`.
   * @param date
   * @returns A Promise that will resolve to a {@link ValuesOnDate} object, of `undefined` if there
   *   is no data available for the given date.
   * @throws {@link COVID19APINotInitializedError} Thrown when the API instance is not initialized
   *   by calling the `init` method first.
   * @throws {@link DataStoreInvalidLocationError} Thrown when the given location cannot be found
   *   in the store.
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  async getDataByLocationAndDate(location: string, date: Date): Promise<ValuesOnDate | undefined> {
    const locationData = await this.getDataByLocation(location);
    const dateStr = dateToDateKey(date);

    return locationData.values.find((dateValues) => dateValues.date === dateStr);
  }

  /**
   * Checks if the data store already has data AND it is not stale, i.e. hasn't expired based on
   * the `dataValidityInMS` option.
   */
  private async hasFreshDataInStore(): Promise<boolean> {
    const savedAt = await this.dataStore.getSavedAt();
    const locationCount = await this.dataStore.getLocationCount();

    if (savedAt == null || locationCount === 0) {
      return false;
    }

    const dataValidity = this.dataValidityInMS;
    const expirationTime = savedAt.getTime() + dataValidity;

    return Date.now() < expirationTime;
  }

  /**
   * Returns `true` if the data store already has US county-level data.
   */
  private async hasUSDataInStore(): Promise<boolean> {
    try {
      const someUSCounty = "US (Autauga, Alabama)";
      await this.dataStore.getLocationData([someUSCounty]);

      return true;
    } catch (e) {
      if (e instanceof DataStoreInvalidLocationError) {
        return false;
      }

      throw e;
    }
  }

  /**
   * The internal location data only includes confirmed cases, deaths and recoveries data. This
   * method adds extra calculated values to the data, such as new confirmed cases and case fatality
   * rate.
   *
   * @param locationData
   */
  private addCalculatedValues(locationData: InternalLocationData): LocationData {
    const calculatedValues = locationData.values.map((valuesOnDate, index) => {
      let newConfirmed = 0;
      let newRecovered: number | null = null;
      let newDeaths: number | null = null;
      let recoveryRate: number | null = 0;
      let caseFatalityRate: number | null = 0;
      let activeCases: number | null = null;

      const { confirmed, recovered, deaths } = valuesOnDate;

      if (recovered != null && deaths != null) {
        activeCases = confirmed - (recovered + deaths);
      }

      if (index > 0) {
        const yesterdaysData = locationData.values?.[index - 1];

        if (recovered != null && yesterdaysData?.recovered != null) {
          newRecovered = recovered - yesterdaysData.recovered;
        }

        if (deaths != null && yesterdaysData?.deaths != null) {
          newDeaths = deaths - yesterdaysData.deaths;
        }

        if (yesterdaysData?.confirmed != null) {
          newConfirmed = confirmed - yesterdaysData.confirmed;
        }

        if (confirmed > 0) {
          recoveryRate = recovered != null ? recovered / confirmed : null;
          caseFatalityRate = deaths != null ? deaths / confirmed : null;
        }
      }

      return {
        ...valuesOnDate,
        newConfirmed,
        newRecovered,
        newDeaths,
        recoveryRate,
        caseFatalityRate,
        activeCases,
      };
    });

    return {
      ...locationData,
      values: calculatedValues,
    };
  }

  /**
   * Internally sets the date that the source of the data was last updated.
   *
   * When using {@link GitHubGetter}, this is the last commit date of the source CSV files.
   */
  private async setSourceLastUpdatedAt(): Promise<void> {
    this._sourceLastUpdatedAt = await this.dataStore.getSourceLastUpdatedAt();
  }

  /**
   * Internally sets the list of locations that are available in the data store, as well as the US
   * state and county location names even if they are not yet loaded, so that they can still be
   * requested even when they are lazy loaded.
   */
  private async setLocations(): Promise<void> {
    this._locations = await this.dataStore.getLocationsList();

    const someStateIndex = this._locations.indexOf("US (Alabama)");
    // If we haven't yet loaded the US state and county data,
    // add the US location names to the locations list, so that
    // the user can request them.
    if (someStateIndex === -1) {
      this._locations = [...this._locations, ...US_LOCATIONS];
    }
  }

  /**
   * Internally sets the first and the last date that the data store has data for.
   */
  private async setFirstAndLastDates(): Promise<void> {
    const someGlobalLocation = "Australia";
    const [someGlobalLocationData] = await this.dataStore.getLocationData([someGlobalLocation]);
    const someGlobalLocationValues = someGlobalLocationData.values;

    const dataSetLength = someGlobalLocationValues.length as number;
    this._firstDate = dateKeyToDate(someGlobalLocationValues[0].date as string);
    this._lastDate = dateKeyToDate(someGlobalLocationValues[dataSetLength - 1].date as string);
  }

  /**
   * Loads data if the store does not have data or the data in the store is expired.
   *
   * @param forceLoadUSData When `true`, loads the US county-level data even when `lazyLoadUSData`
   *   option is also set to `true`.
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private async loadDataIfStoreHasNoFreshData(forceLoadUSData = false): Promise<void> {
    const hasFreshData = await this.hasFreshDataInStore();
    const hasUSData = await this.hasUSDataInStore();
    let sourceLastUpdatedAt: Date | undefined;

    if (!hasUSData && forceLoadUSData) {
      await this.loadUSStateAndCountyData();

      sourceLastUpdatedAt = await this.dataGetter.getSourceLastUpdatedAt();
      await this.dataStore.setSourceLastUpdatedAt(sourceLastUpdatedAt);

      if (
        (sourceLastUpdatedAt != null && sourceLastUpdatedAt.getTime() > Date.now()) ||
        !hasFreshData
      ) {
        await this.loadGlobalData();
      }

      if (this.isInitialized) {
        this.onLoadingStatusChange?.(false);
      }

      return;
    }

    if (!hasFreshData) {
      await this.dataStore.clearData();

      await this.loadGlobalData();
      if (forceLoadUSData || !this.lazyLoadUSData) {
        await this.loadUSStateAndCountyData();
      }

      sourceLastUpdatedAt = await this.dataGetter.getSourceLastUpdatedAt();
      await this.dataStore.setSourceLastUpdatedAt(sourceLastUpdatedAt);
    }

    if (this.isInitialized) {
      this.onLoadingStatusChange?.(false);
    }
  }

  /**
   * Loads the data global confirmed cases, deaths and recoveries data from the data store.
   *
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private async loadGlobalData(): Promise<void> {
    this.onLoadingStatusChange?.(true, "Loading the global data.");
    const parsedGlobalConfirmedData = await this.getParsedGlobalConfirmedData();
    const parsedGlobalDeathsData = await this.getParsedGlobalDeathsData();
    const parsedGlobalRecoveredData = await this.getParsedGlobalRecoveredData();
    const formattedGlobalData = formatGlobalParsedData(
      parsedGlobalConfirmedData,
      parsedGlobalDeathsData,
      parsedGlobalRecoveredData
    );

    await this.dataStore.putLocationData(formattedGlobalData);
  }

  /**
   * Loads the US state and county data for confirmed cases and deaths from the data store.
   *
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private async loadUSStateAndCountyData(): Promise<void> {
    this.onLoadingStatusChange?.(true, "Loading the US data. This might take a little while.");
    const parsedUSConfirmedData = await this.getParsedUSConfirmedData();
    const parsedUSDeathsData = await this.getParsedUSDeathsData();

    const formattedUSData = formatUSParsedData(parsedUSConfirmedData, parsedUSDeathsData);

    await this.dataStore.putLocationData(formattedUSData);
  }

  /**
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private getParsedGlobalConfirmedData = (): Promise<ParsedCSV> =>
    COVID19API.getParsedData(this.dataGetter.getGlobalConfirmedData());

  /**
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private getParsedGlobalDeathsData = (): Promise<ParsedCSV> =>
    COVID19API.getParsedData(this.dataGetter.getGlobalDeathsData());

  /**
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private getParsedGlobalRecoveredData = (): Promise<ParsedCSV> =>
    COVID19API.getParsedData(this.dataGetter.getGlobalRecoveredData());

  /**
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private getParsedUSConfirmedData = (): Promise<ParsedCSV> =>
    COVID19API.getParsedData(this.dataGetter.getUSConfirmedData());

  /**
   * @throws {@link DataGetterError} Thrown when there is an error getting the data.
   */
  private getParsedUSDeathsData = (): Promise<ParsedCSV> =>
    COVID19API.getParsedData(this.dataGetter.getUSDeathsData());
}