Ver código fonte

first pass at redix-style data architecture

Adam Shaw 7 anos atrás
pai
commit
88daa5826f

+ 64 - 2
src/Calendar.ts

@@ -30,6 +30,7 @@ import { DateEnv, DateInput } from './datelib/env'
 import { DateMarker, startOfDay } from './datelib/marker'
 import { createFormatter } from './datelib/formatting'
 import { Duration, createDuration } from './datelib/duration'
+import { CalendarState, INITIAL_STATE, reduce } from './reducers/main'
 
 export default class Calendar {
 
@@ -78,6 +79,10 @@ export default class Calendar {
   footer: Toolbar
   toolbarsManager: Iterator
 
+  state: CalendarState = INITIAL_STATE
+  isReducing: boolean = false
+  actionQueue = []
+
 
   constructor(el: HTMLElement, overrides: OptionsInput) {
 
@@ -96,6 +101,7 @@ export default class Calendar {
     this.constraints = new Constraints(this.eventManager, this)
 
     this.constructed()
+    this.hydrate()
   }
 
 
@@ -126,6 +132,56 @@ export default class Calendar {
   }
 
 
+  // Dispatcher
+  // -----------------------------------------------------------------------------------------------------------------
+
+
+  dispatch(action) {
+    this.actionQueue.push(action)
+
+    if (!this.isReducing) {
+      this.isReducing = true
+      let oldState = this.state
+
+      while (this.actionQueue.length) {
+        this.state = this.reduce(
+          this.state,
+          this.actionQueue.shift(),
+          this
+        )
+      }
+
+      let newState = this.state
+      this.isReducing = false
+
+      if (!oldState.loadingLevel && newState.loadingLevel) {
+        console.log('start loading...')
+      } else if (oldState.loadingLevel && !newState.loadingLevel) {
+        console.log('...stopped loading')
+      }
+    }
+  }
+
+
+  reduce(state: CalendarState, action: object, calendar: Calendar): CalendarState {
+    return reduce(state, action, calendar)
+  }
+
+
+  hydrate() {
+    let rawSources = this.opt('eventSources') || []
+    let singleRawSource = this.opt('events')
+
+    if (singleRawSource) {
+      rawSources.unshift(singleRawSource)
+    }
+
+    rawSources.forEach((rawSource) => {
+      this.dispatch({ type: 'ADD_EVENT_SOURCE', rawSource })
+    })
+  }
+
+
   // Options Public API
   // -----------------------------------------------------------------------------------------------------------------
 
@@ -482,8 +538,14 @@ export default class Calendar {
 
     view.watch('dateProfileForCalendar', [ 'dateProfile' ], (deps) => {
       if (view === this.view) { // hack
-        this.currentDate = deps.dateProfile.date // might have been constrained by view dates
-        this.updateToolbarButtons(deps.dateProfile)
+        let dateProfile = deps.dateProfile
+        this.currentDate = dateProfile.date // might have been constrained by view dates
+        this.updateToolbarButtons(dateProfile)
+
+        this.dispatch({
+          type: 'SET_ACTIVE_RANGE',
+          range: dateProfile.activeUnzonedRange
+        })
       }
     })
   }

+ 3 - 0
src/main.ts

@@ -7,4 +7,7 @@ import './basic/config'
 import './agenda/config'
 import './list/config'
 
+import './reducers/json-feed-event-source'
+import './reducers/array-event-source'
+
 export = exportHooks

+ 18 - 0
src/reducers/array-event-source.ts

@@ -0,0 +1,18 @@
+import { registerSourceType } from './event-sources'
+import { EventInput } from './event-store'
+
+registerSourceType('array', {
+
+  parse(raw: any): EventInput[] {
+    if (Array.isArray(raw)) { // short form
+      return raw
+    } else if (Array.isArray(raw.events)) {
+      return raw.events
+    }
+  },
+
+  fetch(arg, success) {
+    success(arg.eventSource.sourceTypeMeta as EventInput[])
+  }
+
+})

+ 211 - 0
src/reducers/event-sources.ts

@@ -0,0 +1,211 @@
+import { assignTo } from '../util/object'
+import UnzonedRange from '../models/UnzonedRange'
+import Calendar from '../Calendar'
+import { EventInput } from './event-store'
+import { ClassNameInput, parseClassName, refineProps } from './utils'
+
+// types
+
+export interface AbstractEventSourceInput {
+  id?: string | number
+  allDayDefault?: boolean
+  eventDataTransform?: any
+  editable?: boolean
+  startEditable?: boolean
+  durationEditable?: boolean
+  overlap?: any
+  constraint?: any
+  rendering?: string
+  className?: ClassNameInput
+  color?: string
+  backgroundColor?: string
+  borderColor?: string
+  textColor?: string
+}
+
+export interface EventSource {
+  sourceId: string
+  sourceType: string
+  sourceTypeMeta: any
+  publicId: string
+  isFetching: boolean
+  latestFetchId: string | null
+  fetchRange: UnzonedRange
+  allDayDefault: boolean | null
+  eventDataTransform: any
+  editable: boolean | null
+  startEditable: boolean | null
+  durationEditable: boolean | null
+  overlap: any
+  constraint: any
+  rendering: string | null
+  className: string[]
+  color: string | null
+  backgroundColor: string | null
+  borderColor: string | null
+  textColor: string | null
+}
+
+export type EventSourceHash = { [sourceId: string]: EventSource }
+
+export interface EventSourceTypeSettings {
+  parse: (raw: any) => any
+  fetch: (
+    arg: {
+      eventSource: EventSource
+      calendar: Calendar
+      range: UnzonedRange
+    },
+    success: (rawEvents: EventInput) => void,
+    failure: () => void
+  ) => void
+}
+
+// vars
+
+const SIMPLE_SOURCE_PROPS = {
+  allDayDefault: Boolean,
+  eventDataTransform: null,
+  editable: Boolean,
+  startEditable: Boolean,
+  durationEditable: Boolean,
+  overlap: null,
+  constraint: null,
+  rendering: String,
+  className: parseClassName,
+  color: String,
+  backgroundColor: String,
+  borderColor: String,
+  textColor: String
+}
+
+let sourceTypes: { [sourceTypeName: string]: EventSourceTypeSettings} = {}
+let guid = 0
+
+// reducers
+
+export function reduceEventSourceHash(sourceHash: EventSourceHash, action: any, calendar: Calendar): EventSourceHash {
+  let eventSource
+
+  switch (action.type) {
+
+    case 'ADD_EVENT_SOURCE':
+      eventSource = parseSource(action.rawSource)
+
+      if (eventSource) {
+        if (calendar.state.activeRange) {
+          calendar.dispatch({
+            type: 'FETCH_EVENT_SOURCE',
+            sourceId: eventSource.sourceId,
+            range: calendar.state.activeRange
+          })
+        }
+        return assignTo({}, sourceHash, {
+          [eventSource.sourceId]: eventSource
+        })
+      } else {
+        return sourceHash
+      }
+
+    case 'FETCH_EVENT_SOURCE':
+      eventSource = sourceHash[action.sourceId]
+
+      let fetchId = String(guid++)
+      sourceTypes[eventSource.sourceType].fetch(
+        {
+          eventSource,
+          calendar,
+          range: action.range
+        },
+        function(rawEvents) {
+          calendar.dispatch({
+            type: 'RECEIVE_EVENT_SOURCE',
+            sourceId: eventSource.sourceId,
+            fetchId,
+            fetchRange: action.range,
+            rawEvents
+          })
+        },
+        function() {
+          calendar.dispatch({
+            type: 'ERROR_EVENT_SOURCE',
+            sourceId: eventSource.sourceId,
+            fetchId,
+            fetchRange: action.range
+          })
+        }
+      )
+
+      return assignTo({}, sourceHash, {
+        [eventSource.sourceId]: assignTo({}, eventSource, {
+          isFetching: true,
+          latestFetchId: fetchId
+        })
+      })
+
+    case 'RECEIVE_EVENT_SOURCE':
+    case 'ERROR_EVENT_SOURCE': // TODO: call calendar's/source's error handlers maybe
+      eventSource = sourceHash[action.sourceId]
+
+      if (eventSource.latestFetchId === action.fetchId) {
+        return assignTo({}, sourceHash, {
+          [eventSource.sourceId]: assignTo({}, eventSource, {
+            isFetching: false,
+            fetchRange: action.fetchRange
+          })
+        })
+      } else {
+        return sourceHash
+      }
+
+    case 'SET_ACTIVE_RANGE':
+      for (let sourceId in sourceHash) {
+        eventSource = sourceHash[sourceId]
+
+        if (
+          !eventSource.fetchRange ||
+          eventSource.fetchRange.start < action.range.start ||
+          eventSource.fetchRange.end > action.range.end
+        ) {
+          calendar.dispatch({
+            type: 'FETCH_EVENT_SOURCE',
+            sourceId: eventSource.sourceId,
+            range: action.range
+          })
+        }
+      }
+
+      return sourceHash
+
+    default:
+      return sourceHash
+  }
+}
+
+// parsing
+
+export function registerSourceType(type: string, settings: EventSourceTypeSettings) {
+  sourceTypes[type] = settings
+}
+
+function parseSource(raw: AbstractEventSourceInput): EventSource {
+  for (let sourceTypeName in sourceTypes) {
+    let sourceTypeSettings = sourceTypes[sourceTypeName]
+    let sourceTypeMeta = sourceTypeSettings.parse(raw)
+
+    if (sourceTypeMeta) {
+      let source: EventSource = refineProps(raw, SIMPLE_SOURCE_PROPS)
+      source.sourceId = String(guid++)
+      source.sourceType = sourceTypeName
+      source.sourceTypeMeta = sourceTypeMeta
+
+      if (raw.id != null) {
+        source.publicId = String(raw.id)
+      }
+
+      return source
+    }
+  }
+
+  return null
+}

+ 254 - 0
src/reducers/event-store.ts

@@ -0,0 +1,254 @@
+import UnzonedRange from '../models/UnzonedRange'
+import { DateInput } from '../datelib/env'
+import Calendar from '../Calendar'
+import { filterHash, parseClassName, refineProps, ClassNameInput } from './utils'
+import { expandRecurring } from './recurring-events'
+
+// types
+
+export interface EventInput {
+  id?: string | number
+  start?: DateInput
+  end?: DateInput
+  date?: DateInput
+  isAllDay?: boolean
+  title?: string
+  url?: string
+  editable?: boolean
+  startEditable?: boolean
+  durationEditable?: boolean
+  constraint?: any
+  overlap?: any
+  rendering?: '' | 'background' | 'inverse-background' | 'none'
+  className?: ClassNameInput
+  color?: string
+  backgroundColor?: string
+  borderColor?: string
+  textColor?: string
+  [extendedPropName: string]: any
+}
+
+export interface EventDef {
+  defId: string
+  sourceId: string
+  publicId: string | null
+  groupId: string | null
+  hasEnd: boolean
+  isAllDay: boolean
+  title: string | null
+  url: string | null
+  editable: boolean | null
+  startEditable: boolean | null
+  durationEditable: boolean | null
+  constraint: any
+  overlap: any
+  rendering: '' | 'background' | 'inverse-background' | 'none'
+  className: string[]
+  color: string | null
+  backgroundColor: string | null
+  borderColor: string | null
+  textColor: string | null
+  extendedProps: any
+}
+
+export interface EventInstance {
+  instanceId: string
+  defId: string
+  range: UnzonedRange
+  forcedStartTzo: number | null
+  forcedEndTzo: number | null
+}
+
+export interface EventStore {
+  defs: { [defId: string]: EventDef }
+  instances: { [instanceId: string]: EventInstance }
+}
+
+interface EventDateInfo {
+  isAllDay: boolean
+  hasEnd: boolean
+  range: UnzonedRange
+  forcedStartTzo: number | null
+  forcedEndTzo: number | null
+}
+
+// vars
+
+const DATE_PROPS = {
+  start: null,
+  date: null,
+  end: null,
+  isAllDay: null
+}
+
+const SIMPLE_DEF_PROPS = {
+  groupId: String,
+  title: String,
+  url: String,
+  editable: Boolean,
+  startEditable: Boolean,
+  durationEditable: Boolean,
+  constraint: null,
+  overlap: null,
+  rendering: String,
+  className: parseClassName,
+  color: String,
+  backgroundColor: String,
+  borderColor: String,
+  textColor: String
+}
+
+let guid = 0
+
+// reducing
+
+export function reduceEventStore(eventStore: EventStore, action: any, calendar: Calendar): EventStore {
+  switch(action.type) {
+
+    case 'RECEIVE_EVENT_SOURCE':
+      eventStore = excludeSource(eventStore, action.sourceId)
+      addRawEvents(eventStore, action.sourceId, action.fetchRange, action.rawEvents, calendar)
+      return eventStore
+
+    case 'CLEAR_EVENT_SOURCE': // TODO: wire up
+      return excludeSource(eventStore, action.sourceId)
+
+    default:
+      return eventStore
+  }
+}
+
+function excludeSource(eventStore: EventStore, sourceId: string): EventStore {
+  return {
+    defs: filterHash(eventStore.defs, function(def: EventDef) {
+      return def.sourceId !== sourceId
+    }),
+    instances: filterHash(eventStore.instances, function(instance: EventInstance) {
+      return eventStore.defs[instance.defId].sourceId !== sourceId
+    })
+  }
+}
+
+function addRawEvents(eventStore: EventStore, sourceId: string, fetchRange: UnzonedRange, rawEvents: any, calendar: Calendar) {
+  rawEvents.forEach(function(rawEvent: EventInput) {
+    let leftoverProps = {}
+    let recurringDateInfo = expandRecurring(rawEvent, fetchRange, calendar, leftoverProps)
+
+    if (recurringDateInfo) {
+      let def = addDef(eventStore, sourceId, leftoverProps, recurringDateInfo.isAllDay, recurringDateInfo.hasEnd)
+
+      for (let range of recurringDateInfo.ranges) {
+        addInstance(eventStore, def.defId, range)
+      }
+    } else {
+      let dateInfo = parseDateInfo(rawEvent, sourceId, calendar, leftoverProps)
+
+      if (dateInfo) {
+        let def = addDef(eventStore, sourceId, leftoverProps, dateInfo.isAllDay, dateInfo.hasEnd)
+        addInstance(eventStore, def.defId, dateInfo.range, dateInfo.forcedStartTzo, dateInfo.forcedEndTzo)
+      }
+    }
+  })
+}
+
+// parsing + adding
+
+function addDef(eventStore: EventStore, sourceId: string, raw: EventInput, isAllDay: boolean, hasEnd: boolean): EventDef {
+  let leftovers = {} as any
+  let def = refineProps(raw, SIMPLE_DEF_PROPS, leftovers)
+  let defId = String(guid++)
+
+  if (leftovers.id != null) {
+    def.publicId = String(leftovers.id)
+    delete leftovers.id
+  } else {
+    def.publicId = null
+  }
+
+  def.defId = defId
+  def.sourceId = sourceId
+  def.isAllDay = isAllDay
+  def.hasEnd = hasEnd
+  def.extendedProps = leftovers
+
+  eventStore.defs[defId] = def
+  return def
+}
+
+function addInstance(
+  eventStore: EventStore,
+  defId: string,
+  range: UnzonedRange,
+  forcedStartTzo: number = null,
+  forcedEndTzo: number = null
+): EventInstance {
+  let instanceId = String(guid++)
+  let instance = { instanceId, defId, range, forcedStartTzo, forcedEndTzo }
+
+  eventStore.instances[instanceId] = instance
+  return instance
+}
+
+function parseDateInfo(rawEvent: EventInput, sourceId: string, calendar: Calendar, leftoverProps: any): EventDateInfo {
+  let dateProps = refineProps(rawEvent, DATE_PROPS, leftoverProps)
+  let rawStart = dateProps.start
+  let startMeta
+  let hasEnd = false
+  let endMeta = null
+  let endMarker = null
+
+  if (rawStart == null) {
+    rawStart = dateProps.date
+  }
+
+  if (rawStart != null) {
+    startMeta = calendar.dateEnv.createMarkerMeta(rawStart)
+  }
+  if (!startMeta) {
+    return null
+  }
+
+  if (dateProps.end != null) {
+    endMeta = calendar.dateEnv.createMarkerMeta(dateProps.end)
+  }
+
+  let isAllDay = dateProps.isAllDay
+  if (isAllDay == null && sourceId) {
+    let source = calendar.state.eventSources[sourceId]
+    isAllDay = source.allDayDefault
+  }
+  if (isAllDay == null) {
+    isAllDay = calendar.opt('allDayDefault')
+  }
+  if (isAllDay == null) {
+    isAllDay = startMeta.isTimeUnspecified && (!endMeta || endMeta.isTimeUnspecified)
+  }
+
+  if (endMeta) {
+    endMarker = endMeta.marker
+
+    if (endMarker <= startMeta.marker) {
+      endMarker = null
+    }
+  }
+
+  if (endMarker) {
+    hasEnd = true
+  } else {
+    hasEnd = false
+    endMarker = calendar.dateEnv.add(
+      startMeta.marker,
+      isAllDay ?
+        calendar.defaultAllDayEventDuration :
+        calendar.defaultTimedEventDuration
+    )
+  }
+
+  return {
+    isAllDay,
+    hasEnd,
+    range: new UnzonedRange(startMeta.marker, endMarker),
+    forcedStartTzo: startMeta.forcedTimeZoneOffset, // TODO: rename to 'tzo' elsewhere
+    forcedEndTzo: endMeta ? endMeta.forcedTimeZoneOffset : null
+  }
+}

+ 110 - 0
src/reducers/json-feed-event-source.ts

@@ -0,0 +1,110 @@
+import UnzonedRange from '../models/UnzonedRange'
+import * as request from 'superagent'
+import { assignTo } from '../util/object'
+import Calendar from '../Calendar'
+import { registerSourceType } from './event-sources'
+
+interface JsonFeedMeta {
+  url: string
+  method: string
+  extraData: any
+  startParam: string | null
+  endParam: string | null
+  timezoneParam: string | null
+}
+
+registerSourceType('json-feed', {
+
+  parse(raw: any): JsonFeedMeta {
+    if (typeof raw === 'string') { // short form
+      raw = { url: raw }
+    } else if (!raw || typeof raw !== 'object' || !raw.url) {
+      return null
+    }
+
+    return {
+      url: raw.url,
+      method: (raw.method || 'GET').toUpperCase(),
+      extraData: raw.data,
+      startParam: raw.startParam || null,
+      endParam: raw.endParam || null,
+      timezoneParam: raw.timezoneParam || null
+    }
+  },
+
+  fetch(arg, success, failure) {
+    let meta: JsonFeedMeta = arg.eventSource.sourceTypeMeta
+    let theRequest
+    let requestParams = buildRequestParams(meta, arg.range, arg.calendar)
+
+    if (meta.method === 'GET') {
+      theRequest = request.get(meta.url).query(requestParams) // querystring params
+    } else {
+      theRequest = request(meta.method, this.url).send(requestParams) // body data
+    }
+
+    theRequest.end((error, res) => {
+      let rawEvents
+
+      if (!error) {
+        if (res.body) { // parsed JSON
+          rawEvents = res.body
+        } else if (res.text) {
+          // if the server doesn't set Content-Type, won't be parsed as JSON. parse anyway.
+          rawEvents = JSON.parse(res.text)
+        }
+      }
+
+      if (rawEvents) {
+        success(rawEvents)
+      } else {
+        failure()
+      }
+    })
+  }
+
+})
+
+function buildRequestParams(meta: JsonFeedMeta, range: UnzonedRange, calendar: Calendar) {
+  const dateEnv = calendar.dateEnv
+  let startParam
+  let endParam
+  let timezoneParam
+  let customRequestParams
+  let params = {}
+
+  startParam = meta.startParam
+  if (startParam == null) {
+    startParam = calendar.opt('startParam')
+  }
+
+  endParam = meta.endParam
+  if (endParam == null) {
+    endParam = calendar.opt('endParam')
+  }
+
+  timezoneParam = meta.timezoneParam
+  if (timezoneParam == null) {
+    timezoneParam = calendar.opt('timezoneParam')
+  }
+
+  // retrieve any outbound GET/POST data from the options
+  if (typeof meta.extraData === 'function') {
+    // supplied as a function that returns a key/value object
+    customRequestParams = meta.extraData()
+  } else {
+    // probably supplied as a straight key/value object
+    customRequestParams = meta.extraData || {}
+  }
+
+  assignTo(params, customRequestParams)
+
+  params[startParam] = dateEnv.formatIso(range.start)
+  params[endParam] = dateEnv.formatIso(range.end)
+
+  if (dateEnv.timeZone !== 'local') {
+    params[timezoneParam] = dateEnv.timeZone
+  }
+
+  return params
+}

+ 51 - 0
src/reducers/main.ts

@@ -0,0 +1,51 @@
+import UnzonedRange from '../models/UnzonedRange'
+import Calendar from '../Calendar'
+import { EventSourceHash, reduceEventSourceHash } from './event-sources'
+import { EventStore, reduceEventStore } from './event-store'
+
+export interface CalendarState {
+  loadingLevel: number
+  activeRange: UnzonedRange
+  eventSources: EventSourceHash
+  eventStore: EventStore
+}
+
+export const INITIAL_STATE: CalendarState = {
+  loadingLevel: 0,
+  activeRange: null,
+  eventSources: {},
+  eventStore: {
+    defs: {},
+    instances: {}
+  }
+}
+
+export function reduce(state: CalendarState, action: any, calendar: Calendar): CalendarState {
+  return {
+    loadingLevel: reduceLoadingLevel(state.loadingLevel, action),
+    activeRange: reduceActiveRange(state.activeRange, action),
+    eventSources: reduceEventSourceHash(state.eventSources, action, calendar),
+    eventStore: reduceEventStore(state.eventStore, action, calendar)
+  }
+}
+
+function reduceActiveRange(currentActiveRange, action: any) {
+  switch (action.type) {
+    case 'SET_ACTIVE_RANGE':
+      return action.range
+    default:
+      return currentActiveRange
+  }
+}
+
+function reduceLoadingLevel(level: number, action): number {
+  switch (action.type) {
+    case 'FETCH_EVENT_SOURCE':
+      return level + 1
+    case 'RECEIVE_EVENT_SOURCE':
+    case 'ERROR_EVENT_SOURCE':
+      return level - 1
+    default:
+      return level
+  }
+}

+ 115 - 0
src/reducers/recurring-events.ts

@@ -0,0 +1,115 @@
+import { startOfDay, addDays } from '../datelib/marker'
+import { Duration, createDuration } from '../datelib/duration'
+import UnzonedRange from '../models/UnzonedRange'
+import Calendar from '../Calendar'
+import { refineProps, arrayToHash } from './utils'
+import { EventInput } from './event-store'
+
+// types
+
+export interface RecurringEventDateInfo {
+  isAllDay: boolean
+  hasEnd: boolean
+  ranges: UnzonedRange[]
+}
+
+export type RecurringExpandFunc = (
+  rawEvent: EventInput,
+  range: UnzonedRange,
+  calendar: Calendar,
+  leftoverProps: object
+) => RecurringEventDateInfo
+
+// vars
+
+const SIMPLE_RECURRING_PROPS = {
+  daysOfWeek: null,
+  startTime: createDuration,
+  endTime: createDuration
+}
+
+let recurringTypes: { [recurringType: string]: RecurringExpandFunc } = {}
+
+// expanding API
+
+export function expandRecurring(rawEvent: EventInput, range: UnzonedRange, calendar: Calendar, leftoverProps: object): RecurringEventDateInfo {
+  for (let recurringType in recurringTypes) {
+    let expandFunc = recurringTypes[recurringType]
+    let dateInfo = expandFunc(rawEvent, range, calendar, leftoverProps)
+
+    if (dateInfo) {
+      return dateInfo
+    }
+  }
+}
+
+export function registerRecurringType(recurringType: string, func: RecurringExpandFunc) {
+  recurringTypes[recurringType] = func
+}
+
+// simple expanding
+
+registerRecurringType(
+  'simple',
+  function(rawEvent: EventInput, range: UnzonedRange, calendar: Calendar, leftoverProps: object): RecurringEventDateInfo {
+    if (
+      rawEvent.daysOfWeek ||
+      rawEvent.startTime != null ||
+      rawEvent.endTime != null
+    ) {
+      let refinedProps = refineProps(rawEvent, SIMPLE_RECURRING_PROPS, leftoverProps)
+
+      return {
+        isAllDay: !refinedProps.startTime && !refinedProps.endTime,
+        hasEnd: Boolean(refinedProps.endTime),
+        ranges: expandSimple(
+          refinedProps.daysOfWeek,
+          refinedProps.startTime,
+          refinedProps.endTime,
+          range,
+          calendar
+        )
+      }
+    }
+  }
+)
+
+function expandSimple(daysOfWeek: any, startTime: Duration, endTime: Duration, range: UnzonedRange, calendar: Calendar): UnzonedRange[] {
+  let dateEnv = calendar.dateEnv
+  let dowHash = daysOfWeek ? arrayToHash(daysOfWeek) : null
+  let dayMarker = startOfDay(range.start)
+  let endMarker = range.end
+  let instanceRanges = []
+
+  while (dayMarker < endMarker) {
+    let instanceStart
+    let instanceEnd
+
+    // if everyday, or this particular day-of-week
+    if (!dowHash || dowHash[dayMarker.getUTCDay()]) {
+
+      if (startTime) {
+        instanceStart = dateEnv.add(dayMarker, startTime)
+      } else {
+        instanceStart = dayMarker
+      }
+
+      if (endTime) {
+        instanceEnd = dateEnv.add(dayMarker, endTime)
+      } else {
+        instanceEnd = dateEnv.add(
+          instanceStart,
+          startTime ? // a timed event?
+            calendar.defaultTimedEventDuration :
+            calendar.defaultAllDayEventDuration
+        )
+      }
+
+      instanceRanges.push(new UnzonedRange(instanceStart, instanceEnd))
+    }
+
+    dayMarker = addDays(dayMarker, 1)
+  }
+
+  return instanceRanges
+}

+ 58 - 0
src/reducers/utils.ts

@@ -0,0 +1,58 @@
+
+export function filterHash(hash, func) {
+  let filtered = {}
+
+  for (let key in hash) {
+    if (func(hash[key], key)) {
+      filtered[key] = hash[key]
+    }
+  }
+
+  return filtered
+}
+
+export function refineProps(rawProps, processorFuncs, leftoverProps?): any {
+  let refined = {}
+
+  for (let key in processorFuncs) {
+    if (rawProps[key] == null) {
+      refined[key] = null
+    } else if (processorFuncs[key]) {
+      refined[key] = processorFuncs[key](rawProps[key])
+    } else {
+      refined[key] = rawProps[key]
+    }
+  }
+
+  if (leftoverProps) {
+    for (let key in rawProps) {
+      if (processorFuncs[key] === undefined) {
+        leftoverProps[key] = rawProps[key]
+      }
+    }
+  }
+
+  return refined
+}
+
+export type ClassNameInput = string | string[]
+
+export function parseClassName(raw: ClassNameInput) {
+  if (Array.isArray(raw)) {
+    return raw
+  } else if (typeof raw === 'string') {
+    return raw.split(/\s+/)
+  } else {
+    return []
+  }
+}
+
+export function arrayToHash(a) {
+  let hash = {}
+
+  for (let item of a) {
+    hash[item] = true
+  }
+
+  return hash
+}