Adam Shaw пре 7 година
родитељ
комит
0c6f2a14db

+ 1 - 1
package.json

@@ -65,7 +65,7 @@
     "karma-jasmine": "^1.0.2",
     "karma-sourcemap-loader": "^0.3.7",
     "karma-verbose-reporter": "0.0.6",
-    "moment-timezone": "^0.5.5",
+    "moment-timezone": "^0.5.16",
     "native-promise-only": "^0.8.1",
     "node-sass": "^4.8.3",
     "sass-loader": "^6.0.7",

+ 50 - 0
src/datelib/calendar-system.ts

@@ -0,0 +1,50 @@
+import { DateMarker, arrayToUtcDate, dateToUtcArray } from './util'
+
+
+export interface CalendarSystem {
+  getMarkerYear(d: DateMarker): number;
+  getMarkerMonth(d: DateMarker): number;
+  getMarkerDay(d: DateMarker): number;
+  arrayToMarker(arr: number[]): DateMarker;
+  markerToArray(d: DateMarker): number[]
+}
+
+
+let calendarSystemClassMap = {}
+
+
+export function registerCalendarSystem(name, theClass) {
+  calendarSystemClassMap[name] = theClass
+}
+
+
+export function createCalendarSystem(name) {
+  return new calendarSystemClassMap[name]()
+}
+
+
+class GregorianCalendarSystem implements CalendarSystem {
+
+  getMarkerYear(d: DateMarker) {
+    return d.getUTCFullYear()
+  }
+
+  getMarkerMonth(d: DateMarker) {
+    return d.getUTCMonth()
+  }
+
+  getMarkerDay(d: DateMarker) {
+    return d.getUTCDate()
+  }
+
+  arrayToMarker(arr) {
+    return arrayToUtcDate(arr)
+  }
+
+  markerToArray(marker) {
+    return dateToUtcArray(marker)
+  }
+
+}
+
+registerCalendarSystem('gregorian', GregorianCalendarSystem)

+ 71 - 0
src/datelib/duration.ts

@@ -0,0 +1,71 @@
+
+export interface DurationObjInput {
+  years?: number
+  year?: number
+  months?: number
+  month?: number
+  days?: number
+  day?: number
+  hours?: number
+  hour?: number
+  minutes?: number
+  minute?: number
+  seconds?: number
+  second?: number
+  milliseconds?: number
+  millisecond?: number
+  ms?: number
+}
+
+export interface Duration {
+  year: number
+  month: number
+  day: number
+  time: number
+}
+
+let re = /^(?:(\d+)\.)?(\d\d):(\d\d)(?::(\d\d)(?:\.(\d\d\d))?)?/
+
+export function createDuration(input) {
+  if (typeof input === 'string') {
+    return parseString(input)
+  } else if (typeof input === 'object') {
+    return normalizeObject(input)
+  }
+}
+
+function parseString(s: string): Duration {
+  let m = re.exec(s)
+  if (m) {
+    return {
+      year: 0,
+      month: 0,
+      day: m[1] ? parseInt(m[1], 10) : 0, // todo: do this for others
+      time:
+        (parseInt(m[2], 10) || 0) * 60 * 60 * 1000 + // hours
+        (parseInt(m[3], 10) || 0) * 60 * 1000 + // minutes
+        (parseInt(m[4], 10) || 0) * 1000 + // seconds
+        (parseInt(m[5], 10) || 0) // ms
+    }
+  }
+}
+
+function normalizeObject(obj: DurationObjInput): Duration {
+  return {
+    year: obj.years || obj.year || 0,
+    month: obj.months || obj.month || 0,
+    day: obj.days || obj.day || 0,
+    time:
+      (obj.hours || obj.hour || 0) * 60 * 60 * 1000 + // hours
+      (obj.minutes || obj.minute || 0) * 60 * 1000 + // minutes
+      (obj.seconds || obj.second || 0) * 1000 + // seconds
+      (obj.milliseconds || obj.millisecond || obj.ms || 0) // ms
+  }
+}
+
+export function durationsEqual(d0: Duration, d1: Duration): boolean {
+  return d0.year === d1.year &&
+    d0.month === d1.month &&
+    d0.day === d1.day &&
+    d0.time === d1.time
+}

+ 251 - 0
src/datelib/env.ts

