From cad75217f096faaa65951c23b135280cd31df888 Mon Sep 17 00:00:00 2001
From: Matthew Taylor <mewtaylor@gmail.com>
Date: Thu, 19 May 2016 11:34:59 -0400
Subject: [PATCH] Add schedule/detail components for conference

---
 .../navigation/conference/navigation.jsx      |   3 +
 src/redux/conference-details.js               |  80 +++++++++
 src/redux/conference-schedule.js              | 159 ++++++++++++++++++
 src/redux/reducer.js                          |   5 +
 src/routes.json                               |  14 ++
 src/views/conference/details/details.jsx      |  88 ++++++++++
 src/views/conference/details/details.scss     |  29 ++++
 src/views/conference/index/index.jsx          |   8 +-
 src/views/conference/schedule/schedule.jsx    | 122 ++++++++++++++
 src/views/conference/schedule/schedule.scss   |  94 +++++++++++
 10 files changed, 599 insertions(+), 3 deletions(-)
 create mode 100644 src/redux/conference-details.js
 create mode 100644 src/redux/conference-schedule.js
 create mode 100644 src/views/conference/details/details.jsx
 create mode 100644 src/views/conference/details/details.scss
 create mode 100644 src/views/conference/schedule/schedule.jsx
 create mode 100644 src/views/conference/schedule/schedule.scss

