<file_sep>// Constant used in the /data/config.json

// Versions
export const V_APP = 'app'
export const V_HUNTS = 'hunts'
export const V_SIDE_QUESTS = 'side-quests'
export const V_HUNTER_RANKS = 'hunter-ranks'<file_sep>import { SYSTEM_INIT, SYSTEM_CONFIG_RESPONSE, SYSTEM_VERSION_UPDATED } from '../constants/ActionTypes'
import { updateVersion, fetchHunts, fetchSideQuests } from '../actions/system'
import { V_APP, V_HUNTS, V_SIDE_QUESTS, V_HUNTER_RANKS } from '../constants/Config'

const initialState = {

    // Start at 0, that way we can be sure to load a more recent one (in localstorage)
    versions: {
        [V_APP]: 0,
        [V_HUNTER_RANKS]: 0,
        [V_SIDE_QUESTS]: 0,
        [V_HUNTS]: 0
    },
    loading: false,
    error: undefined
}

const ACTIONS = {
    [V_SIDE_QUESTS]: fetchSideQuests,
    [V_HUNTS]: fetchHunts,
    [V_APP]: (v) => updateVersion('app', v)
}

export default function system(state = initialState, action) {
    switch(action.type) {
        case SYSTEM_INIT:

            return Object.assign({}, state, {loading: true})

        case SYSTEM_CONFIG_RESPONSE:

            if(action.success) {
                const versions = action.json.versions
                Object.keys(action.json.versions).forEach(key => {
                    if (versions[key] > state.versions[key]) {
                        if(ACTIONS[key] !== undefined) {
                            action.asyncDispatch(ACTIONS[key](versions[key]))
                        }
                    }
                })

                return Object.assign({}, state, {loading: false})
            } else {
                return Object.assign({}, state, {loading: false, error: action.err})
            }

        case SYSTEM_VERSION_UPDATED:
            const versions = Object.assign({}, state.versions, { [action.key]: action.version })
            console.log("SYSTEM_VERSION_UPDATED", action, versions)
            return Object.assign({}, state, { versions })

        default:
            return state
    }
}<file_sep>import React, { Component, PropTypes } from "react"
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'

import CompletableListView from '../components/CompletableListView'
import ListHeader from '../components/ListHeader'

import * as QuestsActions from '../actions/quests'
import * as NavActions from '../actions/navigation'

class QuestList extends Component {

    static propTypes = {
        items: PropTypes.array.isRequired,
        filter: PropTypes.string.isRequired,
        sort: PropTypes.string.isRequired,
        actions: PropTypes.object.isRequired // TODO specify content of 
    }

    handleQuestCompletion(id) {
        this.props.actions.completeQuest(id)
    }

    render() {
        const { filter, sort, items, nav, actions} = this.props

        const completedItemsCount = items.reduce((count, item) =>
            item.completed ? count + 1 : count,
            0
        )
        const activeItemCount = items.length - completedItemsCount

        return (
            <div>
                <ListHeader 
                    subtitle="Quests"
                    onMenuClick={nav.openDrawer}
                    onShow={actions.filterQuests.bind(this)}
                    onSort={actions.sortQuests.bind(this)}
                    completedCount={completedItemsCount}
                    activeCount={activeItemCount}
                    activeFilter={filter}
                    activeSort={sort}
                    />
                <CompletableListView items={items}
                        onItemCompletion={this.handleQuestCompletion.bind(this)}
                        filter={filter} 
                        sort={sort} />
            </div>
        )
    }
}

function mapStateToProps(state) {
    return state.quests
}

function mapDispatchToProps(dispatch) {
    return {
        actions: bindActionCreators(QuestsActions, dispatch),
        nav: bindActionCreators(NavActions, dispatch)
    }
}

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(QuestList)
<file_sep>import React, { Component, PropTypes } from "react"
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as NavActions from '../actions/navigation'

import {List, ListItem} from 'material-ui/List';
import Subheader from 'material-ui/Subheader';
import Divider from 'material-ui/Divider';
import Checkbox from 'material-ui/Checkbox';
import Toggle from 'material-ui/Toggle';
import AppBar from 'material-ui/AppBar'