@@ -0,0 +1,251 @@
+import { DateMarker, arrayToUtcDate, weekOfYear, dayOfYearFromWeeks, dateToUtcArray, arrayToLocalDate, dateToLocalArray  } from './util'
+import { CalendarSystem, createCalendarSystem } from './calendar-system'
+import { namedTimeZoneOffsetGenerator, getNamedTimeZoneOffsetGenerator } from './timezone'
+import { getLocale } from './locale'
+import { Duration } from './duration'
+import { DateFormatter, buildIsoString } from './formatting'
+import { parse } from './parsing'
+
+export interface DateEnvSettings {
+  timeZone: string
+  timeZoneImpl?: string
+  calendarSystem: string
+  locale: string
+}
+
+export class DateEnv {
+
+  timeZone: string
+  namedTimeZoneOffsetGenerator: namedTimeZoneOffsetGenerator
+  calendarSystem: CalendarSystem
+  locale: string
+  weekMeta: any
+
+  constructor(settings: DateEnvSettings) {
+    this.timeZone = settings.timeZone
+    this.namedTimeZoneOffsetGenerator = getNamedTimeZoneOffsetGenerator(settings.timeZoneImpl)
+    this.calendarSystem = createCalendarSystem(settings.calendarSystem)
+    this.locale = settings.locale
+    this.weekMeta = getLocale(settings.locale).week
+  }
+
+  add(marker: DateMarker, dur: Duration): DateMarker {
+    let { calendarSystem } = this
+
+    return calendarSystem.arrayToMarker([
+      calendarSystem.getMarkerYear(marker) + dur.year,
+      calendarSystem.getMarkerMonth(marker) + dur.month,
+      calendarSystem.getMarkerDay(marker) + dur.day,
+      marker.getUTCHours(),
+      marker.getUTCMinutes(),
+      marker.getUTCSeconds(),
+      marker.getUTCMilliseconds() + dur.time
+    ])
+  }
+
+  startOfYear(marker: DateMarker): DateMarker {
+    let { calendarSystem } = this
+
+    return calendarSystem.arrayToMarker([
+      calendarSystem.getMarkerYear(marker) // might not work, might go to ms
+    ])
+  }
+
+  startOfMonth(marker: DateMarker): DateMarker {
+    let { calendarSystem } = this
+
+    return calendarSystem.arrayToMarker([
+      calendarSystem.getMarkerYear(marker),
+      calendarSystem.getMarkerMonth(marker)
+    ])
+  }
+
+  startOfWeek(marker: DateMarker): DateMarker {
+    let { dow, doy } = this.weekMeta
+    let weekInfo = weekOfYear(marker, dow, doy)
+    let dayOfYear = dayOfYearFromWeeks(weekInfo.year, weekInfo.week, 0, dow, doy)
+
+    return arrayToUtcDate([ dayOfYear.year, 0, dayOfYear.dayOfYear ]) // weeks are always in gregorian, i think
+  }
+
+  startOfDay(marker: DateMarker): DateMarker {
+    return arrayToUtcDate([
+      marker.getUTCFullYear(),
+      marker.getUTCMonth(),
+      marker.getUTCDate()
+    ])
+  }
+
+  toDate(marker: DateMarker): Date {
+    if (this.timeZone === 'UTC' || !this.canComputeTimeZoneOffset()) {
+      return new Date(marker.valueOf())
+    } else {
+      let arr = dateToUtcArray(marker)
+
+      if (this.timeZone === 'local') {
+        return arrayToLocalDate(arr)
+      } else {
+        return new Date(
+          marker.valueOf() -
+          this.namedTimeZoneOffsetGenerator(this.timeZone, arr)
+        )
+      }
+    }
+  }
+
+  canComputeTimeZoneOffset() {
+    return this.timeZone === 'UTC' || this.timeZone === 'local' || this.namedTimeZoneOffsetGenerator
+  }
+
+  // will return undefined if cant compute it
+  computeTimeZoneOffset(marker: DateMarker) {
+    if (this.timeZone === 'UTC') {
+      return 0
+    } else {
+      let arr = dateToUtcArray(marker)
+
+      if (this.timeZone === 'local') {
+        return arrayToLocalDate(arr).getTimezoneOffset()
+      } else if (this.namedTimeZoneOffsetGenerator) {
+        return this.namedTimeZoneOffsetGenerator(this.timeZone, arr)
+      }
+    }
+  }
+
+  toRangeFormat(start: DateMarker, end: DateMarker, formatter: DateFormatter, extraOptions: any = {}) {
+    return formatter.format(
+      {
+        marker: start,
+        timeZoneOffset: extraOptions.forcedStartTimeZoneOffset != null ?
+          extraOptions.forcedStartTimeZoneOffset :
+          this.computeTimeZoneOffset(start)
+      },
+      {
+        marker: end,
+        timeZoneOffset: extraOptions.forcedEndTimeZoneOffset != null ?
+          extraOptions.forcedEndTimeZoneOffset :
+          this.computeTimeZoneOffset(end)
+      },
+      this
+    )
+  }
+
+  toFormat(marker: DateMarker, formatter: DateFormatter, extraOptions: any = {}) {
+    return formatter.format(
+      {
+        marker: marker,
+        timeZoneOffset: extraOptions.forcedTimeZoneOffset != null ?
+          extraOptions.forcedTimeZoneOffset :
+          this.computeTimeZoneOffset(marker)
+      },
+      null,
+      this
+    )
+  }
+
+  toIso(marker: DateMarker, extraOptions: any = {}) {
+    return buildIsoString(
+      marker,
+      extraOptions.forcedTimeZoneOffset != null ?
+        extraOptions.forcedTimeZoneOffset :
+        this.computeTimeZoneOffset(marker)
+    )
+  }
+
+  parse(str: string) {
+    let parts = parse(str)
+    let marker = parts.marker
+    let forcedTimeZoneOffset = null
+
+    if (parts.timeZoneOffset != null) {
+      if (this.canComputeTimeZoneOffset()) { // can get rid of this now?
+        marker = this.timestampToMarker(marker.valueOf() - parts.timeZoneOffset * 60 * 1000)
+      } else {
+        forcedTimeZoneOffset = parts.timeZoneOffset
+      }
+    }
+
+    return { marker, hasTime: parts.hasTime, forcedTimeZoneOffset } // TODO: timeNotSpecified
+  }
+
+  dateToMarker(date: Date) {
+    return this.timestampToMarker(date.valueOf())
+  }
+
+  timestampToMarker(ms: number) {
+    if (this.timeZone === 'UTC') {
+      return new Date(ms)
+    } else if (this.timeZone === 'local') {
+      return arrayToUtcDate(dateToLocalArray(new Date(ms)))
+    } else {
+      throw 'need tz system!!!'
+    }
+  }
+
+  diffWholeYears(m0: DateMarker, m1: DateMarker): number {
+    let { calendarSystem } = this
+
+    if (
+      m0.getUTCMilliseconds() === m1.getUTCMilliseconds() && // TODO: util for time
+      m0.getUTCSeconds() === m1.getUTCSeconds() &&
+      m0.getUTCMinutes() === m1.getUTCMinutes() &&
+      m0.getUTCHours() === m1.getUTCHours() &&
+      calendarSystem.getMarkerDay(m0) === calendarSystem.getMarkerDay(m1) &&
+      calendarSystem.getMarkerMonth(m0) === calendarSystem.getMarkerMonth(m1)
+    ) {
+      return calendarSystem.getMarkerYear(m1) - calendarSystem.getMarkerYear(m0)
+    }
+    return null
+  }
+
+  diffWholeMonths(m0: DateMarker, m1: DateMarker): number {
+    let { calendarSystem } = this
+
+    if (
+      m0.getUTCMilliseconds() === m1.getUTCMilliseconds() &&
+      m0.getUTCSeconds() === m1.getUTCSeconds() &&
+      m0.getUTCMinutes() === m1.getUTCMinutes() &&
+      m0.getUTCHours() === m1.getUTCHours() &&
+      calendarSystem.getMarkerDay(m0) === calendarSystem.getMarkerDay(m1)
+    ) {
+      return calendarSystem.getMarkerMonth(m1) - calendarSystem.getMarkerMonth(m0) +
+       (calendarSystem.getMarkerYear(m1) - calendarSystem.getMarkerYear(m0)) * 12
+    }
+    return null
+  }
+
+  diffWholeWeeks(m0: DateMarker, m1: DateMarker): number {
+    let days = this.diffWholeDays(m0, m1)
+
+    if (days !== null && days % 7 === 0) {
+      return days / 7
+    }
+
+    return null
+  }
+
+  diffWholeDays(m0: DateMarker, m1: DateMarker): number {
+    if (
+      m0.getUTCMilliseconds() === m1.getUTCMilliseconds() &&
+      m0.getUTCSeconds() === m1.getUTCSeconds() &&
+      m0.getUTCMinutes() === m1.getUTCMinutes() &&
+      m0.getUTCHours() === m1.getUTCHours()
+    ) {
+      return Math.round((m1.valueOf() - m0.valueOf()) / 864e5)
+    }
+    return null
+  }
+
+  diffDayAndTime(m0: DateMarker, m1: DateMarker): Duration {
+    let m0day = this.startOfDay(m0)
+    let m1day = this.startOfDay(m1)
+
+    return {
+      year: 0,
+      month: 0,
+      day: Math.round((m1day.valueOf() - m0day.valueOf()) / 864e5),
+      time: (m1.valueOf() - m1day.valueOf()) - (m0.valueOf() - m0day.valueOf())
+    }
+  }
+
+}

