From 606bfd7c88a0f59790c1c018d21dc1e9a6560a5b Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 1 Aug 2016 16:54:33 -0700 Subject: [PATCH] Fix(accounts): show challenge info on user profile This changes the behavior of the user profile page to pull the current challenge info from our challenge map and overwrite the user challenge. This should also make name changes point to the correct challenge regardless of the info saved to the user profile --- .../challenges/redux/fetch-challenges-saga.js | 13 +- common/utils/create-name-id-map.js | 12 ++ server/boot/user.js | 49 ++++--- server/services/map.js | 128 +---------------- server/utils/map.js | 136 ++++++++++++++++++ server/views/account/show.jade | 38 ++--- 6 files changed, 197 insertions(+), 179 deletions(-) create mode 100644 common/utils/create-name-id-map.js create mode 100644 server/utils/map.js 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