class Settings extends Component {
    static propTypes = {
        versions: PropTypes.object.isRequired,
        openDrawer: PropTypes.func.isRequired
    }

    render() {
        const { versions } = this.props
        console.log(versions)

        const versionsItem = Object.keys(versions).map((kind) => {

            return (<ListItem
                    key={kind}
                    primaryText={kind}
                    disabled={true}
                    secondaryText={`Version ${versions[kind]}`}
                    />)
        })

        return (
            <div>
                <header className="header">
                    <AppBar 
                        title="FFXV Tracking - Settings"
                        onLeftIconButtonTouchTap={this.props.openDrawer} />
                </header>
                <List>
                    <Subheader>Versions</Subheader>
                    {versionsItem}
                </List>
                <Divider />
            </div>
        )
    }
}

function mapStateToProps(state) {
    return state.system
}

function mapDispatchToProps(dispatch) {
    return {
        openDrawer: bindActionCreators(NavActions, dispatch).openDrawer
    }
}

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Settings)
<file_sep>import * as types from '../constants/ActionTypes';

// Complete toggle and Reset

export function completeHunt(id) {
  return { type: types.COMPLETE_HUNT, id };
}

// UI

export function sortHunts(sort) {
  return { type: types.HUNT_SORT, sort };
}

export function filterHunts(filter) {
  return { type: types.HUNT_FILTER, filter };
}

// Network

export function requestHunts() {
  return { type: types.REQUEST_HUNTS }
}

function slugify(text) {
  return text.toString().toLowerCase()
    .replace(/\s+/g, '-')           // Replace spaces with -
    .replace(/[^\w\-]+/g, '')       // Remove all non-word chars
    .replace(/\-\-+/g, '-')         // Replace multiple - with single -
    .replace(/^-+/, '')             // Trim - from start of text
    .replace(/-+$/, '');            // Trim - from end of text
}

export function receiveHunts(status, json, version) {
  const action = { type: types.RESPONSE_HUNTS, status, receivedAt: Date.now(), version }

  if(status === "success") {
    return Object.assign(action, {hunts: json.map(e => Object.assign(e, {id: slugify(e.name)}))})
  } else {
    return Object.assign(action, {error: json})
  }
}
<file_sep>import Perf from 'react-addons-perf'
import merge from 'lodash/merge'
import find from 'lodash/find'

import { COMPLETE_QUEST, QUEST_SORT, QUEST_FILTER, RESPONSE_SIDE_QUESTS } from '../constants/ActionTypes'
import { SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED } from '../constants/Filters'
import { SORT_NAME, SORT_REGION, SORT_LEVEL, SORT_LOCATION } from '../constants/Filters'
import { V_SIDE_QUESTS } from '../constants/Config'
import { updateVersion } from '../actions/system'

// TODO Have an object with {quests, filter, sort}
const initialState = {
  items: [],
  filter: SHOW_ALL,
  sort: SORT_NAME
};

let lastLocPayload = {pathname: 'none'}

export default function quests(state = initialState, action) {
  switch (action.type) {
  case RESPONSE_SIDE_QUESTS:
    if(action.status === 'success') {
      const items = action.quests.map((item) => {
        return merge({}, find(state.items, {'id': item.id}), item)
      })

      action.asyncDispatch(updateVersion(V_SIDE_QUESTS, action.version))
      return Object.assign({}, state, { items, error: undefined });
    } else {
      return Object.assign({}, state, {error: action.error});
    }

  case COMPLETE_QUEST:
    const completedQuests = state.items.map(quest =>
      quest.id === action.id
          ? Object.assign({}, quest, { completed: !quest.completed })
          : quest
    );

    return Object.assign({}, state, {items: completedQuests});

  case QUEST_FILTER:
    return Object.assign({}, state, {filter: action.filter})

  case QUEST_SORT:
    return Object.assign({}, state, {sort: action.sort})

  case '@@router/LOCATION_CHANGE':
  /*
    const locPayload = action.payload
    console.log(`Location changed. Printing performance from ${lastLocPayload.pathname} to ${locPayload.pathname}.`)
    Perf.stop()
    
    const measurements = Perf.getLastMeasurements()
    Perf.printInclusive(measurements)
    Perf.printExclusive(measurements)
    Perf.printWasted(measurements)

    console.log(`Collecting performance from ${locPayload.pathname} to next location change.`)
    Perf.start()
    */

  default:
    return state;
  }
}
<file_sep>import React, { Component, PropTypes } from "react"
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'