+ 387 - 0
src/datelib/formatting.ts

@@ -0,0 +1,387 @@
+import { DateMarker } from './util'
+import { CalendarSystem } from './calendar-system'
+import { assignTo } from '../util/object'
+import { DateEnv } from './env'
+
+
+export interface ZonedMarker {
+  marker: DateMarker,
+  timeZoneOffset: number
+}
+
+export interface ExpandedZonedMarker extends ZonedMarker {
+  arr: number[]
+}
+
+export interface DateFormatter {
+  format(start: ZonedMarker, end: ZonedMarker, env: DateEnv)
+}
+
+
+// TODO: optimize by only using DateTimeFormat?
+
+
+
+
+
+const DEFAULT_SEPARATOR = ' - '
+
+const SEVERITIES_FOR_PARTS = {
+  year: 4,
+  month: 3,
+  day: 2,
+  weekday: 2,
+  hour: 1,
+  minute: 1,
+  second: 1
+}
+
+class NativeFormatter implements DateFormatter {
+
+  formatSettings: any
+  separator: string // for ranges
+
+  constructor(formatSettings) {
+    formatSettings = assignTo({}, formatSettings, { timeZone: 'UTC' })
+
+    if (formatSettings.separator) {
+      this.separator = formatSettings.separator
+      delete formatSettings.separator
+    }
+
+    this.formatSettings = formatSettings
+  }
+
+  format(start: ZonedMarker, end: ZonedMarker, env: DateEnv) {
+    let { formatSettings } = this
+
+    // need to format the timeZone name in a zone other than UTC?
+    if (formatSettings.timeZoneName && env.timeZone !== 'UTC') {
+      formatSettings = assignTo({}, formatSettings) // copy
+
+      if (start.timeZoneOffset == null || end && end.timeZoneOffset == null) {
+        delete formatSettings.timeZoneName // don't have necessary tzo into. don't even try
+      } else {
+        formatSettings.timeZoneName = 'short' // only know how to display offset info for short (+00:00)
+      }
+    }
+
+    if (end) {
+      return formatRange(start, end, env, formatSettings, this.separator || DEFAULT_SEPARATOR)
+    } else {
+      return formatZonedMarker(start, env.locale, env.timeZone, formatSettings)
+    }
+  }
+
+}
+
+
+
+
+function formatRange(start: ZonedMarker, end: ZonedMarker, env: DateEnv, formatSettings: any, separator: string) {
+
+  let diffSeverity = computeMarkerDiffSeverity(start.marker, end.marker, env.calendarSystem)
+  if (!diffSeverity) {
+    return formatZonedMarker(start, env.locale, env.timeZone, formatSettings)
+  }
+
+  let biggestUnitForPartial = diffSeverity
+  if (
+    biggestUnitForPartial > 1 && // hour/min/sec/ms
+    (formatSettings.year === 'numeric' || formatSettings.year === '2-digit') &&
+    (formatSettings.month === 'numeric' || formatSettings.month === '2-digit') &&
+    (formatSettings.day === 'numeric' || formatSettings.day === '2-digit')
+  ) {
+    biggestUnitForPartial = 1
+  }
+
+  let full0 = formatZonedMarker(start, env.locale, env.timeZone, formatSettings)
+  let full1 = formatZonedMarker(end, env.locale, env.timeZone, formatSettings)
+  let partialFormatSettings = computePartialFormattingOptions(formatSettings, biggestUnitForPartial)
+  let partial0 = formatZonedMarker(start, env.locale, env.timeZone, partialFormatSettings)
+  let partial1 = formatZonedMarker(end, env.locale, env.timeZone, partialFormatSettings)
+  let insertion = findCommonInsertion(full0, partial0, full1, partial1)
+
+  if (insertion) {
+    return insertion.before + partial0 + separator + partial1 + insertion.after;
+  }
+
+  return full0 + separator + full1;
+}
+
+// 0 = exactly the same
+// 1 = different by time
+// 2 = different by day
+// 3 = different by month
+// 4 = different by year
+function computeMarkerDiffSeverity(d0: DateMarker, d1: DateMarker, ca: CalendarSystem) {
+  if (ca.getMarkerYear(d0) !== ca.getMarkerYear(d1)) {
+    return 4;
+  }
+  if (ca.getMarkerMonth(d0) !== ca.getMarkerMonth(d1)) {
+    return 3;
+  }
+  if (ca.getMarkerDay(d0) !== ca.getMarkerDay(d1)) {
+    return 2;
+  }
+  if (
+    d0.getUTCHours() !== d1.getUTCHours() ||
+    d0.getUTCMinutes() !== d1.getUTCMinutes() ||
+    d0.getUTCSeconds() !== d1.getUTCSeconds() ||
+    d0.getUTCMilliseconds() !== d1.getUTCMilliseconds()
+  ) {
+    return 1;
+  }
+  return 0;
+}
+
+function computePartialFormattingOptions(options, biggestUnit) {
+  var partialOptions = {};
+  var name;
+
+  for (name in options) {
+    if (
+      name === 'timeZone' ||
+      SEVERITIES_FOR_PARTS[name] <= biggestUnit // if undefined, will always be false
+    ) {
+      partialOptions[name] = options[name];
+    }
+  }
+
+  return partialOptions;
+}
+
+function findCommonInsertion(full0, partial0, full1, partial1) {
+  var i0, i1;
+  var found0, found1;
+  var before0, after0;
+  var before1, after1;
+
+  i0 = 0;
+  while (i0 < full0.length) {
+    found0 = full0.indexOf(partial0, i0);
+    if (found0 === -1) {
+      break;
+    }
+
+    before0 = full0.substr(0, found0);
+    i0 = found0 + partial0.length;
+    after0 = full0.substr(i0);
+
+    i1 = 0;
+    while (i1 < full1.length) {
+      found1 = full1.indexOf(partial1, i1);
+      if (found1 === -1) {
+        break;
+      }
+
+      before1 = full1.substr(0, found1);
+      i1 = found1 + partial1.length;
+      after1 = full1.substr(i1);
+
+      if (before0 === before1 && after0 === after1) {
+        return {
+          before: before0,
+          after: after0
+        }
+      }
+    }
+  }
+
+  return null;
+}
+
+
+
+
+
+
+
+
+
+export interface FuncFormatterDate extends ExpandedZonedMarker {
+  year: number,
+  month: number,
+  day: number,
+  hour: number,
+  minute: number,
+  second: number,
+  millisecond: number
+}
+
+export interface FuncFormatterArg {
+  date: FuncFormatterDate
+  start: FuncFormatterDate
+  end?: FuncFormatterDate
+  timeZone: string
+  locale: string
+}
+
+export type funcFormatterFunc = (arg: FuncFormatterArg) => string
+
+class FuncFormatter implements DateFormatter {
+
+  func: funcFormatterFunc
+
+  constructor(func: funcFormatterFunc) {
+    this.func = func
+  }
+
+  format(start: ZonedMarker, end: ZonedMarker, env: DateEnv) {
+    let startInfo = dateForFuncFormatter(start, env)
+    let endInfo = end ? dateForFuncFormatter(end, env) : null
+
+    return this.func({
+      date: startInfo,
+      start: startInfo,
+      end: endInfo,
+      timeZone: env.timeZone,
+      locale: env.locale
+    })
+  }
+
+}
+
+function dateForFuncFormatter(dateInfo: ZonedMarker, env: DateEnv): FuncFormatterDate {
+  let arr = env.calendarSystem.markerToArray(dateInfo.marker)
+  return {
+    marker: dateInfo.marker,
+    timeZoneOffset: dateInfo.timeZoneOffset,
+    arr: arr,
+    year: arr[0],
+    month: arr[1],
+    day: arr[2],
+    hour: arr[3],
+    minute: arr[4],
+    second: arr[5],
+    millisecond: arr[6],
+  }
+}
+
+
+
+
+
+
+
+export interface CmdStrFormatterArg {
+  date: ExpandedZonedMarker
+  start: ExpandedZonedMarker
+  end?: ExpandedZonedMarker
+  timeZone: string
+  locale: string
+}
+
+export type cmdStrFormatterFunc = (cmd: string, arg: CmdStrFormatterArg) => string
+
+let soleCmdStrProcessor: cmdStrFormatterFunc = null
+
+export function registerCmdStrProcessor(name, input: cmdStrFormatterFunc) {
+  if (!soleCmdStrProcessor) {
+    soleCmdStrProcessor = input
+  }
+}
+
+class CmdStrFormatter implements DateFormatter {
+
+  cmdStr: string
+
+  constructor(cmdStr: string) {
+    this.cmdStr = cmdStr
+  }
+
+  format(start: ZonedMarker, end: ZonedMarker, env: DateEnv) {
+    let startInfo = dateInfoForCmdStrFormatter(start, env)
+    let endInfo = end ? dateInfoForCmdStrFormatter(end, env) : null
+
+    return soleCmdStrProcessor(this.cmdStr, {
+      date: startInfo,
+      start: startInfo,
+      end: endInfo,
+      timeZone: env.timeZone,
+      locale: env.locale
+    })
+  }
+
+}
+
+function dateInfoForCmdStrFormatter(dateInfo: ZonedMarker, env: DateEnv): ExpandedZonedMarker {
+  return {
+    marker: dateInfo.marker,
+    timeZoneOffset: dateInfo.timeZoneOffset,
+    arr: env.calendarSystem.markerToArray(dateInfo.marker)
+  }
+}
+
+
+
+
+
+
+
+
+
+export function createFormatter(input): DateFormatter {
+  if (typeof input === 'string') {
+    return new CmdStrFormatter(input)
+  }
+  else if (typeof input === 'function') {
+    return new FuncFormatter(input)
+  }
+  else if (typeof input === 'object') {
+    return new NativeFormatter(input)
+  }
+}
+
+
+
+
+
+function formatPrettyTimeZoneOffset(minutes: number) { // combine these two???
+  let sign = minutes < 0 ? '+' : '-' // whaaa
+  let abs = Math.abs(minutes)
+  let hours = Math.floor(abs / 60)
+  let mins = Math.round(abs % 60)
+
+  return 'GMT' + sign + hours + (mins ? ':' + pad(mins) : '')
+}
+
+function formatIsoTimeZoneOffset(minutes: number) {
+  let sign = minutes < 0 ? '+' : '-' // whaaa
+  let abs = Math.abs(minutes)
+  let hours = Math.floor(abs / 60)
+  let mins = Math.round(abs % 60)
+
+  return sign + pad(hours) + ':' + pad(mins)
+}
+
+
+function pad(n) {
+  return n < 10 ? '0' + n : '' + n
+}
+
+
+export function buildIsoString(marker: DateMarker, timeZoneOffset?: number) {
+  let s = marker.toISOString().replace('.000', '')
+
+  if (timeZoneOffset !== 0) {
+    s = s.replace('Z', '')
+
+    if (timeZoneOffset != null) {
+      s += formatIsoTimeZoneOffset(timeZoneOffset)
+    }
+  }
+
+  return s
+}
+
+
+function formatZonedMarker(d: ZonedMarker, locale: string, desiredTimeZone: string, formatSettings: any) {
+  let s = d.marker.toLocaleString(locale, formatSettings)
+
+  if (formatSettings.timeZoneName && d.timeZoneOffset != null && desiredTimeZone !== 'UTC') {
+    s = s.replace(/UTC|GMT/, formatPrettyTimeZoneOffset(d.timeZoneOffset))
+  }
+
+  return s
+}

