redbadger/pride-london-app

View on GitHub
src/data/event.js

Summary

Maintainability
B
5 hrs
Test Coverage
// @flow
import R from "ramda";
import {
  set as setDate,
  diff as diffDate,
  add as addToDate,
  toFormat as formatDate,
  FORMAT_EUROPEAN_DATE,
  FORMAT_CONTENTFUL_ISO
} from "../lib/date";
import type { Maybe } from "../lib/maybe";
import * as maybe from "../lib/maybe";
import type { Decoder } from "../lib/decode";
import * as decode from "../lib/decode";
import type { FieldRef } from "./field-ref";
import decodeFieldRef from "./field-ref";
import * as colors from "../constants/colors";

export type Events = {
  [id: string]: Event
};

export type EventCategoryName =
  | "Cabaret and Variety"
  | "Community"
  | "Talks and Debates"
  | "Film and Screenings"
  | "Plays and Theatre"
  | "Social and Networking"
  | "Nightlife"
  | "Exhibition and Tours"
  | "Sports and Activities"
  | "Health"
  | "Music";

export const eventCategoryNames = [
  "Cabaret and Variety",
  "Community",
  "Talks and Debates",
  "Film and Screenings",
  "Plays and Theatre",
  "Social and Networking",
  "Nightlife",
  "Exhibition and Tours",
  "Sports and Activities",
  "Health",
  "Music"
];

const decodeEventCategoryName = decode.oneOf(
  eventCategoryNames.map(decode.value)
);

export type EventCategory = {
  label: EventCategoryName,
  color: string,
  contrast: boolean
};

// We use a switch here, rather than a look up so that we can ensure that
// this function will always return an EventCategory.
export const getEventCategoryFromName = (
  category: EventCategoryName
): EventCategory => {
  switch (category) {
    case "Cabaret and Variety":
      return {
        label: "Cabaret and Variety",
        color: colors.coralColor,
        contrast: false
      };
    case "Community":
      return {
        label: "Community",
        color: colors.brightLightBlueColor,
        contrast: true
      };
    case "Talks and Debates":
      return {
        label: "Talks and Debates",
        color: colors.darkSkyBlueColor,
        contrast: false
      };
    case "Film and Screenings":
      return {
        label: "Film and Screenings",
        color: colors.lightTealColor,
        contrast: true
      };
    case "Plays and Theatre":
      return {
        label: "Plays and Theatre",
        color: colors.warmPinkColor,
        contrast: false
      };
    case "Social and Networking":
      return {
        label: "Social and Networking",
        color: colors.turquoiseBlueColor,
        contrast: true
      };
    case "Nightlife":
      return {
        label: "Nightlife",
        color: colors.yellowColor,
        contrast: true
      };
    case "Exhibition and Tours":
      return {
        label: "Exhibition and Tours",
        color: colors.brightPurpleColor,
        contrast: false
      };
    case "Sports and Activities":
      return {
        label: "Sports and Activities",
        color: colors.vomitYellowColor,
        contrast: true
      };
    case "Health":
      return {
        label: "Health",
        color: colors.bubblegumPinkColor,
        contrast: true
      };
    case "Music":
      return {
        label: "Music",
        color: colors.cornflowerBlueColor,
        contrast: false
      };
    default:
      // Default case with cast to enable flow exhaustive match. This will
      // cause flow to error if passed anything other than an EventCategory;
      return (category: empty);
  }
};

export type Event = {
  // important to keep this at the top level so type refinement works
  contentType: "event",
  id: string,
  locale: string,
  revision: number,
  fields: {
    name: string,
    eventCategories: Array<EventCategoryName>,
    audience: Array<string>,
    startTime: string,
    endTime: string,
    location: { lat: number, lon: number },
    addressLine1: Maybe<string>,
    addressLine2: Maybe<string>,
    city: Maybe<string>,
    postcode: Maybe<string>,
    locationName: string,
    eventPriceLow: number,
    eventPriceHigh: number,
    accessibilityOptions: Array<string>,
    eventDescription: string,
    accessibilityDetails: Maybe<string>,
    email: Maybe<string>,
    phone: Maybe<string>,
    ticketingUrl: Maybe<string>,
    venueDetails: Array<string>,
    individualEventPicture: FieldRef,
    eventsListPicture: FieldRef,
    performances: Array<FieldRef>,
    recurrenceDates: Array<string>,
    stage: boolean
  }
};

export type SavedEvents = Set<string>;

export type EventDays = Event[][];

const maybeField = <A>(
  locale: string,
  field: string,
  decoder: Decoder<A>
): Decoder<Maybe<A>> =>
  decode.field(field, decode.maybe(decode.field(locale, decoder)));