import CompletableListView from '../components/CompletableListView'
import ListHeader from '../components/ListHeader'

import * as HuntsActions from '../actions/hunts'
import * as NavActions from '../actions/navigation'

class HuntList extends Component {

    static propTypes = {
        items: PropTypes.array.isRequired,
        filter: PropTypes.string.isRequired,
        sort: PropTypes.string.isRequired,
        actions: PropTypes.object.isRequired // TODO specify content of 
    }

    handleHuntCompletion(id) {
        this.props.actions.completeHunt(id)
    }

    render() {
        const { filter, sort, items, nav, actions } = this.props

        const completedItemsCount = items.reduce((count, item) =>
            item.completed ? count + 1 : count,
            0
        )
        const activeItemCount = items.length - completedItemsCount

        return (
            <div>
                <ListHeader 
                    subtitle="Hunts"
                    onMenuClick={nav.openDrawer}
                    onShow={actions.filterHunts.bind(this)}
                    onSort={actions.sortHunts.bind(this)}
                    completedCount={completedItemsCount}
                    activeCount={activeItemCount}
                    activeFilter={filter}
                    activeSort={sort}
                    />
                <CompletableListView items={items}
                        onItemCompletion={this.handleHuntCompletion.bind(this)}
                        filter={filter} 
                        sort={sort} />
            </div>
        )
    }
}

function mapStateToProps(state) {
    return state.hunts
}

function mapDispatchToProps(dispatch) {
    return {
        actions: bindActionCreators(HuntsActions, dispatch),
        nav: bindActionCreators(NavActions, dispatch)
    }
}

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(HuntList)
<file_sep>import { routerMiddleware } from 'react-router-redux'
import { hashHistory } from 'react-router'
import thunkMiddleware from 'redux-thunk'
import asyncDispatchMiddleware from './asyncDispatchMiddleware'


export const router = routerMiddleware(hashHistory)
export const thunk = thunkMiddleware
export const asyncDispatch = asyncDispatchMiddleware<file_sep>import * as types from '../constants/ActionTypes';

// Complete toggle and Reset

export function completeQuest(id) {
  return { type: types.COMPLETE_QUEST, id };
}

// UI

export function sortQuests(sort) {
  return { type: types.QUEST_SORT, sort };
}

export function filterQuests(filter) {
  return { type: types.QUEST_FILTER, filter };
}

// Network

export function requestSideQuests() {
  return { type: types.REQUEST_SIDE_QUESTS }
}

function slugify(text) {
  return text.toString().toLowerCase()
    .replace(/\s+/g, '-')           // Replace spaces with -
    .replace(/[^\w\-]+/g, '')       // Remove all non-word chars
    .replace(/\-\-+/g, '-')         // Replace multiple - with single -
    .replace(/^-+/, '')             // Trim - from start of text
    .replace(/-+$/, '');            // Trim - from end of text
}

export const receiveSideQuests = (status, json, version) => (dispatch) => {
  const action = { type: types.RESPONSE_SIDE_QUESTS, status, receivedAt: Date.now(), version }

  if(status === "success") {
    dispatch(Object.assign(action, {quests: json.map(e => Object.assign(e, {id: slugify(e.name)}))}))
  } else {
    dispatch(Object.assign(action, {error: json}))
  }
}
<file_sep>import React, { PropTypes, Component } from 'react'
import IconMenu from 'material-ui/IconMenu'
import IconButton from 'material-ui/IconButton'
import FontIcon from 'material-ui/FontIcon'
import MenuItem from 'material-ui/MenuItem'
import DropDownMenu from 'material-ui/DropDownMenu'
import { Toolbar, ToolbarGroup, ToolbarSeparator, ToolbarTitle } from 'material-ui/Toolbar'