+ 25 - 0
src/datelib/locale.ts

@@ -0,0 +1,25 @@
+
+export interface LocaleData {
+  week: { dow: number, doy: number }
+}
+
+let localeMap: { [name: string]: LocaleData } = {}
+
+const EN_LOCALE = {
+  week: {
+    dow: 1,
+    doy: 4
+  }
+}
+
+
+export function registerLocale(name: string, data: LocaleData) {
+  localeMap[name] = data
+}
+
+export function getLocale(name: string) {
+  return localeMap[name] || EN_LOCALE
+}
+
+
+registerLocale('en', EN_LOCALE)

+ 40 - 0
src/datelib/main.ts

@@ -0,0 +1,40 @@
+import { DateEnv } from './env'
+import { createFormatter } from './formatting'
+import { DateMarker, nowMarker } from './util'
+//import './moment'
+//import './moment-timezone'
+
+let env = new DateEnv({
+  timeZone: 'Asia/Hong_Kong',
+  timeZoneImpl: 'UTC-coercion', //'moment-timezone',
+  calendarSystem: 'gregorian',
+  locale: 'es' // TODO: what about 'auto'?
+})
+
+let start: DateMarker = nowMarker()
+let end: DateMarker = env.startOfMonth(start)
+
+let formatter = createFormatter({
+  year: 'numeric',
+  month: 'long',
+  //weekday: 'long',
+  day: 'numeric',
+  //hour: '2-digit',
+  //minute: '2-digit',
+  //hour12: true,
+  //timeZoneName: 'long'
+})
+
+// let formatter = createFormatter(function() {
+//   debugger
+//   return '!!!'
+// })
+
+// let formatter = createFormatter('dddd, MMMM Do YYYY, h:mm:ss a Z')
+
+console.log(
+  env.toRangeFormat(end, start, formatter, {
+    forcedStartTimeZoneOffset: 60,
+    forcedEndTimeZoneOffset: 60
+  })
+)

