diff --git a/api/api.js b/api/api.js new file mode 100644 index 00000000..1404a6d9 --- /dev/null +++ b/api/api.js @@ -0,0 +1,10 @@ +// @flow +import apiDev from './apiDev'; +import apiServer from './apiServer'; +import type { Api } from './apiType'; + +const isProd = true; + +const api: Api = isProd ? apiServer : apiDev + +export default api; \ No newline at end of file diff --git a/api/apiDev.js b/api/apiDev.js new file mode 100644 index 00000000..289ce869 --- /dev/null +++ b/api/apiDev.js @@ -0,0 +1,34 @@ +// @flow +import type { Api, Files } from './apiType'; +import { tracks, mockRoleToLevel } from '../constants' + +const wait = () => { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), 500); + }) +} + +const apiDev: Api = { + async submitFiles(files: Files) { + // does some stuff + await wait(); + return; + }, + async getMasterConfig(department: string) { + return { + rating: tracks, + role: mockRoleToLevel + } + }, + async fetchUsers() { + return [] + }, + async fetchUser() { + return + }, + async saveUser() { + return + } +} + +export default apiDev; \ No newline at end of file diff --git a/api/apiServer.js b/api/apiServer.js new file mode 100644 index 00000000..e35638cf --- /dev/null +++ b/api/apiServer.js @@ -0,0 +1,101 @@ +// @flow +import type { Api, Files, MasterConfig } from './apiType'; +import type { MilestoneMap } from '../constants' +import { tracks } from '../constants' +import { UserData } from '../models/UserData' +import _ from 'lodash'; + + +const apiServer: Api = { + submitFiles(files: Files) { + let formdata = new FormData(); + _.forEach(files, (file, key) => { + formdata.append(key, file); + }); + formdata.append("department", "ENGINEERING"); + // update endpoint + return fetch("http://localhost:8080/api/performancematrix/config", { + body: formdata, + method: 'POST', + }).then(response => undefined) + }, + getMasterConfig(dept: string) { + return fetch(`http://localhost:8080/api/performancematrix/config/${dept}`) + // stubbed + .then(res => res.json()) + .then(data => { + return sanitizeData(data); + }) + }, + fetchUsers() { + return fetch(`http://localhost:8080/users`) + .then(res => res.json()) + .then(users => users.map(user => user.email)) + }, + fetchUser(user: string) { + return fetch(`http://localhost:8080/api/performancematrix/fetchuser/${user}`) + .then(res => res.json()) + .then(data => { + if (_.isEmpty(data)) { + return {}; + } + + return { + ...data, + ratings: parseRating(data.ratings) + } + }) + }, + saveUser(ratings: MilestoneMap, currentRole: string, username: string) { + const formData = new FormData(); + formData.append("userId", username); + formData.append("curRole", currentRole); + formData.append("ratings", JSON.stringify(ratings)); + + return fetch(`http://localhost:8080/api/performancematrix/saveuser`, { + method: 'POST', + header: {'Content-Type': 'application/json' }, + body: formData + }) + .then() + } +}; + +const parseRating = (ratings: string) => { + const jsonRating = JSON.parse(ratings); + return _.mapValues(jsonRating, strVal => +strVal) +} + + + +// Not sure what this column is, but we don't want it +const BLACKLIST = ['Ratings'] + +const sortByCategory = (ratings) => { + return _(ratings) + .groupBy(rating => rating.category) + .reduce((memo, group) => { + group.forEach(entry => { + memo[entry.displayName] = entry; + }) + return memo; + }, {}) +} + +const sanitizeData = ({ rating, role }: MasterConfig) => { + // sometimes the category milestones doesn't have any data - so we filter these out + const newRating = _.chain(rating) + .mapValues(category => { + return { + ...category, + milestones: category.milestones.filter(milestone => milestone.summary), + } + }) + .pickBy((category, categoryName) => category.milestones.length > 0 && !BLACKLIST.includes(categoryName)) + .value(); + return { rating: sortByCategory(newRating), role } +} + +export default apiServer; + + diff --git a/api/apiType.js b/api/apiType.js new file mode 100644 index 00000000..7f777b4a --- /dev/null +++ b/api/apiType.js @@ -0,0 +1,22 @@ +// @flow +import type { Tracks, RoleToLevel } from '../constants' +import type { UserData } from '../models/UserData' +import type { MilestoneMap } from '../constants' + +export type Files = { + [fileName: string]: File +} + +export type MasterConfig = { + role: RoleToLevel, + rating: Tracks +} + +export type Api = { + submitFiles: (files: File) => Promise, + getMasterConfig: (dept: string) => Promise, + fetchUsers: () => Promise>, + fetchUser: (username: string) => Promise, + saveUser: (ratings: MilestoneMap, currentRole: string, username: string) => Promise +} + diff --git a/components/DepartmentDropDown.js b/components/DepartmentDropDown.js new file mode 100644 index 00000000..01b2037e --- /dev/null +++ b/components/DepartmentDropDown.js @@ -0,0 +1,43 @@ +// @flow +import * as React from 'react'; +import glamorous, { Div } from 'glamorous'; +import { departments } from './../constants'; + + +type DropDownStates = { + value: string, +} + +class DepartmentDropDown extends React.Component<{}, DropDownStates> { + constructor() { + super() + + this.state = { + value: "ENGINEERING", + } + + } + + handleChange = (e: any) => { + e.preventDefault(); + this.setState({value: e.target.value}) + } + + render() { + const departmentOptions = departments.map((department, index) => { + return ( + + ) + }); + return ( +
+ +
+ ) + } + +} + +export default DepartmentDropDown \ No newline at end of file diff --git a/components/FileUploadButton.js b/components/FileUploadButton.js new file mode 100644 index 00000000..6319c168 --- /dev/null +++ b/components/FileUploadButton.js @@ -0,0 +1,42 @@ +// @flow +import * as React from 'react'; +import glamorous from 'glamorous'; +import { teal, teal2 } from '../palette' + +const HiddenFileInput = glamorous.input({ + width: '100%', + height: '100%', + opacity: 0, + overflow: 'hidden', + position: 'absolute', + left: 0, + top: 0 +}) + +const UploadLabel = glamorous.button({ + position: 'relative', + backgroundColor: teal, + padding: '10px 20px', + borderRadius: '5px', + fontWeight: 600, + ':hover': { + backgroundColor: teal2 + }, + transition: 'background-color 0.25s', + margin: '0 auto', + textAlign: 'center', + width: '80px' +}) + +type FileUploadButtonProps = { + onChange: () => void +} + +const FileUploadButton = ({ onChange }: FileUploadButtonProps) => ( + + Upload + + +) + +export default FileUploadButton; \ No newline at end of file diff --git a/components/NightingaleChart.js b/components/NightingaleChart.js index acbc28f7..68e5e147 100644 --- a/components/NightingaleChart.js +++ b/components/NightingaleChart.js @@ -1,27 +1,38 @@ // @flow +import { reduce } from 'lodash' import React from 'react' import * as d3 from 'd3' -import { trackIds, milestones, tracks, categoryColorScale } from '../constants' +import { milestones, categoryColorScale } from '../constants' +import type { Tracks } from '../constants' import type { TrackId, Milestone, MilestoneMap } from '../constants' +import { gray2 } from '../palette' const width = 400 -const arcMilestones = milestones.slice(1) // we'll draw the '0' milestone with a circle, not an arc. type Props = { + label: string, + tracks: Tracks, milestoneByTrack: MilestoneMap, - focusedTrackId: TrackId, - handleTrackMilestoneChangeFn: (TrackId, Milestone) => void + focusedTrackId?: TrackId, + handleTrackMilestoneChangeFn: (TrackId, Milestone) => void, + trackIds: Array } +let arcMilestones = []; class NightingaleChart extends React.Component { colorScale: any radiusScale: any arcFn: any - constructor(props: *) { + constructor(props: Props) { super(props) + const maxNumMilestones = reduce(props.tracks, (max, category) => { + return Math.max(max, category.milestones.length) + }, 0) + arcMilestones = [...new Array(maxNumMilestones)].map((_, i) => i) + this.colorScale = d3.scaleSequential(d3.interpolateWarm) .domain([0, 5]) @@ -33,20 +44,22 @@ class NightingaleChart extends React.Component { this.arcFn = d3.arc() .innerRadius(milestone => this.radiusScale(milestone)) .outerRadius(milestone => this.radiusScale(milestone) + this.radiusScale.bandwidth()) - .startAngle(- Math.PI / trackIds.length) - .endAngle(Math.PI / trackIds.length) + .startAngle(- Math.PI / this.props.trackIds.length) + .endAngle(Math.PI / this.props.trackIds.length) .padAngle(Math.PI / 200) .padRadius(.45 * width) .cornerRadius(2) } render() { - const currentMilestoneId = this.props.milestoneByTrack[this.props.focusedTrackId] + const { label, trackIds, tracks } = this.props; + const currentMilestoneId = this.props.focusedTrackId && this.props.milestoneByTrack[this.props.focusedTrackId] return (
+