import InboxIcon from 'material-ui/svg-icons/content/inbox'
import LoopIcon from 'material-ui/svg-icons/av/loop'
import SortByAlphaIcon from 'material-ui/svg-icons/av/sort-by-alpha'
import ArchiveIcon from 'material-ui/svg-icons/content/archive'
import FilterIcon from 'material-ui/svg-icons/content/filter-list'
import SortIcon from 'material-ui/svg-icons/content/sort'

import AppBar from 'material-ui/AppBar'

import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/Filters'
import { SORT_NAME, SORT_LEVEL, SORT_REGION, SORT_LOCATION } from '../constants/Filters'

const defaultStyle = {
  marginLeft: 20
}

const FILTER_TITLES = {
  [SHOW_ALL]: 'All',
  [SHOW_ACTIVE]: 'Active',
  [SHOW_COMPLETED]: 'Completed'
}

const FILTER_ICONS = {
  [SHOW_ALL]: <InboxIcon />,
  [SHOW_ACTIVE]: <LoopIcon />,
  [SHOW_COMPLETED]: <ArchiveIcon />
}

const SORT_TITLES = {
  [SORT_NAME]: 'By Name',
  [SORT_LEVEL]: 'By Level',
  [SORT_REGION]: 'By Region',
  [SORT_LOCATION]: 'By Location'
}

const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1)

class Header extends Component {

  static propTypes = {
    onMenuClick: PropTypes.func.isRequired,
    subtitle: PropTypes.string.isRequired,

    onSort: PropTypes.func.isRequired,
    onShow: PropTypes.func.isRequired,

    completedCount: PropTypes.number.isRequired,
    activeCount: PropTypes.number.isRequired,

    activeFilter: PropTypes.string.isRequired,
    activeSort: PropTypes.string.isRequired
  }

  countForFilter(filter) {
    const { activeCount, completedCount } = this.props

    if (filter === SHOW_ALL) return activeCount + completedCount
    if (filter === SHOW_ACTIVE) return activeCount
    if (filter === SHOW_COMPLETED) return completedCount
  }

  renderFilterLink(filter) {
    const { onShow, activeFilter } = this.props
    const count = this.countForFilter(filter)
    const title = FILTER_TITLES[filter] + (count > 0 ? ' (' +  count + ')' : '')
    const style = (activeFilter === filter) ? {fontWeight: 'bold'} : {}

    return (
      <MenuItem key={filter}
                style={style}
                focusState={'keyboard-focused'}
                primaryText={`Show ${title}`}
                leftIcon={FILTER_ICONS[filter]}
                onTouchTap={() => onShow(filter)}
      />
    )
  }

  renderSortLink(sort) {
    const { onSort, activeSort } = this.props
    const style = (activeSort === sort) ? {fontWeight: 'bold'} : {}

    return (
      <MenuItem key={sort} style={style} primaryText={SORT_TITLES[sort]} onTouchTap={() => onSort(sort)} />
    )
  }

  render() {
    const {onSort, onShow, subtitle} = this.props

    const sortButton = (
      <IconButton touch={true}>
          <SortIcon />
      </IconButton>
    )

    const moreButton = (
      <IconButton touch={true}>
          <FilterIcon />
      </IconButton>
    )

    return (
      <header className="header">
        <AppBar title={`FFXV Tracking - ${capitalize(subtitle)}`} onLeftIconButtonTouchTap={this.props.onMenuClick}>
          <ToolbarGroup>
            <IconMenu iconButtonElement={sortButton}>
              {[SORT_NAME, SORT_LEVEL, SORT_REGION, SORT_LOCATION].map(sort =>
                this.renderSortLink(sort)
              )}
            </IconMenu>
            <IconMenu iconButtonElement={moreButton} >
              {[SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED].map(filter =>
                this.renderFilterLink(filter)
              )}
            </IconMenu>
          </ToolbarGroup>
        </AppBar>
      </header>
    )
  }
}

export default Header
<file_sep>import { createStore, applyMiddleware, compose } from 'redux';
import { routerMiddleware } from 'react-router-redux'
import { hashHistory } from 'react-router'
import persistState from 'redux-localstorage'
import rootReducer from '../reducers';
import * as middlewares from '../middleware';