+ 8 - 0
src/datelib/moment-timezone.ts

@@ -0,0 +1,8 @@
+import * as moment from 'moment'
+import 'moment-timezone'
+import { registerNamedTimeZoneOffsetGenerator } from './timezone'
+
+registerNamedTimeZoneOffsetGenerator('moment-timezone', function(timeZoneName: string, array: number[]) {
+  // TODO: need to return ms!!!
+  return -(moment as any).tz(array, timeZoneName).utcOffset() // need negative!
+})

+ 19 - 0
src/datelib/moment.ts

@@ -0,0 +1,19 @@
+import * as moment from 'moment'
+import { registerCmdStrProcessor } from './formatting'
+
+registerCmdStrProcessor('moment', function(cmdStr: string, marker, params) {
+  let mom: moment.Moment
+  let arr = params.calendarSystem.markerToArray(marker)
+
+  if (params.timeZone === 'local') {
+    mom = moment(arr)
+  } else if (params.timeZone === 'UTC' || !(moment as any).tz) {
+    mom = moment.utc(arr)
+  } else {
+    mom = (moment as any).tz(arr, params.timeZone)
+  }
+
+  mom.locale(params.locale)
+
+  return mom.format(cmdStr)
+})

+ 32 - 0
src/datelib/parsing.ts

@@ -0,0 +1,32 @@
+
+const ISO_TIME_RE = /^\s*\d{4}-\d\d-\d\d[T ]\d/
+const ISO_TZO_RE = /(?:(Z)|([-+])(\d\d)(?::(\d\d))?)$/
+
+// TODO: accept a Date object somehow!!!
+
+export function parse(str) {
+  let timeZoneOffset = null
+  let hasTime = false
+
+  if (ISO_TIME_RE.test(str)) {
+    hasTime = true
+
+    str = str.replace(ISO_TZO_RE, function(whole, z, sign, minutes, seconds) {
+      if (z) {
+        timeZoneOffset = 0
+      } else {
+        timeZoneOffset = (
+          parseInt(minutes, 10) * 60 +
+          parseInt(seconds || 0, 10)
+        ) * (sign === '-' ? -1 : 1)
+      }
+      return ''
+    }) + '-00:00' // otherwise will parse in local
+  }
+
+  return {
+    marker: new Date(str),
+    hasTime,
+    timeZoneOffset
+  }
+}

+ 16 - 0
src/datelib/timezone.ts

@@ -0,0 +1,16 @@
+
+
+
+export type namedTimeZoneOffsetGenerator = (timeZoneName: string, array: number[]) => number
+
+let namedTimeZoneOffsetGeneratorMap = {}
+
+
+export function registerNamedTimeZoneOffsetGenerator(name, timeZoneOffsetGenerator: namedTimeZoneOffsetGenerator) {
+  namedTimeZoneOffsetGeneratorMap[name] = timeZoneOffsetGenerator
+}
+
+
+export function getNamedTimeZoneOffsetGenerator(name) {
+  return namedTimeZoneOffsetGeneratorMap[name]
+}

+ 142 - 0
src/datelib/util.ts