{label}

{trackIds.map((trackId, i) => { const isCurrentTrack = trackId == this.props.focusedTrackId + const numMilestones = tracks[trackId].milestones.length; + return ( - {arcMilestones.map((milestone) => { + {arcMilestones.map((milestone, i) => { + const outOfRange = i > numMilestones - 1; + const isCurrentMilestone = isCurrentTrack && milestone == currentMilestoneId - const isMet = this.props.milestoneByTrack[trackId] >= milestone || milestone == 0 + const isMet = this.props.milestoneByTrack[trackId] >= milestone + 1 //|| milestone == 0 + return ( this.props.handleTrackMilestoneChangeFn(trackId, milestone)} d={this.arcFn(milestone)} style={{fill: isMet ? categoryColorScale(tracks[trackId].category) : undefined}} /> @@ -85,7 +107,7 @@ class NightingaleChart extends React.Component { cx="0" cy="-50" style={{fill: categoryColorScale(tracks[trackId].category)}} - className={"track-milestone " + (isCurrentTrack && !currentMilestoneId ? "track-milestone-current" : "")} + className={"track-milestone " + (isCurrentTrack ? "track-milestone-current" : "")} onClick={() => this.props.handleTrackMilestoneChangeFn(trackId, 0)} /> )})} diff --git a/components/PointSummaries.js b/components/PointSummaries.js index 7eaf9e66..c4957014 100644 --- a/components/PointSummaries.js +++ b/components/PointSummaries.js @@ -1,89 +1,104 @@ // @flow import { pointsToLevels, milestoneToPoints, trackIds, totalPointsFromMilestoneMap } from '../constants' -import type { MilestoneMap } from '../constants' +import { Div } from 'glamorous' +import FunButton from '../glamorous/FunButton' +import type { MilestoneMap, RoleToLevel } from '../constants' +import type { User } from '../models/User' import React from 'react' +import _ from 'lodash' type Props = { - milestoneByTrack: MilestoneMap + milestoneByTrack: MilestoneMap, + user?: string, + saveUser: (withPromotion: boolean) => void, + nextRoleToLevel: { + [category: string]: number + }, + openPromotionModal: () => void } + class PointSummaries extends React.Component { render() { - const totalPoints = totalPointsFromMilestoneMap(this.props.milestoneByTrack) - - let currentLevel, nextLevel + const { milestoneByTrack, nextRoleToLevel, user } = this.props; + const totalPoints = _.reduce(milestoneByTrack, (memo, track) => memo + track, 0) - let pointsForCurrentLevel = totalPoints - while (!(currentLevel = pointsToLevels[pointsForCurrentLevel])) { - pointsForCurrentLevel-- - } - - let pointsToNextLevel = 1 - while (!(nextLevel = pointsToLevels[totalPoints + pointsToNextLevel])) { - pointsToNextLevel++ - if (pointsToNextLevel > 135) { - pointsToNextLevel = 'N/A' - break - } - } + const minCumScore = nextRoleToLevel["Min Cumulative Scores"]; const blocks = [ - { - label: 'Current level', - value: currentLevel - }, { label: 'Total points', value: totalPoints }, { - label: 'Points to next level', - value: pointsToNextLevel + label: 'Points needed for next level', + value: minCumScore } ] + const meetsMinReqForAllFields = _.every(milestoneByTrack, (score, categoryName) => { + return score >= nextRoleToLevel[categoryName]; + }) + + const deservesPromotion = meetsMinReqForAllFields && totalPoints >= minCumScore; + + const promoteUser = () => { + this.props.saveUser(true) + this.props.openPromotionModal() + } + + if (!user) return null; + return ( - - - - - {blocks.map(({label}, i) => ( - - ))} - - - {blocks.map(({value}, i) => ( - - ))} - - -
- {label} -
- {value} -
+
+

+ {user} +

+ + + + + {blocks.map(({label}, i) => ( + + ))} + + + {blocks.map(({value}, i) => ( + + ))} + + +
+ {label} +
+ {value} +
+ {deservesPromotion ? promoteUser() : this.props.saveUser(false)}}> + {deservesPromotion ? 'Promote Employee' : 'Save Information' } + +
) } } diff --git a/components/ProgressBar.js b/components/ProgressBar.js new file mode 100644 index 00000000..0720bb84 --- /dev/null +++ b/components/ProgressBar.js @@ -0,0 +1,40 @@ +// @flow +import * as React from 'react'; +import glamorous, { Div } from 'glamorous'; +import { green1, gray2 } from '../palette' + +const Circle = glamorous.div({ + display: 'inline-block', + minWidth: '30px', + minHeight: '30px', + borderRadius: '1000px' +}, +({ active }) => ({ backgroundColor: active ? green1 : gray2 }) +) + +const LineBetween = glamorous.hr({ + display: 'inline-block', + width: '100%' +}) + +type ProgressBarProps = { + numBuckets: number, + currentStep: number, + changeStep: (step: number) => void +} + +const ProgressBar = ({ currentStep, numBuckets, changeStep }: ProgressBarProps) => ( +
+ { + [...new Array(numBuckets - 1)].map((_, index) => + + index} onClick={() => changeStep(index + 1)} /> + + + ) + } + = numBuckets} onClick={() => changeStep(numBuckets)} /> +
+) + +export default ProgressBar; \ No newline at end of file diff --git a/components/PromotionModal.js b/components/PromotionModal.js new file mode 100644 index 00000000..9bc1b4c3 --- /dev/null +++ b/components/PromotionModal.js @@ -0,0 +1,28 @@ +import * as React from 'react'; +import Modal from 'react-modal' +import glam from 'glamorous' +import CancelIcon from './icons/CancelIcon' + +const ModalContainer = glam.div({ + marginTop: "100px", + padding: '150px', + display: 'flex', + justifyContent: 'center', + textAlign: 'center' +}) + +const PromotionModal = ({ + isModalOpen, + userName, + nextRole, + onClose +}) => ( + + + +

Congratulations! {userName} was promoted to {nextRole}!

+
+
+) + +export default PromotionModal; \ No newline at end of file diff --git a/components/SnowflakeApp.js b/components/SnowflakeApp.js index db61502f..3f28e8ec 100644 --- a/components/SnowflakeApp.js +++ b/components/SnowflakeApp.js @@ -1,48 +1,43 @@ // @flow +import { isEmpty, isUndefined, mapValues } from 'lodash' +import { Div, H1 } from 'glamorous' import TrackSelector from '../components/TrackSelector' import NightingaleChart from '../components/NightingaleChart' import KeyboardListener from '../components/KeyboardListener' import Track from '../components/Track' import Wordmark from '../components/Wordmark' import LevelThermometer from '../components/LevelThermometer' -import { eligibleTitles, trackIds, milestones, milestoneToPoints } from '../constants' +import { milestoneToPoints, departments } from '../constants' +import type { RoleToLevel, Tracks } from '../constants' import PointSummaries from '../components/PointSummaries' import type { Milestone, MilestoneMap, TrackId } from '../constants' import React from 'react' -import TitleSelector from '../components/TitleSelector' +import Modal from 'react-modal' +import UploadModal from './UploadModal/UploadModal' +import api from '../api/api' +import coerceMilestone from '../utils/coerceMilestone' +import FunButton from '../glamorous/FunButton' +import Spinner from './Spinner' +import { teal2 } from '../palette' +import type { UserData } from '../models/UserData' +import UserSelect from './UserSelect' +import PromotionModal from './PromotionModal' type SnowflakeAppState = { milestoneByTrack: MilestoneMap, name: string, title: string, - focusedTrackId: TrackId, -} - -const hashToState = (hash: String): ?SnowflakeAppState => { - if (!hash) return null - const result = defaultState() - const hashValues = hash.split('#')[1].split(',') - if (!hashValues) return null - trackIds.forEach((trackId, i) => { - result.milestoneByTrack[trackId] = coerceMilestone(Number(hashValues[i])) - }) - if (hashValues[16]) result.name = decodeURI(hashValues[16]) - if (hashValues[17]) result.title = decodeURI(hashValues[17]) - return result -} - -const coerceMilestone = (value: number): Milestone => { - // HACK I know this is goofy but i'm dealing with flow typing - switch(value) { - case 0: return 0 - case 1: return 1 - case 2: return 2 - case 3: return 3 - case 4: return 4 - case 5: return 5 - default: return 0 - } + focusedTrackId?: TrackId, + isUploadModalOpen: boolean, + tracks?: Tracks, + trackIds: Array, + roleToLevel?: RoleToLevel, + loadingData: boolean, + users: Array, + selectedUser: string, + selectedUserData?: UserData, + promotionModalOpen: boolean, } const emptyState = (): SnowflakeAppState => { @@ -50,24 +45,13 @@ const emptyState = (): SnowflakeAppState => { name: '', title: '', milestoneByTrack: { - 'MOBILE': 0, - 'WEB_CLIENT': 0, - 'FOUNDATIONS': 0, - 'SERVERS': 0, - 'PROJECT_MANAGEMENT': 0, - 'COMMUNICATION': 0, - 'CRAFT': 0, - 'INITIATIVE': 0, - 'CAREER_DEVELOPMENT': 0, - 'ORG_DESIGN': 0, - 'WELLBEING': 0, - 'ACCOMPLISHMENT': 0, - 'MENTORSHIP': 0, - 'EVANGELISM': 0, - 'RECRUITING': 0, - 'COMMUNITY': 0 }, - focusedTrackId: 'MOBILE' + isUploadModalOpen: false, + trackIds: [], + loadingData: true, + users: [], + selectedUser: '', + promotionModalOpen: false } } @@ -76,31 +60,18 @@ const defaultState = (): SnowflakeAppState => { name: 'Cersei Lannister', title: 'Staff Engineer', milestoneByTrack: { - 'MOBILE': 1, - 'WEB_CLIENT': 2, - 'FOUNDATIONS': 3, - 'SERVERS': 2, - 'PROJECT_MANAGEMENT': 4, - 'COMMUNICATION': 1, - 'CRAFT': 1, - 'INITIATIVE': 4, - 'CAREER_DEVELOPMENT': 3, - 'ORG_DESIGN': 2, - 'WELLBEING': 0, - 'ACCOMPLISHMENT': 4, - 'MENTORSHIP': 2, - 'EVANGELISM': 2, - 'RECRUITING': 3, - 'COMMUNITY': 0 }, - focusedTrackId: 'MOBILE' + isUploadModalOpen: false, + trackIds: [], + loadingData: true, + users: [], + selectedUser: '', + promotionModalOpen: false } } -const stateToHash = (state: SnowflakeAppState) => { - if (!state || !state.milestoneByTrack) return null - const values = trackIds.map(trackId => state.milestoneByTrack[trackId]).concat(encodeURI(state.name), encodeURI(state.title)) - return values.join(',') +const defaultMilestoneByTrack = (tracks?: Tracks) => { + return mapValues(tracks, () => 0); } type Props = {} @@ -111,21 +82,206 @@ class SnowflakeApp extends React.Component { this.state = emptyState() } - componentDidUpdate() { - const hash = stateToHash(this.state) - if (hash) window.location.replace(`#${hash}`) - } - componentDidMount() { - const state = hashToState(window.location.hash) - if (state) { - this.setState(state) - } else { + // request for the the initial constants - the defualt will be engineering masterconfig + Promise.all([api.getMasterConfig(departments[0]), api.fetchUsers()]) + .then(([{ role, rating}, users]) => { + this.setState({ + roleToLevel: role, + tracks: rating, + trackIds: Object.keys(rating), + milestoneByTrack: mapValues(rating, _ => 0), + focusedTrackId: Object.keys(rating)[0], + loadingData: false, + users + }) + }) + .catch(() => { + this.setState({ + ...defaultState(), + loadingData: false + }) + }) + this.setState(defaultState()) - } + } + + fetchConfig = () => { + Promise.all([api.getMasterConfig(departments[0]), api.fetchUsers()]) + .then(([{ role, rating}, users]) => { + this.setState({ + roleToLevel: role, + tracks: rating, + trackIds: Object.keys(rating), + milestoneByTrack: mapValues(rating, _ => 0), + focusedTrackId: Object.keys(rating)[0], + loadingData: false, + users + }) + }) + } + + toggleUploadModal = () => { + + this.setState({ + isUploadModalOpen: !this.state.isUploadModalOpen + }) + } + + selectUser = (e: any) => { + const userName: string = e.target.value; + api.fetchUser(userName) + .then(userData => { + this.setState({ + selectedUser: userName, + selectedUserData: userData, + milestoneByTrack: isEmpty(userData.ratings) ? defaultMilestoneByTrack(this.state.tracks) : userData.ratings + }) + }) + } + + renderUserSelect = () => { + if (!this.state.tracks || typeof this.state.focusedTrackId === 'undefined') return null; + + return ( + + ) + } + + saveUser = async (withPromotion: boolean) => { + debugger + await api.saveUser( + this.state.milestoneByTrack, + withPromotion ? this.state.selectedUserData.ladder[0] : this.state.selectedUserData.currentRole, + this.state.selectedUser + ) + + const userData = await api.fetchUser(this.state.selectedUser) + + this.setState({ + selectedUserData: userData, + milestoneByTrack: isEmpty(userData.ratings) ? defaultMilestoneByTrack(this.state.tracks) : userData.ratings + }) + } + + togglePromotionModal = () => { + this.setState({ + promotionModalOpen: !this.state.promotionModalOpen + }) + } + + renderProjectedThresholds = () => { + const { tracks, trackIds, selectedUserData, roleToLevel } = this.state; + + if (!selectedUserData) return null + + const ladder = selectedUserData.ladder; + + return ladder.map(role => { + const levels = roleToLevel[role]; + + return {}} /> + + }) + } + + togglePromotionModal = () => { + this.setState({ + promotionModalOpen: !this.state.promotionModalOpen + }) + } + + renderVisualiations = () => { + if (!this.state.roleToLevel || !this.state.selectedUserData || !this.state.selectedUser || !this.state.tracks || typeof this.state.focusedTrackId === 'undefined') return null; + const trackIds = Object.keys(this.state.tracks); + + const nextRoleToLevel = this.state.roleToLevel[this.state.selectedUserData.ladder[0]]; + + return ( +
+ {/* */} +
+ +
+
+ + {}} /> + {this.renderProjectedThresholds()} +
+ + + this.handleTrackMilestoneChange(track, milestone)} /> +
+ ) + } + + renderFirstToCome = () => { + const { tracks } = this.state; + + return !tracks + ?

+ We don't have any configurations yet! Be the first to upload one. +

+ : null; + } + + renderContent = () => { + if (this.state.loadingData) return
; + + return ( +
+ {this.renderFirstToCome()} + this.toggleUploadModal()} width="500px" height="50px" display="block"> + upload a new matrix + + + {this.renderUserSelect()} + + {this.renderVisualiations()} + +
+ ) } render() { + return (
-
- - - -
-
-
-
- this.setState({name: e.target.value})} - placeholder="Name" - /> - this.setTitle(title)} /> - - - -
-
- this.handleTrackMilestoneChange(track, milestone)} /> -
-
- - - this.handleTrackMilestoneChange(track, milestone)} /> -
-
- Made with ❤️ by Medium Eng. - Learn about the growth framework. - Get the source code. - Read the terms of service. -
-
+
+ {this.renderContent()} +
) } handleTrackMilestoneChange(trackId: TrackId, milestone: Milestone) { - const milestoneByTrack = this.state.milestoneByTrack - milestoneByTrack[trackId] = milestone - const titles = eligibleTitles(milestoneByTrack) - const title = titles.indexOf(this.state.title) === -1 ? titles[0] : this.state.title + if (this.state.tracks === undefined) return; + + const isWithinRange = !isUndefined(this.state.tracks[trackId].milestones[milestone]) + + const milestoneByTrack = this.state.milestoneByTrack + if (isWithinRange) { + milestoneByTrack[trackId] = milestone + } - this.setState({ milestoneByTrack, focusedTrackId: trackId, title }) + this.setState({ + milestoneByTrack, + focusedTrackId: isWithinRange ? trackId : this.state.focusedTrackId + }) } shiftFocusedTrack(delta: number) { + const { trackIds } = this.state; + if (!this.state.focusedTrackId) return; + let index = trackIds.indexOf(this.state.focusedTrackId) index = (index + delta + trackIds.length) % trackIds.length const focusedTrackId = trackIds[index] @@ -228,23 +394,23 @@ class SnowflakeApp extends React.Component { } setFocusedTrackId(trackId: TrackId) { + const { trackIds } = this.state; let index = trackIds.indexOf(trackId) const focusedTrackId = trackIds[index] + this.setState({ focusedTrackId }) } shiftFocusedTrackMilestoneByDelta(delta: number) { + if (!this.state.focusedTrackId) return; + let prevMilestone = this.state.milestoneByTrack[this.state.focusedTrackId] let milestone = prevMilestone + delta + if (milestone < 0) milestone = 0 if (milestone > 5) milestone = 5 - this.handleTrackMilestoneChange(this.state.focusedTrackId, milestone) - } - setTitle(title: string) { - let titles = eligibleTitles(this.state.milestoneByTrack) - title = titles.indexOf(title) == -1 ? titles[0] : title - this.setState({ title }) + this.handleTrackMilestoneChange(this.state.focusedTrackId, coerceMilestone(milestone)) } } diff --git a/components/Spinner.js b/components/Spinner.js new file mode 100644 index 00000000..e607c2f9 --- /dev/null +++ b/components/Spinner.js @@ -0,0 +1,13 @@ +import * as React from 'react'; +import glamorous from 'glamorous' + +const Spinner = () => ( +
+
+
+
+
+
+) + +export default Spinner; \ No newline at end of file diff --git a/components/TitleSelector.js b/components/TitleSelector.js index ec9b1d2e..57c1fd6c 100644 --- a/components/TitleSelector.js +++ b/components/TitleSelector.js @@ -1,34 +1,34 @@ // @flow -import React from 'react' -import { eligibleTitles } from '../constants' -import type { MilestoneMap } from '../constants' +// import React from 'react' +// import { eligibleTitles } from '../constants' +// import type { MilestoneMap } from '../constants' -type Props = { - milestoneByTrack: MilestoneMap, - currentTitle: String, - setTitleFn: (string) => void -} +// type Props = { +// milestoneByTrack: MilestoneMap, +// currentTitle: String, +// setTitleFn: (string) => void +// } -class TitleSelector extends React.Component { - render() { - const titles = eligibleTitles(this.props.milestoneByTrack) - return - } -} +// class TitleSelector extends React.Component { +// render() { +// const titles = eligibleTitles(this.props.milestoneByTrack) +// return +// } +// } -export default TitleSelector +// export default TitleSelector diff --git a/components/Track.js b/components/Track.js index 8b4ac354..0f3274f4 100644 --- a/components/Track.js +++ b/components/Track.js @@ -1,20 +1,29 @@ // @flow - +import _ from 'lodash' import { tracks, milestones, categoryColorScale } from '../constants' +import type { Track, Tracks } from '../constants' import React from 'react' -import type { MilestoneMap, TrackId, Milestone } from '../constants' +import type { MilestoneMap, Milestone } from '../constants' +import coerceMilestone from '../utils/coerceMilestone' +import { H3 } from 'glamorous' type Props = { - milestoneByTrack: MilestoneMap, + tracks: Tracks, trackId: TrackId, + milestoneByTrack: MilestoneMap, handleTrackMilestoneChangeFn: (TrackId, Milestone) => void } -class Track extends React.Component { +type TrackId = string + +class TrackComponent extends React.Component { render() { - const track = tracks[this.props.trackId] - const currentMilestoneId = this.props.milestoneByTrack[this.props.trackId] - const currentMilestone = track.milestones[currentMilestoneId - 1] + const { milestoneByTrack, tracks, trackId } = this.props; + + const track = tracks[trackId] + const currentMilestoneId = milestoneByTrack[trackId] || 0 + const currentMilestone = track.milestones[currentMilestoneId] + return (