const maybeFieldWithDefault = <A>(
  locale: string,
  field: string,
  decoder: Decoder<A>,
  defaultValue: A
): Decoder<A> =>
  decode.map(
    maybe.withDefault(defaultValue),
    maybeField(locale, field, decoder)
  );

export const decodeEvent = (locale: string): Decoder<Event> =>
  decode.shape({
    contentType: decode.at(
      ["sys", "contentType", "sys", "id"],
      decode.value("event")
    ),
    id: decode.at(["sys", "id"], decode.string),
    locale: decode.succeed(locale),
    revision: decode.at(["sys", "revision"], decode.number),
    fields: decode.field(
      "fields",
      decode.shape({
        name: decode.at(["name", locale], decode.string),
        eventCategories: decode.at(
          ["eventCategories", locale],
          decode.array(decodeEventCategoryName)
        ),
        audience: maybeFieldWithDefault(
          locale,
          "audience",
          decode.array(decode.string),
          []
        ),
        // may want to combine startTime and endTime to fix the
        // date flipping issue in the decoder
        // may also want to convert it to a DateTime
        startTime: decode.at(["startTime", locale], decode.string),
        endTime: decode.at(["endTime", locale], decode.string),
        location: decode.at(
          ["location", locale],
          decode.shape({
            lat: decode.field("lat", decode.number),
            lon: decode.field("lon", decode.number)
          })
        ),
        addressLine1: maybeField(locale, "addressLine1", decode.string),
        addressLine2: maybeField(locale, "addressLine2", decode.string),
        city: maybeField(locale, "city", decode.string),
        postcode: maybeField(locale, "postcode", decode.string),
        locationName: decode.at(["locationName", locale], decode.string),
        eventPriceLow: decode.at(["eventPriceLow", locale], decode.number),
        eventPriceHigh: decode.at(["eventPriceHigh", locale], decode.number),
        accessibilityOptions: maybeFieldWithDefault(
          locale,
          "accessibilityOptions",
          decode.array(decode.string),
          []
        ),
        eventDescription: decode.at(
          ["eventDescription", locale],
          decode.string
        ),
        accessibilityDetails: maybeField(
          locale,
          "accessibilityDetails",
          decode.string
        ),
        email: maybeField(locale, "email", decode.string),
        phone: maybeField(locale, "phone", decode.string),
        ticketingUrl: maybeField(locale, "ticketingUrl", decode.string),
        venueDetails: maybeFieldWithDefault(
          locale,
          "venueDetails",
          decode.array(decode.string),
          []
        ),
        individualEventPicture: decode.at(
          ["individualEventPicture", locale],
          decodeFieldRef
        ),
        eventsListPicture: decode.at(
          ["eventsListPicture", locale],
          decodeFieldRef
        ),
        performances: maybeFieldWithDefault(
          locale,
          "performances",
          decode.array(decodeFieldRef),
          []
        ),
        recurrenceDates: maybeFieldWithDefault(
          locale,
          "recurrenceDates",
          decode.array(decode.string),
          []
        ),
        stage: maybeFieldWithDefault(locale, "stage", decode.boolean, false)
      })
    )
  });

const formatEuropeanDate = value => formatDate(value, FORMAT_EUROPEAN_DATE);

const generateRecurringEvent = (event: Event) => (
  recurrenceStartTime: string
) => {
  const { endTime, startTime } = event.fields;

  const difference = diffDate(recurrenceStartTime, startTime);
  const recurrenceEndTime = addToDate(endTime, difference);

  return R.mergeDeepRight(event, {
    fields: {
      startTime: formatDate(recurrenceStartTime, FORMAT_CONTENTFUL_ISO),
      endTime: formatDate(recurrenceEndTime, FORMAT_CONTENTFUL_ISO),
      recurrenceDates: [
        formatEuropeanDate(startTime),
        ...event.fields.recurrenceDates
      ]
    },
    id: `${event.id}-recurrence-${formatEuropeanDate(recurrenceStartTime)}`
  });
};

const recurrenceDateToStartTime = (originalStartTime: string) => (
  recurrence: string
) => {
  const [recurrenceDay, recurrenceMonth, recurrenceYear] = recurrence.split(
    "/"
  );

  return formatDate(
    setDate(originalStartTime, {
      year: recurrenceYear,
      month: recurrenceMonth,
      day: recurrenceDay
    }),
    FORMAT_CONTENTFUL_ISO
  );
};

export const expandRecurringEvents = (event: Event): Array<Event> => {
  const recurrenceStartTimes = [
    event.fields.startTime,
    ...event.fields.recurrenceDates.map(
      recurrenceDateToStartTime(event.fields.startTime)
    )
  ];

  const events = R.uniq(recurrenceStartTimes)
    .slice(1)
    .map(generateRecurringEvent(event));
  return [event, ...events];
};