@@ -0,0 +1,142 @@
+
+export type DateMarker = Date
+
+export function nowMarker(): DateMarker {
+  return arrayToUtcDate(dateToLocalArray(new Date()))
+}
+
+
+// export function markersEqual(m0: DateMarker, m1)
+
+
+export function dateToLocalArray(date) {
+  return [
+    date.getFullYear(),
+    date.getMonth(),
+    date.getDate(),
+    date.getHours(),
+    date.getMinutes(),
+    date.getSeconds(),
+    date.getMilliseconds()
+  ]
+}
+
+export function arrayToLocalDate(arr) {
+  if (!arr.length) {
+    return new Date()
+  }
+  return new Date(
+    arr[0],
+    arr[1] || 0,
+    arr[2] || 1,
+    arr[3] || 0,
+    arr[4] || 0,
+    arr[5] || 0,
+  )
+}
+
+export function dateToUtcArray(date) {
+  return [
+    date.getUTCFullYear(),
+    date.getUTCMonth(),
+    date.getUTCDate(),
+    date.getUTCHours(),
+    date.getUTCMinutes(),
+    date.getUTCSeconds(),
+    date.getUTCMilliseconds()
+  ]
+}
+
+export function arrayToUtcDate(arr) {
+  return new Date(Date.UTC.apply(Date, arr))
+}
+
+
+// WEEK UTILS
+
+// https://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday
+export function dayOfYearFromWeeks(year, week, weekday, dow, doy) {
+  var localWeekday = (7 + weekday - dow) % 7,
+      weekOffset = firstWeekOffset(year, dow, doy),
+      dayOfYear = 1 + 7 * (week - 1) + localWeekday + weekOffset,
+      resYear, resDayOfYear;
+
+  if (dayOfYear <= 0) {
+      resYear = year - 1;
+      resDayOfYear = daysInYear(resYear) + dayOfYear;
+  } else if (dayOfYear > daysInYear(year)) {
+      resYear = year + 1;
+      resDayOfYear = dayOfYear - daysInYear(year);
+  } else {
+      resYear = year;
+      resDayOfYear = dayOfYear;
+  }
+
+  return {
+      year: resYear,
+      dayOfYear: resDayOfYear
+  };
+}
+
+export function weekOfYear(marker: DateMarker, dow, doy) {
+  var year = marker.getUTCFullYear(),
+      weekOffset = firstWeekOffset(year, dow, doy),
+      week = Math.floor((dayOfYear(marker) - weekOffset - 1) / 7) + 1,
+      resWeek, resYear;
+
+  if (week < 1) {
+      resYear = year - 1;
+      resWeek = week + weeksInYear(resYear, dow, doy);
+  } else if (week > weeksInYear(year, dow, doy)) {
+      resWeek = week - weeksInYear(year, dow, doy);
+      resYear = year + 1;
+  } else {
+      resYear = year;
+      resWeek = week;
+  }
+
+  return {
+      week: resWeek,
+      year: resYear
+  }
+}
+
+function dayOfYear(marker: DateMarker) { // TODO: use a day diff util?
+  return Math.round(
+    (
+      arrayToUtcDate([
+        marker.getUTCFullYear(),
+        marker.getUTCMonth(),
+        marker.getUTCDate()
+      ]).valueOf() -
+      arrayToUtcDate([
+        marker.getUTCFullYear(),
+        0,
+        1
+      ]).valueOf()
+    ) / 864e5
+  )
+}
+
+function weeksInYear(year, dow, doy) {
+  var weekOffset = firstWeekOffset(year, dow, doy),
+      weekOffsetNext = firstWeekOffset(year + 1, dow, doy);
+  return (daysInYear(year) - weekOffset + weekOffsetNext) / 7;
+}
+
+// start-of-first-week - start-of-year
+function firstWeekOffset(year, dow, doy) {
+  var // first-week day -- which january is always in the first week (4 for iso, 1 for other)
+      fwd = 7 + dow - doy,
+      // first-week day local weekday -- which local weekday is fwd
+      fwdlw = (7 + arrayToUtcDate([ year, 0, fwd ]).getUTCDay() - dow) % 7;
+  return -fwdlw + fwd - 1;
+}
+
+function daysInYear(year) {
+  return isLeapYear(year) ? 366 : 365;
+}
+
+function isLeapYear(year) {
+  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+}

+ 5 - 0
src/exports.ts

@@ -137,3 +137,8 @@ export { default as DayGrid } from './basic/DayGrid'
 export { default as BasicView } from './basic/BasicView'
 export { default as MonthView } from './basic/MonthView'
 export { default as ListView } from './list/ListView'
+
+export { DateEnv } from './datelib/env'
+export { createDuration, durationsEqual } from './datelib/duration'
+export { nowMarker } from './datelib/util' // might not need
+export { createFormatter } from './datelib/formatting'

+ 531 - 0
tests/automated/datelib/main.js