export default function configureStore(initialState) {

  const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

  const store = createStore(
    rootReducer,
    initialState,
    composeEnhancers(
      applyMiddleware(...Object.values(middlewares)),
      persistState()
    )
  );

  if (module.hot) {
    // Enable Webpack hot module replacement for reducers
    module.hot.accept('../reducers', () => {
      const nextReducer = require('../reducers');
      store.replaceReducer(nextReducer);
    });
  }

  return store;
}
<file_sep>export const SHOW_ALL = 'show_all'
export const SHOW_COMPLETED = 'show_completed'
export const SHOW_ACTIVE = 'show_active'

export const SORT_NAME = 'order_name'
export const SORT_LEVEL = 'order_level'
export const SORT_REGION = 'order_chapter'
export const SORT_LOCATION = 'order_location'<file_sep>import { combineReducers } from 'redux'
import { routerReducer } from 'react-router-redux'
import system from './system'
import quests from './quests'
import drawer from './drawer'
import hunts from './hunts'

const rootReducer = combineReducers({
    system,
    quests,
    drawer,
    hunts,
    routing: routerReducer
})

export default rootReducer
<file_sep>const fs = require('fs')
const config = require('../data/config.json')
const merge = require('lodash/merge')

const newAppVersion = config.versions.app + 1

const newConfig = merge(config, {
    versions: {
        app: newAppVersion
    }
})

fs.writeFileSync('./data/config.json', JSON.stringify(newConfig))
fs.writeFileSync('./VERSION', newAppVersion)<file_sep>const fs = require('fs')
const cheerio = require('cheerio')
const _ = require('lodash')

// Selector to the main section
const ghContentSelector = 'body > div.shell.container-content-main > div.gh-shell > div.container_24.clear > div.grid_16.gh-content > div.grid_12.push_4.alpha.omega.bodyCopy.gh-content'

function getRewards(td) {
    return td.contents()
        .filter(function(){
            return this.nodeType == 3
        })
        .map(function(){ 
            return this.data 
        })
        .toArray()
        .map((e) => e.trim().replace(',', ''))
        .filter((s) => s.length > 0)
}

function parseIGNWikiPage(file, regionSuffixToReplace, parseRow, arrToQuestObject) {
    const $ = cheerio.load(file)
    const contentNode = $(ghContentSelector)

    return $('h3.gh-sectionHeader', contentNode).toArray().map((element, index) => {
        const e = $(element)

        const region = e.contents()
            .filter(function () { 
                return !(this.name === "a" && this.attribs.hasOwnProperty('class'))
            }).text()
            .replace(regionSuffixToReplace + 's', '')
            .replace(regionSuffixToReplace, '')

        const table = e.next().next()

        const data = $('tr', table).toArray()
            .map((rawTr, i) => {
                // Ignore Headers
                if(i === 0) {
                    return undefined
                }
                const tr = $(rawTr) 
                const tds = $('td', tr).toArray()

                return parseRow($, tds)
            })
            .filter((e) => e != undefined)
            .map(arrToQuestObject)

        return {region, data}
    }).reduce((acc, val) => {
        return acc.concat(val.data.map((e) => Object.assign(e, {region: val.region})))
    }, [])
}


//
// Side Quests functions
//

function parseQuestRow($, tds) {
    const isCid = tds.length === 2
    return tds.map((rawTd, i) => {
        if(isCid) {
            if(i == 0) {
                return [
                    $(rawTd).text().trim(),
                    _.find(rawTd.children, (c) => c.name === 'a').attribs.href
                ]
            } else if(i == 1) {
                return getRewards($(rawTd))
            } else {
                return $(rawTd).text().trim()
            }
        } else {
            if(i == 0) {
                const a = _.find(rawTd.children, (c) => c.name === 'a')
                return [
                    $(rawTd).text().trim(),
                    (a != undefined) ? a.attribs.href : undefined
                ]
            } else if(i == 3) {
                const td = $(rawTd)

                return getRewards(td)
            } else {
                return $(rawTd).text().trim()
            }
        }
    })
}