diff --git a/src/components/navigation/conference/navigation.jsx b/src/components/navigation/conference/navigation.jsx
index 68b5831c6..72ab5ff8b 100644
--- a/src/components/navigation/conference/navigation.jsx
+++ b/src/components/navigation/conference/navigation.jsx
@@ -24,6 +24,9 @@ var Navigation = React.createClass({
                             <li className="link plan">
                                 <a href="/conference/plan">Plan Your Visit</a>
                             </li>
+                            <li className="link schedule">
+                                <a href="/conference/schedule">Schedule</a>
+                            </li>
                         </ul>
                     </li>
                 </ul>
diff --git a/src/redux/conference-details.js b/src/redux/conference-details.js
new file mode 100644
index 000000000..dece82fdd
--- /dev/null
+++ b/src/redux/conference-details.js
@@ -0,0 +1,80 @@
+var keyMirror = require('keymirror');
+var api = require('../mixins/api.jsx').api;
+
+var Types = keyMirror({
+    SET_DETAILS: null,
+    SET_DETAILS_FETCHING: null,
+    SET_DETAILS_ERROR: null
+});
+
+module.exports.detailsReducer = function (state, action) {
+    if (typeof state === 'undefined') {
+        state = {};
+    }
+    switch (action.type) {
+    case Types.SET_DETAILS:
+        return action.details;
+    case Types.SET_DETAILS_FETCHING:
+        return {fetching: action.fetching};
+    case Types.SET_DETAILS_ERROR:
+        return {error: action.error};
+    default:
+        return state;
+    }
+};
+
+module.exports.setDetailsError = function (error) {
+    return {
+        type: Types.SET_DETAILS_ERROR,
+        error: error
+    };
+};
+
+module.exports.setDetails = function (details) {
+    return {
+        type: Types.SET_DETAILS,
+        details: details
+    };
+};
+
+module.exports.setDetailsFetching = function () {
+    return {
+        type: Types.SET_DETAILS_FETCHING,
+        fetching: true
+    };
+};
+
+module.exports.startGetDetails = function (id) {
+    return function (dispatch) {
+        dispatch(module.exports.setDetailsFetching());
+        dispatch(module.exports.getDetails(id));
+    };
+};
+
+module.exports.getDetails = function (id) {
+    return function (dispatch) {
+        api({
+            uri: '/conference/' + id + '/details'
+        }, function (err, body) {
+            if (err) {
+                dispatch(module.exports.setDetailsError(err));
+                return;
+            }
+
+            if (typeof body !== 'undefined') {
+                var columns = body.columns;
+                if (body.rows) {
+                    var details = body.rows[0];
+                    var detailsObject = details.reduce(function (prev, cur, index) {
+                        prev[columns[index]] = cur;
+                        return prev;
+                    }, {});
+                    dispatch(module.exports.setDetails(detailsObject));
+                } else {
+                    dispatch(module.exports.setDetailsError('Not Found'));
+                }
+                return;
+            }
+        });
+    };
+};
diff --git a/src/redux/conference-schedule.js b/src/redux/conference-schedule.js
new file mode 100644
index 000000000..0877e9a57
--- /dev/null
+++ b/src/redux/conference-schedule.js
@@ -0,0 +1,159 @@
+var keyMirror = require('keymirror');
+var api = require('../mixins/api.jsx').api;
+
+var Types = keyMirror({
+    SET_DAY: null,
+    SET_SCHEDULE: null,
+    SET_SCHEDULE_FETCHING: null,
+    SET_DAY_ERROR: null,
+    SET_SCHEDULE_ERROR: null
+});
+
+module.exports.dayReducer = function (state, action) {
+    if (typeof state === 'undefined') {
+        state = '';
+    }
+    switch (action.type) {
+    case Types.SET_DAY:
+        return action.day;
+    case Types.SET_DAY_ERROR:
+        return state;
+    default:
+        return state;
+    }
+};
+
+module.exports.scheduleReducer = function (state, action) {
+    if (typeof state === 'undefined') {
+        state = [];
+    }
+    switch (action.type) {
+    case Types.SET_SCHEDULE:
+        return action.schedule;
+    case Types.SET_SCHEDULE_FETCHING:
+        return state;
+    case Types.SET_SCHEDULE_ERROR:
+        return state;
+    default:
+        return state;
+    }
+};
+
+module.exports.setDayError = function (error) {
+    return {
+        type: Types.SET_DAY_ERROR,
+        error: error
+    };
+};
+
+module.exports.setDay = function (day) {
+    return {
+        type: Types.SET_DAY,
+        day: day
+    };
+};
+
+module.exports.setSchedule = function (schedule) {
+    return {
+        type: Types.SET_SCHEDULE,
+        schedule: schedule
+    };
+};
+
+module.exports.setScheduleFetching = function () {
+    return {
+        type: Types.SET_SCHEDULE_FETCHING,
+        fetching: true
+    };
+};
+
+module.exports.setScheduleError = function (error) {
+    return {
+        type: Types.SET_SCHEDULE_ERROR,
+        error: error
+    };
+};
+
+module.exports.startGetSchedule = function (day) {
+    return function (dispatch) {
+        dispatch(module.exports.setScheduleFetching());
+        dispatch(module.exports.getDaySchedule(day));
+    };
+};
+
+/**
+ * Gets the schedule for the given day from the api
+ * @param  {String} day  Day of the conference (Thursday, Friday or Satrurday)
+ *
+ *  @return {Object}     Schedule for the day, broken into chunks
+ */
+module.exports.getDaySchedule = function (day) {
+    return function (dispatch) {
+        api({
+            uri: '/conference/schedule/' + day
+        }, function (err, body) {
+            if (err) {
+                dispatch(module.exports.setDayError(err));
+                dispatch(module.exports.setScheduleError(err));
+                return;
+            }
+
+            if (typeof body !== 'undefined') {
+                var columns = body.columns;
+                var rows = body.rows || [];
+                // Group events by the time period in which they occur (for presentation)
+                var scheduleByChunk = rows.reduce(function (prev, cur) {
+                    var cleanedRow = {};
+                    for (var i = 0; i < columns.length; i++) {
+                        if (cur[i].length > 0) {
+                            cleanedRow[columns[i]] = cur[i];
+                        }
+                    }
+                    cleanedRow['uri'] = '/conference/' + cleanedRow.rowid + '/details';
+                    var chunk = cleanedRow.Chunk;
+                    if (typeof prev.chunks[chunk] === 'undefined') {
+                        prev.chunks[chunk] = [cleanedRow];
+                        prev.info.push({
+                            name: chunk,
+                            time: cleanedRow.Start
+                        });
+                    } else {
+                        prev.chunks[chunk].push(cleanedRow);
+                    }
+                    return prev;
+                }, {chunks: [], info: []});
+                // group periods of time by start time
+                scheduleByChunk.info.sort(function compare (a, b) {
+                    var aAm = (a.time.substr(a.time.length - 1, a.time.length) === 'a') ? true : false;
+                    var bAm = (b.time.substr(b.time.length - 1, b.time.length) === 'a') ? true : false;
+                    var aTime = parseInt(a.time.substr(0, a.time.length - 1));
+                    var bTime = parseInt(b.time.substr(0, b.time.length - 1));
+
+                    if (aTime < bTime) {
+                        if (bAm && !aAm) {
+                            return 1;
+                        } else {
+                            return -1;
+                        }
+                    } else {
+                        if (aAm && !bAm) {
+                            return -1;
+                        } else {
+                            return 1;
+                        }
+                    }
+                });
+                var schedule = [];
+                for (var i = 0; i < scheduleByChunk.info.length; i++) {
+                    schedule.push({
+                        info: scheduleByChunk.info[i],
+                        items: scheduleByChunk.chunks[scheduleByChunk.info[i].name]
+                    });
+                }
+                dispatch(module.exports.setDay(day));
+                dispatch(module.exports.setSchedule(schedule));
+                return;
+            }
+        });
+    };
+};
diff --git a/src/redux/reducer.js b/src/redux/reducer.js
index 47fa7ccf9..356cb55ac 100644
--- a/src/redux/reducer.js
+++ b/src/redux/reducer.js
@@ -1,10 +1,15 @@
 var combineReducers = require('redux').combineReducers;
 
 var authReducers = require('./auth.js');
+var conferenceScheduleReducers = require('./conference-schedule.js');
+var conferenceDetailsReducers = require('./conference-details.js');
 
 var appReducer = combineReducers({
     session: authReducers.sessionReducer,
     token: authReducers.tokenReducer,
+    day: conferenceScheduleReducers.dayReducer,
+    schedule: conferenceScheduleReducers.scheduleReducer,
+    details: conferenceDetailsReducers.detailsReducer
 });
 
 module.exports = appReducer;
diff --git a/src/routes.json b/src/routes.json
index 8dbe3f90f..6bc5b2014 100644
--- a/src/routes.json
+++ b/src/routes.json
@@ -74,6 +74,20 @@
         "title": "What to Expect",
         "viewportWidth": "device-width"
     },
+    {
+        "name": "conference-schedule",
+        "pattern": "^/conference/schedule/?$",
+        "view": "conference/schedule/schedule",
+        "title": "Conference Schedule",
+        "viewportWidth": "device-width"
+    },
+    {
+        "name": "conference-details",
+        "pattern": "^/conference/:id/details/?$",
+        "view": "conference/details/details",
+        "title": "Event Details",
+        "viewportWidth": "device-width"
+    },
     {
         "name": "donate",
         "pattern": "^/info/donate/?",
diff --git a/src/views/conference/details/details.jsx b/src/views/conference/details/details.jsx
new file mode 100644
index 000000000..5e2632f56
--- /dev/null
+++ b/src/views/conference/details/details.jsx
@@ -0,0 +1,88 @@
+var classNames = require('classnames');
+var connect = require('react-redux').connect;
+var React = require('react');
+var render = require('../../../lib/render.jsx');
+
+var detailsActions = require('../../../redux/conference-details.js');
+
+var Page = require('../../../components/page/conference/page.jsx');
+
+require('./details.scss');
+
+var ConferenceDetails = React.createClass({
+    type: 'ConferenceDetails',
+    propTypes: {
+        detailsId: React.PropTypes.number,
+        details: React.PropTypes.object
+    },
+    componentDidMount: function () {
+        var pathname = window.location.pathname.toLowerCase();
+        if (pathname[pathname.length - 1] === '/') {
+            pathname = pathname.substring(0, pathname.length - 1);
+        }
+        var path = pathname.split('/');
+        var detailsId = path[path.length - 2];
+        this.props.dispatch(detailsActions.startGetDetails(detailsId));
+    },
+    render: function () {
+        var backUri = '/conference/schedule';
+        if (!this.props.details.error && !this.props.details.fetching) {
+            backUri = backUri + '#' + this.props.details.Day;
+        }
+        var classes = classNames({
+            'inner': true,
+            'details': true,
+            'fetching': this.props.details.fetching
+        });
+        return (
+            <div className={classes}>
+                <div className="back">
+                    <a href={backUri}>
+                        &larr; Back to Full Schedule
+                    </a>
+                </div>
+                {this.props.details.error ? [
+                    <h2>Agenda Item Not Found</h2>
+                ] : [
+                    <h2>{this.props.details.Title}</h2>,
+                    <ul className="logistics">
+                        <li>
+                            {this.props.details.Presenter}
+                        </li>
+                        <li>
+                            {this.props.details.Start} &ndash; {this.props.details.End}
+                        </li>
+                        <li>
+                            {this.props.details.Type}
+                        </li>
+                        <li>
+                            {this.props.details.Location}
+                        </li>
+                    </ul>,
+                    <div className="description">
+                        {this.props.details.Description}
+                    </div>,
+                    <div className="back">
+                    {this.props.details.fetching ? [] : [
+                        <a href={backUri}>
+                            &larr; Back to Full Schedule
+                        </a>
+                    ]}
+                    </div>
+                ]}
+            </div>
+        );
+    }
+});
+
+var mapStateToProps = function (state) {
+    return {
+        details: state.details,
+        fetching: state.fetching,
+        error: state.error
+    };
+};
+
+var ConnectedDetails = connect(mapStateToProps)(ConferenceDetails);
+
+render(<Page><ConnectedDetails /></Page>, document.getElementById('app'));
diff --git a/src/views/conference/details/details.scss b/src/views/conference/details/details.scss
new file mode 100644
index 000000000..9b77199c8
--- /dev/null
+++ b/src/views/conference/details/details.scss
@@ -0,0 +1,29 @@
+.details {
+    &.inner {
+        margin-top: 2rem;
+
+        &.fetching {
+            opacity: .6;
+        }
+    }
+
+    .back {
+        margin: 1rem 0;
+    }
+
+    ul {
+        &.logistics {
+            margin: .25rem 0 2.5rem;
+            padding-left: 0;
+            list-style-type: none;
+        }
+
+        li {
+            margin: .25rem 0;
+        }
+    }
+
+    .description {
+        margin: 2rem 0;
+    }
+}
diff --git a/src/views/conference/index/index.jsx b/src/views/conference/index/index.jsx
index 770225b4e..3625345a8 100644
--- a/src/views/conference/index/index.jsx
+++ b/src/views/conference/index/index.jsx
@@ -55,11 +55,13 @@ var ConferenceSplash = React.createClass({
                         </div>
                         <div>
                             <h3>
-                                <img src="/images/conference/schedule/coming-soon.png" alt="schedule-coming-soon" />
-                                Schedule
+                                <a href="/conference/schedule">
+                                    <img src="/images/conference/schedule/schedule.png" alt="schedule" />
+                                    Schedule
+                                </a>
                             </h3>
                             <p>
-                                Stay tuned for the full schedule of events and sessions
+                                Full schedule of events and sessions
                             </p>
                         </div>
                     </FlexRow>
diff --git a/src/views/conference/schedule/schedule.jsx b/src/views/conference/schedule/schedule.jsx
new file mode 100644
index 000000000..854adc338
--- /dev/null
+++ b/src/views/conference/schedule/schedule.jsx
@@ -0,0 +1,122 @@
+var classNames = require('classnames');
+var connect = require('react-redux').connect;
+var React = require('react');
+var render = require('../../../lib/render.jsx');
+
+var scheduleActions = require('../../../redux/conference-schedule.js');
+
+var FlexRow = require('../../../components/flex-row/flex-row.jsx');
+var Page = require('../../../components/page/conference/page.jsx');
+var SubNavigation = require('../../../components/subnavigation/subnavigation.jsx');
+var TitleBanner = require('../../../components/title-banner/title-banner.jsx');
+
+require('./schedule.scss');
+
+var ConferenceSchedule = React.createClass({
+    type: 'ConferenceSchedule',
+    propTypes: {
+        day: React.PropTypes.string,
+        schedule: React.PropTypes.array
+    },
+    componentDidMount: function () {
+        var day = window.location.hash.substr(1) || 'thursday';
+        this.handleScheduleChange(day);
+    },
+    handleScheduleChange: function (day) {
+        this.props.dispatch(scheduleActions.startGetSchedule(day));
+    },
+    renderChunkItems: function (chunk) {
+        return chunk.map(function (item) {
+            if (item.Presenter) {
+                return (
+                    <a href={item.uri} className="item-url">
+                        <div key={item.rowid} className="agenda-item">
+                            <h3>{item.Title}</h3>
+                            <FlexRow>
+                                <p>{item.Start} &ndash; {item.End}</p>
+                                <p>{item.Location}</p>
+                            </FlexRow>
+                            <FlexRow>
+                                <p>{item.Presenter}</p>
+                                <p>{item.Type}</p>
+                            </FlexRow>
+                        </div>
+                    </a>
+                );
+            } else {
+                return (
+                    <div key={item.rowid} className="agenda-item no-click">
+                        <h3>{item.Title}</h3>
+                        <FlexRow>
+                            <p>{item.Start} &ndash; {item.End}</p>
+                            <p>{item.Location}</p>
+                        </FlexRow>
+                    </div>
+                );
+            }
+        });
+    },
+    render: function () {
+        var tabClasses = {
+            'thursday': classNames({
+                'selected': (this.props.day === 'thursday')
+            }),
+            'friday': classNames({
+                'selected': (this.props.day === 'friday')
+            }),
+            'saturday': classNames({
+                'last': true,
+                'selected': (this.props.day === 'saturday')
+            })
+        };
+        return (
+            <div className="schedule">
+                <TitleBanner>
+                    <h1>
+                        Schedule
+                    </h1>
+                </TitleBanner>
+                <SubNavigation>
+                    <li className={tabClasses.thursday}
+                        onClick={this.handleScheduleChange.bind(this, 'thursday')}>
+                        <img src="/svgs/conference/expect/aug4-icon.svg" alt="August 4th Icon" />
+                        <span>Thursday</span>
+                    </li>
+                    <li className={tabClasses.friday}
+                        onClick={this.handleScheduleChange.bind(this, 'friday')}>
+                        <img src="/svgs/conference/expect/aug5-icon.svg" alt="August 5th Icon" />
+                        <span>Friday</span>
+                    </li>
+                    <li className={tabClasses.saturday}
+                        onClick={this.handleScheduleChange.bind(this, 'saturday')}>
+                        <img src="/svgs/conference/expect/aug6-icon.svg" alt="August 6th Icon" />
+                        <span>Saturday</span>
+                    </li>
+                </SubNavigation>
+                <div className="inner">
+                    {this.props.schedule.map(function (chunk) {
+                        return ([
+                            <h2 key={chunk.info.name} className="breaking-title">
+                                <span>{chunk.info.name} – {chunk.info.time}</span>
+                            </h2>,
+                            this.renderChunkItems(chunk.items)
+                        ]);
+                    }.bind(this))}
+                </div>
+            </div>
+        );
+    }
+});
+
+var mapStateToProps = function (state) {
+    return {
+        day: state.day,
+        schedule: state.schedule,
+        fetching: state.fetching,
+        error: state.error
+    };
+};
+
+var ConnectedSchedule = connect(mapStateToProps)(ConferenceSchedule);
+
+render(<Page><ConnectedSchedule /></Page>, document.getElementById('app'));
diff --git a/src/views/conference/schedule/schedule.scss b/src/views/conference/schedule/schedule.scss
new file mode 100644
index 000000000..2f565de36
--- /dev/null
+++ b/src/views/conference/schedule/schedule.scss
@@ -0,0 +1,94 @@
+@import "../../../colors";
+
+.schedule {
+    .title-banner {
+        margin-bottom: 0;
+    }
+
+    .sub-nav {
+        z-index: -1;
+        box-shadow: 0 2px 5px $ui-dark-gray;
+        padding: 0;
+
+        li {
+            margin: 0;
+            border: 0;
+            border-top: 4px solid transparent;
+            border-left: 2px solid $active-gray;
+            border-radius: 0;
+            color: $type-gray;
+            font-size: 1rem;
+
+            &.last {
+                border-right: 2px solid $active-gray;
+            }
+
+            &:hover,
+            &:active,
+            &.selected {
+                border-top: 4px solid $ui-orange;
+                border-left: 2px solid $active-gray;
+                box-shadow: none;
+                background-color: inherit;
+                padding: .75em 1em;
+            }
+
+            &.selected {
+                font-weight: 700;
+            }
+        }
+
+        img {
+            margin-right: .5em;
+            width: 2em;
+            vertical-align: middle;
+        }
+    }
+
+    .inner {
+        h2 {
+            &.breaking-title {
+                margin: 4rem 0 2rem 0;
+                border-bottom: 1px solid $ui-dark-gray;
+                width: 100%;
+                height: 1.7rem; // match the line-height for h2
+                text-align: center;
+            }
+
+            span {
+                background-color: $ui-white;
+                padding: 0 10px;
+            }
+        }
+
+        a.item-url {
+            display: block;
+
+            &:hover {
+                background-color: lighten($ui-blue, 40);
+            }
+        }
+
+        .agenda-item {
+            margin: 1rem 0;
+            border: 1px solid $active-gray;
+            border-radius: 5px;
+            padding: 1.25rem 2.25rem;
+
+            &.no-click {
+                background-color: $ui-gray;
+            }
+
+            .flex-row {
+                margin: .5rem 0;
+                justify-content: flex-start;
+                align-items: flex-start;
+
+                p {
+                    margin: 0;
+                    width: 50%;
+                }
+            }
+        }
+    }
+}