@@ -0,0 +1,531 @@
+
+describe('datelib', function() {
+  var DateEnv = FullCalendar.DateEnv
+  var createFormatter = FullCalendar.createFormatter
+  var createDuration = FullCalendar.createDuration
+
+  // todo: accept a date object, as well as ms timestamp
+
+  describe('when UTC', function() {
+    var env
+
+    beforeEach(function() {
+      env = new DateEnv({
+        timeZone: 'UTC',
+        calendarSystem: 'gregorian',
+        locale: 'en'
+      })
+    })
+
+    describe('ISO8601 parsing', function() {
+
+      it('parses non-tz as UTC', function() {
+        var res = env.parse('2018-06-08')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(Date.UTC(2018, 5, 8)))
+        expect(res.forcedTimeZoneOffset).toBeNull()
+      })
+
+      it('parses a date already in UTC', function() {
+        var res = env.parse('2018-06-08T00:00:00Z')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(Date.UTC(2018, 5, 8)))
+        expect(res.forcedTimeZoneOffset).toBeNull()
+      })
+
+      it('parses timezones into UTC', function() {
+        var res = env.parse('2018-06-08T00:00:00+12:00')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(Date.UTC(2018, 5, 7, 12)))
+        expect(res.forcedTimeZoneOffset).toBeNull()
+      })
+
+      it('detects lack of time', function() {
+        var res = env.parse('2018-06-08')
+        expect(res.hasTime).toBe(false)
+      })
+
+      it('detects presence of time', function() {
+        var res = env.parse('2018-06-08T00:00:00')
+        expect(res.hasTime).toBe(true)
+      })
+
+      it('detects presence of time even if timezone', function() {
+        var res = env.parse('2018-06-08T00:00:00+12:00')
+        expect(res.hasTime).toBe(true)
+      })
+
+    })
+
+    it('outputs ISO8601 formatting', function() {
+      var marker = env.parse('2018-06-08T00:00:00').marker
+      var s = env.toIso(marker)
+      expect(s).toBe('2018-06-08T00:00:00Z')
+    })
+
+    it('outputs pretty format with UTC timezone', function() {
+      var marker = env.parse('2018-06-08').marker
+      var formatter = createFormatter({
+        weekday: 'long',
+        day: 'numeric',
+        month: 'long',
+        year: 'numeric',
+        timeZoneName: 'short'
+      })
+      var s = env.toFormat(marker, formatter)
+      expect(s).toBe('Friday, June 8, 2018, UTC')
+    })
+
+
+    describe('range formatting', function() {
+      var formatter = createFormatter({
+        day: 'numeric',
+        month: 'long',
+        year: 'numeric'
+      })
+
+      it('works with different days of same month', function() {
+        var m0 = env.parse('2018-06-08').marker
+        var m1 = env.parse('2018-06-09').marker
+        var s = env.toRangeFormat(m0, m1, formatter)
+        expect(s).toBe('June 8 - 9, 2018')
+      })
+
+      it('works with different day/month of same year', function() {
+        var m0 = env.parse('2018-06-08').marker
+        var m1 = env.parse('2018-07-09').marker
+        var s = env.toRangeFormat(m0, m1, formatter)
+        expect(s).toBe('June 8 - July 9, 2018')
+      })
+
+      it('works with completely different dates', function() {
+        var m0 = env.parse('2018-06-08').marker
+        var m1 = env.parse('2020-07-09').marker
+        var s = env.toRangeFormat(m0, m1, formatter)
+        expect(s).toBe('June 8, 2018 - July 9, 2020')
+      })
+
+    })
+
+
+    // date math
+
+    describe('add', function() {
+
+      it('works with positives', function() {
+        var dur = createDuration({
+          year: 1,
+          month: 2,
+          day: 3,
+          hour: 4,
+          minute: 5,
+          second: 6,
+          ms: 7
+        })
+        var d0 = env.dateToMarker(new Date(Date.UTC(2018, 5, 5, 12)))
+        var d1 = env.toDate(env.add(d0, dur))
+        expect(d1).toEqual(
+          new Date(Date.UTC(2019, 7, 8, 16, 5, 6, 7))
+        )
+      })
+
+      it('works with negatives', function() {
+        var dur = createDuration({
+          year: -1,
+          month: -2,
+          day: -3,
+          hour: -4,
+          minute: -5,
+          second: -6,
+          millisecond: -7
+        })
+        var d0 = env.dateToMarker(new Date(Date.UTC(2018, 5, 5, 12)))
+        var d1 = env.toDate(env.add(d0, dur))
+        expect(d1).toEqual(
+          new Date(Date.UTC(2017, 3, 2, 7, 54, 53, 993))
+        )
+      })
+
+    })
+
+    it('startOfYear', function() {
+      var d0 = env.dateToMarker(new Date(Date.UTC(2018, 5, 5, 12)))
+      var d1 = env.toDate(env.startOfYear(d0))
+      expect(d1).toEqual(
+        new Date(Date.UTC(2018, 0, 1))
+      )
+    })
+
+    it('startOfMonth', function() {
+      var d0 = env.dateToMarker(new Date(Date.UTC(2018, 5, 5, 12)))
+      var d1 = env.toDate(env.startOfMonth(d0))
+      expect(d1).toEqual(
+        new Date(Date.UTC(2018, 5, 1))
+      )
+    })
+
+    //!!!!!!!!!!!!!
+    // TODO: test other locales
+    xit('startOfWeek', function() {
+      var d0 = env.dateToMarker(new Date(Date.UTC(2018, 5, 5, 12)))
+      var d1 = env.toDate(env.startOfWeek(d0))
+      expect(d1).toEqual(
+        new Date(Date.UTC(2018, 5, 3))
+      )
+    })
+
+    it('startOfDay', function() {
+      var d0 = env.dateToMarker(new Date(Date.UTC(2018, 5, 5, 12, 30)))
+      var d1 = env.toDate(env.startOfDay(d0))
+      expect(d1).toEqual(
+        new Date(Date.UTC(2018, 5, 5))
+      )
+    })
+
+    describe('diffWholeYears', function() {
+
+      it('returns null if not whole', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5, 12, 0))
+        var d1 = new Date(Date.UTC(2020, 5, 5, 12, 30))
+        var diff = env.diffWholeYears(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(null)
+      })
+
+      it('returns negative', function() {
+        var d0 = new Date(Date.UTC(2020, 5, 5, 12, 0))
+        var d1 = new Date(Date.UTC(2018, 5, 5, 12, 0))
+        var diff = env.diffWholeYears(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(-2)
+      })
+
+      it('returns positive', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5, 12, 0))
+        var d1 = new Date(Date.UTC(2020, 5, 5, 12, 0))
+        var diff = env.diffWholeYears(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(2)
+      })
+
+    })
+
+    describe('diffWholeMonths', function() {
+
+      it('returns null if not whole', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2020, 5, 6))
+        var diff = env.diffWholeMonths(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(null)
+      })
+
+      it('returns negative', function() {
+        var d0 = new Date(Date.UTC(2020, 9, 5))
+        var d1 = new Date(Date.UTC(2018, 5, 5))
+        var diff = env.diffWholeMonths(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(-12 * 2 - 4)
+      })
+
+      it('returns positive', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2020, 9, 5))
+        var diff = env.diffWholeMonths(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(12 * 2 + 4)
+      })
+
+    })
+
+    describe('diffWholeWeeks', function() {
+
+      it('returns null if not whole', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2018, 5, 20))
+        var diff = env.diffWholeWeeks(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(null)
+      })
+
+      it('returns negative', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 19))
+        var d1 = new Date(Date.UTC(2018, 5, 5))
+        var diff = env.diffWholeWeeks(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(-2)
+      })
+
+      it('returns positive', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2018, 5, 19))
+        var diff = env.diffWholeWeeks(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(2)
+      })
+
+    })
+
+    describe('diffWholeDays', function() {
+
+      it('returns null if not whole', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2018, 5, 19, 12))
+        var diff = env.diffWholeDays(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(null)
+      })
+
+      it('returns negative', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 19))
+        var d1 = new Date(Date.UTC(2018, 5, 5))
+        var diff = env.diffWholeDays(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(-14)
+      })
+
+      it('returns positive', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2018, 5, 19))
+        var diff = env.diffWholeDays(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toBe(14)
+      })
+
+    })
+
+    describe('diffDayAndTime', function() {
+
+      it('returns negative', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 19, 12))
+        var d1 = new Date(Date.UTC(2018, 5, 5))
+        var diff = env.diffDayAndTime(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toEqual({
+          year: 0,
+          month: 0,
+          day: -14,
+          time: -12 * 60 * 60 * 1000
+        })
+      })
+
+      it('returns positive', function() {
+        var d0 = new Date(Date.UTC(2018, 5, 5))
+        var d1 = new Date(Date.UTC(2018, 5, 19, 12))
+        var diff = env.diffDayAndTime(
+          env.dateToMarker(d0),
+          env.dateToMarker(d1)
+        )
+        expect(diff).toEqual({
+          year: 0,
+          month: 0,
+          day: 14,
+          time: 12 * 60 * 60 * 1000
+        })
+      })
+
+    })
+
+  })
+
+  describe('when local', function() {
+    var env
+
+    beforeEach(function() {
+      env = new DateEnv({
+        timeZone: 'local',
+        calendarSystem: 'gregorian',
+        locale: 'en'
+      })
+    })
+
+    describe('ISO8601 parsing', function() {
+
+      it('parses non-tz as local', function() {
+        var res = env.parse('2018-06-08')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(2018, 5, 8))
+        expect(res.forcedTimeZoneOffset).toBeNull()
+      })
+
+      it('parses timezones into local', function() {
+        var res = env.parse('2018-06-08T00:00:00+12:00')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(Date.UTC(2018, 5, 7, 12)))
+        expect(res.forcedTimeZoneOffset).toBeNull()
+      })
+
+    })
+
+    it('outputs ISO8601 formatting', function() {
+      var marker = env.parse('2018-06-08T00:00:00').marker
+      var s = env.toIso(marker)
+      var realTzo = getFormattedTimzoneOffset(new Date(2018, 5, 8))
+      expect(s).toBe('2018-06-08T00:00:00' + realTzo)
+    })
+
+    it('outputs pretty format with local timezone', function() {
+      var marker = env.parse('2018-06-08').marker
+      var formatter = createFormatter({
+        weekday: 'long',
+        day: 'numeric',
+        month: 'long',
+        year: 'numeric',
+        timeZoneName: 'short'
+      })
+      var s = env.toFormat(marker, formatter)
+      expect(s).toBe('Friday, June 8, 2018, ' + getFormattedTimzoneOffset2(new Date(2018, 5, 8)))
+    })
+
+
+    // because `new Date(year)` is error-prone
+    it('startOfYear', function() {
+      var d0 = env.dateToMarker(new Date(2018, 5, 5, 12))
+      var d1 = env.toDate(env.startOfYear(d0))
+      expect(d1).toEqual(
+        new Date(2018, 0, 1)
+      )
+    })
+
+  })
+
+  describe('when named timezone with coercion', function() {
+    var env
+
+    beforeEach(function() {
+      env = new DateEnv({
+        timeZone: 'America/Chicago',
+        calendarSystem: 'gregorian',
+        locale: 'en'
+      })
+    })
+
+    describe('ISO8601 parsing', function() {
+
+      it('parses non-tz as UTC with no forcedTimeZoneOffset', function() {
+        var res = env.parse('2018-06-08')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(Date.UTC(2018, 5, 8)))
+        expect(res.forcedTimeZoneOffset).toBeNull()
+      })
+
+      it('parses as UTC after stripping and with a forcedTimeZoneOffset', function() {
+        var res = env.parse('2018-06-08T00:00:00+12:00')
+        var date = env.toDate(res.marker)
+        expect(date).toEqual(new Date(Date.UTC(2018, 5, 8)))
+        expect(res.forcedTimeZoneOffset).toBe(12 * 60)
+      })
+
+    })
+
+    it('outputs pretty format with no timezone even tho specified', function() {
+      var marker = env.parse('2018-06-08').marker
+      var formatter = createFormatter({
+        weekday: 'long',
+        day: 'numeric',
+        month: 'long',
+        year: 'numeric',
+        timeZoneName: 'short'
+      })
+      var s = env.toFormat(marker, formatter)
+      expect(s).toBe('Friday, June 8, 2018')
+    })
+
+    // TODO: when trying to do 'long' timezone
+
+  })
+
+  describe('duration parsing', function() {
+
+    it('accepts whole day in string', function() {
+      var dur = createDuration('2.00:00:00')
+      expect(dur).toEqual({
+        year: 0,
+        month: 0,
+        day: 2,
+        time: 0
+      })
+    })
+
+    it('accepts hours, minutes, seconds, and milliseconds', function() {
+      var dur = createDuration('01:02:03.500')
+      expect(dur).toEqual({
+        year: 0,
+        month: 0,
+        day: 0,
+        time:
+          1 * 60 * 60 * 1000 +
+          2 * 60 * 1000 +
+          3 * 1000 +
+          500
+      })
+    })
+
+    it('accepts just hours and minutes', function() {
+      var dur = createDuration('01:02')
+      expect(dur).toEqual({
+        year: 0,
+        month: 0,
+        day: 0,
+        time:
+          1 * 60 * 60 * 1000 +
+          2 * 60 * 1000
+      })
+    })
+
+  })
+
+
+  // utils
+
+  function getFormattedTimzoneOffset(date) {
+    let minutes = date.getTimezoneOffset()
+    let sign = minutes < 0 ? '+' : '-' // whaaa
+    let abs = Math.abs(minutes)
+    let hours = Math.floor(abs / 60)
+    let mins = Math.round(abs % 60)
+
+    return sign + pad(hours) + ':' + pad(mins)
+  }
+
+  function getFormattedTimzoneOffset2(date) {
+    let minutes = date.getTimezoneOffset()
+    let sign = minutes < 0 ? '+' : '-' // whaaa
+    let abs = Math.abs(minutes)
+    let hours = Math.floor(abs / 60)
+    let mins = Math.round(abs % 60)
+
+    return 'GMT' + sign + hours + (mins ? ':' + pad(mins) : '')
+  }
+
+  function pad(n) {
+    return n < 10 ? '0' + n : '' + n
+  }
+
+})