// TODO Match 'Chapter' and 'Ch'
const chapterRegex = /(.*)\(Chapter\ (\d{1,2})\)/

function arrToQuestObject(arr) {
    // Cid
    if(arr.length === 2) {
        return {
            name: arr[0][0],
            link: arr[0][1],
            rewards: arr[1]
        }
    }

    // Side quests
    const result = chapterRegex.exec(arr[2])
    let location, chapter
    if(result === null || result.length !== 3) {
        location = arr[2]
    } else {
        location = result[1]
        chapter = parseInt(result[2], 10)
    }
    return {
        name: arr[0][0],
        link: arr[0][1],
        level: parseInt(arr[1], 10),
        chapter, location,
        rewards: arr[3]
    }
}

//
// Hunts functions
//

function parseHuntsRow($, tds) {
    const isAltissia = tds.length === 5;
    return tds.map((rawTd, i) => {
        if(isAltissia) {
            if(i === 4) {
             return getRewards($(rawTd))
            } else {
                return $(rawTd).text().trim()
            }
        } else {
            if(i === 3 || i === 5) {
                return getRewards($(rawTd))
            } else {
                return $(rawTd).text().trim()
            }
        }
    })
}

function arrToHuntObject(arr) {
    // Altissia
    if(arr.length === 5) {
        return {
            name: arr[0],
            level: parseInt(arr[1], 10),
            location: arr[2],
            enemy: arr[3],
            rewards: arr[4]
        }
    }

    // Regular hunts
    
    // TODO Regex on habitat to extract time 
    // TODO Regex on each enemy to extract number
    // TODO Regex on Location for (region, location, hunter rank)
    return {
        name: arr[0],
        level: arr[1],
        location: arr[2],
        enemy: arr[3],
        habitat: arr[4],
        rewards: arr[5]
    }
} 

//
// Effectively parsing files
//

const sideQuests = parseIGNWikiPage(
    fs.readFileSync('./fixtures/side-quests.html').toString(),
    ' Side Quest', parseQuestRow, arrToQuestObject
)
const hunts = parseIGNWikiPage(
    fs.readFileSync('./fixtures/bounty-hunts.html').toString(),
    ' Bounty Hunts', parseHuntsRow, arrToHuntObject
)

fs.writeFileSync('./data/side-quests.json', JSON.stringify(sideQuests))
fs.writeFileSync('./data/hunts.json', JSON.stringify(hunts))<file_sep>import {OPEN_DRAWER, CLOSE_DRAWER, OPEN_PAGE} from '../constants/ActionTypes';

const initialState = {
    open: false
};

export default function drawer(state = initialState, action) {
    switch(action.type) {
        case OPEN_DRAWER:
            return {
                open: true
            };

        case CLOSE_DRAWER:
        case OPEN_PAGE:
            return {
                open: false
            };

        default:
        return state;
    }
}<file_sep>// Side Quests
export const COMPLETE_QUEST = 'COMPLETE_QUEST'
export const QUEST_SORT = 'QUEST_SORT'
export const QUEST_FILTER = 'QUEST_FILTER'
export const REQUEST_SIDE_QUESTS = 'REQUEST_SIDE_QUESTS'
export const RESPONSE_SIDE_QUESTS = 'RESPONSE_SIDE_QUESTS'

// Hunts
export const COMPLETE_HUNT = 'COMPLETE_HUNT'
export const HUNT_SORT = 'HUNT_SORT'
export const HUNT_FILTER = 'HUNT_FILTER'
export const REQUEST_HUNTS = 'REQUEST_HUNTS'
export const RESPONSE_HUNTS = 'RESPONSE_HUNTS'

// Drawer
export const OPEN_DRAWER = 'OPEN_DRAWER'
export const CLOSE_DRAWER = 'CLOSE_DRAWER'

// Navigation
export const OPEN_PAGE = 'OPEN_PAGE'

// Initialisation
export const SYSTEM_INIT = 'SYSTEM_INIT'
export const SYSTEM_CONFIG_RESPONSE = 'SYSTEM_CONFIG_RESPONSE'
export const SYSTEM_VERSION_UPDATED = 'SYSTEM_VERSION_UPDATED'