diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js index a50edcb7b9..a1351067ac 100644 --- a/common/app/routes/challenges/redux/fetch-challenges-saga.js +++ b/common/app/routes/challenges/redux/fetch-challenges-saga.js @@ -12,21 +12,10 @@ import { delayedRedirect, createErrorObservable } from '../../../redux/actions'; +import createNameIdMap from '../../../../utils/create-name-id-map'; const { fetchChallenge, fetchChallenges, replaceChallenge } = types; -function createNameIdMap(entities) { - const { challenge } = entities; - return { - ...entities, - challengeIdToName: Object.keys(challenge) - .reduce((map, challengeName) => { - map[challenge[challengeName].id] = challenge[challengeName].dashedName; - return map; - }, {}) - }; -} - export default function fetchChallengesSaga(action$, getState, { services }) { return action$ .filter(({ type }) => ( diff --git a/common/utils/create-name-id-map.js b/common/utils/create-name-id-map.js new file mode 100644 index 0000000000..5d2ea1a106 --- /dev/null +++ b/common/utils/create-name-id-map.js @@ -0,0 +1,12 @@ +// createNameIdMap(entities: Object) => Object +export default function createNameIdMap(entities) { + const { challenge } = entities; + return { + ...entities, + challengeIdToName: Object.keys(challenge) + .reduce((map, challengeName) => { + map[challenge[challengeName].id] = challenge[challengeName].dashedName; + return map; + }, {}) + }; +} diff --git a/server/boot/user.js b/server/boot/user.js index 8c134e7a7c..48b2ca4536 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -3,15 +3,12 @@ import moment from 'moment-timezone'; import { Observable } from 'rx'; import debugFactory from 'debug'; -import supportedLanguages from '../../common/utils/supported-languages'; import { frontEndChallengeId, dataVisChallengeId, backEndChallengeId } from '../utils/constantStrings.json'; - import certTypes from '../utils/certTypes.json'; - import { ifNoUser401, ifNoUserRedirectTo @@ -22,6 +19,9 @@ import { calcCurrentStreak, calcLongestStreak } from '../utils/user-stats'; +import supportedLanguages from '../../common/utils/supported-languages'; +import createNameIdMap from '../../common/utils/create-name-id-map'; +import { cachedMap } from '../utils/map'; const debug = debugFactory('fcc:boot:user'); const sendNonUserToMap = ifNoUserRedirectTo('/map'); @@ -85,25 +85,35 @@ function getChallengeGroup(challenge) { return 'challenges'; } -// buildDisplayChallenges(challengeMap: Object, tz: String) => Observable[{ +// buildDisplayChallenges( +// entities: { challenge: Object, challengeIdToName: Object }, +// challengeMap: Object, +// tz: String +// ) => Observable[{ // algorithms: Array, // projects: Array, // challenges: Array // }] -function buildDisplayChallenges(challengeMap = {}, timezone) { - return Observable.from(Object.keys(challengeMap)) - .map(challengeId => challengeMap[challengeId]) - .map(challenge => { - let finalChallenge = { ...challenge }; - if (challenge.completedDate) { +function buildDisplayChallenges( + { challenge: challengeMap = {}, challengeIdToName }, + userChallengeMap = {}, + timezone +) { + return Observable.from(Object.keys(userChallengeMap)) + .map(challengeId => userChallengeMap[challengeId]) + .map(userChallenge => { + const challengeId = userChallenge.id; + const challenge = challengeMap[ challengeIdToName[challengeId] ]; + let finalChallenge = { ...userChallenge, ...challenge }; + if (userChallenge.completedDate) { finalChallenge.completedDate = moment - .tz(challenge.completedDate, timezone) + .tz(userChallenge.completedDate, timezone) .format(dateFormat); } - if (challenge.lastUpdated) { + if (userChallenge.lastUpdated) { finalChallenge.lastUpdated = moment - .tz(challenge.lastUpdated, timezone) + .tz(userChallenge.lastUpdated, timezone) .format(dateFormat); } @@ -128,6 +138,8 @@ module.exports = function(app) { const router = app.loopback.Router(); const api = app.loopback.Router(); const User = app.models.User; + const Block = app.models.Block; + const map$ = cachedMap(Block); function findUserByUsername$(username, fields) { return observeQuery( User, @@ -187,7 +199,7 @@ module.exports = function(app) { (req, res) => res.redirect(req.url.replace('full-stack', 'back-end')) ); - router.get('/:username', returnUser); + router.get('/:username', showUserProfile); app.use('/:lang', router); app.use(api); @@ -248,7 +260,7 @@ module.exports = function(app) { return res.redirect('/' + username); } - function returnUser(req, res, next) { + function showUserProfile(req, res, next) { const username = req.params.username.toLowerCase(); const { user } = req; @@ -313,7 +325,12 @@ module.exports = function(app) { }); } - return buildDisplayChallenges(userPortfolio.challengeMap, timezone) + return map$.map(({ entities }) => createNameIdMap(entities)) + .flatMap(entities => buildDisplayChallenges( + entities, + userPortfolio.challengeMap, + timezone + )) .map(displayChallenges => ({ ...userPortfolio, ...displayChallenges, diff --git a/server/services/map.js b/server/services/map.js index 70472a5fbb..e48e9f4078 100644 --- a/server/services/map.js +++ b/server/services/map.js @@ -1,134 +1,12 @@ -import _ from 'lodash'; import { Observable } from 'rx'; -import { Schema, valuesOf, arrayOf, normalize } from 'normalizr'; import debug from 'debug'; -import { nameify, unDasherize } from '../utils'; -import supportedLanguages from '../../common/utils/supported-languages'; +import { unDasherize } from '../utils'; +import { mapChallengeToLang, cachedMap, getMapForLang } from '../utils/map'; const isDev = process.env.NODE_ENV !== 'production'; const isBeta = !!process.env.BETA; const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; -const log = debug('fcc:services:challenges'); -const challenge = new Schema('challenge', { idAttribute: 'dashedName' }); -const block = new Schema('block', { idAttribute: 'dashedName' }); -const superBlock = new Schema('superBlock', { idAttribute: 'dashedName' }); - -block.define({ - challenges: arrayOf(challenge) -}); - -superBlock.define({ - blocks: arrayOf(block) -}); - -const mapSchema = valuesOf(superBlock); - -/* - * interface ChallengeMap { - * result: [superBlockDashedName: String] - * entities: { - * superBlock: { - * [superBlockDashedName: String]: { - * blocks: [blockDashedName: String] - * } - * }, - * block: { - * [blockDashedName: String]: { - * challenges: [challengeDashedName: String] - * } - * }, - * challenge: { - * [challengeDashedName: String]: Challenge - * } - * } - * } - */ -function cachedMap(Block) { - const query = { - include: 'challenges', - order: ['superOrder ASC', 'order ASC'] - }; - return Block.find$(query) - .flatMap(blocks => Observable.from(blocks.map(block => block.toJSON()))) - .reduce((map, block) => { - if (map[block.superBlock]) { - map[block.superBlock].blocks.push(block); - } else { - map[block.superBlock] = { - title: _.startCase(block.superBlock), - order: block.superOrder, - name: nameify(_.startCase(block.superBlock)), - dashedName: block.superBlock, - blocks: [block], - message: block.superBlockMessage - }; - } - return map; - }, {}) - .map(map => normalize(map, mapSchema)) - .map(map => { - // make sure challenges are in the right order - map.entities.block = Object.keys(map.entities.block) - // turn map into array - .map(key => map.entities.block[key]) - // perform re-order - .map(block => { - block.challenges = block.challenges.reduce((accu, dashedName) => { - const index = map.entities.challenge[dashedName].suborder; - accu[index - 1] = dashedName; - return accu; - }, []); - return block; - }) - // turn back into map - .reduce((blockMap, block) => { - blockMap[block.dashedName] = block; - return blockMap; - }, {}); - return map; - }) - .map(map => { - // re-order superBlocks result - const result = Object.keys(map.result).reduce((result, supName) => { - const index = map.entities.superBlock[supName].order; - result[index] = supName; - return result; - }, []); - return { - ...map, - result - }; - }) - .shareReplay(); -} - -function mapChallengeToLang({ translations = {}, ...challenge }, lang) { - if (!supportedLanguages[lang]) { - lang = 'en'; - } - const translation = translations[lang] || {}; - if (lang !== 'en') { - challenge = { - ...challenge, - ...translation - }; - } - return challenge; -} - -function getMapForLang(lang) { - return ({ entities: { challenge: challengeMap, ...entities }, result }) => { - entities.challenge = Object.keys(challengeMap) - .reduce((translatedChallengeMap, key) => { - translatedChallengeMap[key] = mapChallengeToLang( - challengeMap[key], - lang - ); - return translatedChallengeMap; - }, {}); - return { result, entities }; - }; -} +const log = debug('fcc:services:map'); function shouldNotFilterComingSoon({ isComingSoon, isBeta: challengeIsBeta }) { return isDev || diff --git a/server/utils/map.js b/server/utils/map.js new file mode 100644 index 0000000000..8dfe32f824 --- /dev/null +++ b/server/utils/map.js @@ -0,0 +1,136 @@ +import _ from 'lodash'; +import { Observable } from 'rx'; +import { Schema, valuesOf, arrayOf, normalize } from 'normalizr'; + +import { nameify } from '../utils'; +import supportedLanguages from '../../common/utils/supported-languages'; + +const challenge = new Schema('challenge', { idAttribute: 'dashedName' }); +const block = new Schema('block', { idAttribute: 'dashedName' }); +const superBlock = new Schema('superBlock', { idAttribute: 'dashedName' }); + +block.define({ + challenges: arrayOf(challenge) +}); + +superBlock.define({ + blocks: arrayOf(block) +}); + +const mapSchema = valuesOf(superBlock); +let mapObservableCache; +/* + * interface ChallengeMap { + * result: [superBlockDashedName: String] + * entities: { + * superBlock: { + * [superBlockDashedName: String]: { + * blocks: [blockDashedName: String] + * } + * }, + * block: { + * [blockDashedName: String]: { + * challenges: [challengeDashedName: String] + * } + * }, + * challenge: { + * [challengeDashedName: String]: Challenge + * } + * } + * } + */ +export function cachedMap(Block) { + if (mapObservableCache) { + return mapObservableCache; + } + const query = { + include: 'challenges', + order: ['superOrder ASC', 'order ASC'] + }; + const map$ = Block.find$(query) + .flatMap(blocks => Observable.from(blocks.map(block => block.toJSON()))) + .reduce((map, block) => { + if (map[block.superBlock]) { + map[block.superBlock].blocks.push(block); + } else { + map[block.superBlock] = { + title: _.startCase(block.superBlock), + order: block.superOrder, + name: nameify(_.startCase(block.superBlock)), + dashedName: block.superBlock, + blocks: [block], + message: block.superBlockMessage + }; + } + return map; + }, {}) + .map(map => normalize(map, mapSchema)) + .map(map => { + // make sure challenges are in the right order + map.entities.block = Object.keys(map.entities.block) + // turn map into array + .map(key => map.entities.block[key]) + // perform re-order + .map(block => { + block.challenges = block.challenges.reduce((accu, dashedName) => { + const index = map.entities.challenge[dashedName].suborder; + accu[index - 1] = dashedName; + return accu; + }, []); + return block; + }) + // turn back into map + .reduce((blockMap, block) => { + blockMap[block.dashedName] = block; + return blockMap; + }, {}); + return map; + }) + .map(map => { + // re-order superBlocks result + const result = Object.keys(map.result).reduce((result, supName) => { + const index = map.entities.superBlock[supName].order; + result[index] = supName; + return result; + }, []); + return { + ...map, + result + }; + }) + .shareReplay(); + mapObservableCache = map$; + return map$; +} + +export function mapChallengeToLang( + { translations = {}, ...challenge }, + lang +) { + if (!supportedLanguages[lang]) { + lang = 'en'; + } + const translation = translations[lang] || {}; + if (lang !== 'en') { + challenge = { + ...challenge, + ...translation + }; + } + return challenge; +} + +export function getMapForLang(lang) { + return ({ entities: { challenge: challengeMap, ...entities }, result }) => { + entities.challenge = Object.keys(challengeMap) + .reduce((translatedChallengeMap, key) => { + translatedChallengeMap[key] = mapChallengeToLang( + challengeMap[key], + lang + ); + return translatedChallengeMap; + }, {}); + return { result, entities }; + }; +} + diff --git a/server/views/account/show.jade b/server/views/account/show.jade index 9e4075b0cd..cf9cb2c5e7 100644 --- a/server/views/account/show.jade +++ b/server/views/account/show.jade @@ -64,20 +64,6 @@ block content $(document).ready(function () { var cal = new CalHeatMap(); var calendar = !{JSON.stringify(calender)}; - /* - var estUTCOffset = -5; - // moment returns the utc offset in minutes - var userUTCOffset = moment().utcOffset() / 60; - var secondsToOffset = - (estUTCOffset - userUTCOffset) * 3600; - var offsetCalendar = {}; - for (var prop in calendar) { - if (calendar.hasOwnProperty(prop)) { - var offsetProp = prop + secondsToOffset; - offsetCalendar[offsetProp] = calendar[prop]; - } - } - */ cal.init({ itemSelector: "#cal-heatmap", domain: "month", @@ -118,13 +104,13 @@ block content for challenge in projects tr td.col-xs-5.hidden-xs - a(href='/challenges/' + removeOldTerms(challenge.name), target='_blank')= removeOldTerms(challenge.name) + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}', target='_blank')= challenge.name td.col-xs-2.hidden-xs= challenge.completedDate ? challenge.completedDate : 'Not Available' td.col-xs-2.hidden-xs= challenge.lastUpdated ? challenge.lastUpdated : '' td.col-xs-2.hidden-xs a(href=challenge.solution, target='_blank') View project td.col-xs-12.visible-xs - a(href=challenge.solution, target='_blank')= removeOldTerms(challenge.name) + a(href=challenge.solution, target='_blank')= challenge.name if (algorithms.length > 0) .col-sm-12 table.table.table-striped @@ -136,19 +122,19 @@ block content th.col-xs-2.hidden-xs Solution for challenge in algorithms tr - td.col-xs-5.hidden-xs= removeOldTerms(challenge.name) + td.col-xs-5.hidden-xs= challenge.name td.col-xs-2.hidden-xs= challenge.completedDate ? challenge.completedDate : 'Not Available' td.col-xs-2.hidden-xs= challenge.lastUpdated ? challenge.lastUpdated : '' td.col-xs-2.hidden-xs if (challenge.solution) - a(href='/challenges/' + removeOldTerms(challenge.name) + '?solution=' + encodeURIComponent(encodeFcc(challenge.solution)), target='_blank') View solution + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}?solution=#{encodeURIComponent(encodeFcc(challenge.solution))}', target='_blank') View solution else - a(href='/challenges/' + removeOldTerms(challenge.name)) View this challenge + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}') View this challenge td.col-xs-12.visible-xs if (challenge.solution) - a(href='/challenges/' + removeOldTerms(challenge.name) + '?solution=' + encodeURIComponent(encodeFcc(challenge.solution)), target='_blank')= removeOldTerms(challenge.name) + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}?solution=#{encodeURIComponent(encodeFcc(challenge.solution))}', target='_blank')= challenge.name else - a(href='/challenges/' + removeOldTerms(challenge.name))= removeOldTerms(challenge.name) + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}')= challenge.name if (challenges.length > 0) .col-sm-12 table.table.table-striped @@ -160,20 +146,20 @@ block content th.col-xs-2.hidden-xs Solution for challenge in challenges tr - td.col-xs-5.hidden-xs= removeOldTerms(challenge.name) + td.col-xs-5.hidden-xs= challenge.name td.col-xs-2.hidden-xs= challenge.completedDate ? challenge.completedDate : 'Not Available' td.col-xs-2.hidden-xs= challenge.lastUpdated ? challenge.lastUpdated : '' td.col-xs-2.hidden-xs if (challenge.solution && challenge.name) - a(href='/challenges/' + removeOldTerms(challenge.name) + '?solution=' + encodeURIComponent(encodeFcc(challenge.solution)), target='_blank') View solution + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}?solution=#{encodeURIComponent(encodeFcc(challenge.solution))}', target='_blank') View solution else if (challenge.name) - a(href='/challenges/' + removeOldTerms(challenge.name)) View this challenge + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}') View this challenge else span N/A td.col-xs-12.visible-xs if (challenge.solution && challenge.name) - a(href='/challenges/' + removeOldTerms(challenge.name) + '?solution=' + encodeURIComponent(encodeFcc(challenge.solution)), target='_blank')= removeOldTerms(challenge.name) + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}?solution=#{encodeURIComponent(encodeFcc(challenge.solution))}', target='_blank')= challenge.name else if (challenge.name) - a(href='/challenges/' + removeOldTerms(challenge.name))= removeOldTerms(challenge.name) + a(href='/challenges/#{challenge.block}/#{challenge.dashedName}')= challenge.name else span N/A