├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app ├── app.js ├── utils │ └── transform.js └── views │ ├── app.jsx │ ├── layout.hbs │ ├── person.jsx │ ├── timezone.jsx │ └── timezoneList.jsx ├── assets └── stylesheets │ └── index.styl ├── index.js ├── package.json └── public ├── favicon.png ├── images ├── arrow-keys.png └── worldmap.png └── js └── bundle.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | *.log 4 | public/stylesheets/*.css -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Dan Farrelly 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 11 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 12 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 13 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 14 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 15 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 16 | SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node ./index.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timezone 2 | 3 | > **Note** For the repo for the SaaS app **[Timezone.io](http://timezone.io)** 4 | head over to **[timezoneio/timezoneio](https://github.com/timezoneio/timezoneio)**. 5 | This project is the original, simple version meant to be self-hosted. Please 6 | feel free to sign up for free at **[Timezone.io](http://timezone.io)** and 7 | contribute issues and pull requests on that project's repo! 8 | 9 | Timezone is an application aimed at helping remote teams by making it easier 10 | to see where and **when** their coworkers are. This project was the basis for 11 | the larger [Timezone.io](https://github.com/timezoneio/timezoneio) project and 12 | is meant for self-hosting. 13 | 14 | ![Screenshot](https://dl.dropboxusercontent.com/u/50627698/timezone-github.png) 15 | 16 | # Setup 17 | 18 | Clone this repo and add a `people.json` file in the repo's root directory. 19 | Timezone codes for the `tz` field can be found [here](http://momentjs.com/timezone/). 20 | Each person object should have data in the following format: 21 | 22 | ```json 23 | [ 24 | { 25 | "name": "Dan", 26 | "avatar": "https://d389zggrogs7qo.cloudfront.net/images/team/dan.jpg", 27 | "city": "NYC", 28 | "tz": "America/New_York" 29 | }, 30 | { 31 | "name": "Niel", 32 | "avatar": "https://d389zggrogs7qo.cloudfront.net/images/team/niel.jpg", 33 | "city": "Cape Town", 34 | "tz": "Africa/Johannesburg" 35 | } 36 | ] 37 | ``` 38 | 39 | # Configuration 40 | 41 | By default, timezone uses port 3000. This port can be changed by setting 42 | the environment variable, `PORT`. i.e. `PORT=80` to use port 80. 43 | 44 | # Deploy 45 | 46 | This project is designed with a Procfile to deploy to a Heroku instance. Please 47 | check with Heroku's up to date documentation for any latest changes. You should 48 | be able to commit your changes in your forked repo (including adding your own 49 | people.json file) then run: 50 | 51 | ```bash 52 | $ heroku create 53 | $ git push heroku master 54 | ``` 55 | 56 | 57 | # Development 58 | 59 | You must have [Node.js](http://nodejs.org/) and [Browserify](http://browserify.org/) 60 | installed on your system to compile the project assets. After install Node.js, run: 61 | 62 | ```bash 63 | $ npm install -g browserify 64 | ``` 65 | 66 | To run the server and download all dependencies for the project run this in the 67 | project root directory: 68 | 69 | ```bash 70 | $ npm install 71 | ``` 72 | 73 | `bundle.js` contains all of the necessary scripts and data for the client. 74 | To re-build this file with Browserify run: 75 | 76 | ```bash 77 | $ npm run build 78 | ``` 79 | 80 | Now to start the server on localhost:3000 you can run: 81 | 82 | ```bash 83 | $ node ./index.js 84 | ``` 85 | 86 | **Note:** These docs are very basic and need some more love. I'll add more info 87 | soon :) 88 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var moment = require('moment-timezone'); 3 | 4 | var transform = require('./utils/transform.js'); 5 | var App = require('./views/app.jsx'); 6 | 7 | 8 | // Organize into timezones 9 | var time = moment(); 10 | var timezones = transform(time, window.people); 11 | 12 | // Add the component to the DOM 13 | var targetNode = document.querySelector('#app'); 14 | 15 | React.render( 16 | React.createElement(App, { 17 | time: time, 18 | timezones: timezones 19 | }), 20 | targetNode 21 | ); 22 | 23 | 24 | var KEY = { 25 | LEFT: 37, 26 | RIGHT: 39 27 | }; 28 | 29 | // Listen to keyup for timechange 30 | window.addEventListener('keyup', function(e){ 31 | 32 | if (e.keyCode === KEY.RIGHT){ 33 | time.add(1, 'h'); 34 | } else if (e.keyCode === KEY.LEFT){ 35 | time.subtract(1, 'h'); 36 | } 37 | 38 | // Push new data to re-render component 39 | React.render( 40 | React.createElement(App, { 41 | time: time, 42 | timezones: timezones 43 | }), 44 | targetNode 45 | ); 46 | 47 | }); 48 | 49 | function reRender() { 50 | 51 | var now = moment(); 52 | if (now.hour() === time.hour() && now.minute() === time.minute()) return; 53 | 54 | time.hour( now.hour() ); 55 | time.minute( now.minute() ); 56 | 57 | React.render( 58 | React.createElement(App, { 59 | time: time, 60 | timezones: timezones 61 | }), 62 | targetNode 63 | ); 64 | 65 | } 66 | 67 | // Check every 10 seconds for an updated time 68 | setInterval(reRender, 1000 * 10); 69 | 70 | // Check on window focus 71 | window.onfocus = reRender; 72 | -------------------------------------------------------------------------------- /app/utils/transform.js: -------------------------------------------------------------------------------- 1 | var moment = require('moment-timezone'); 2 | 3 | 4 | function appendTime(time, person) { 5 | person.time = moment( time ).tz( person.tz ); 6 | } 7 | 8 | function sortByTimezone(a, b){ 9 | return a.time.utcOffset() - b.time.utcOffset(); 10 | } 11 | 12 | 13 | module.exports = function transform(time, people) { 14 | 15 | // Append a moment date to each person 16 | people.forEach(appendTime.bind(people, time)); 17 | people.sort(sortByTimezone); 18 | 19 | var timezones = people.reduce(function(zones, person){ 20 | var last = zones[ zones.length - 1 ]; 21 | var offset = last && last.people[0].time.utcOffset(); 22 | 23 | if (last && offset === person.time.utcOffset()) { 24 | last.people.push(person); 25 | } else { 26 | zones.push({ 27 | tz: person.tz, 28 | people: [ person ] 29 | }); 30 | } 31 | 32 | return zones; 33 | }, []); 34 | 35 | timezones.forEach(function(timezone){ 36 | if (timezone.people.length / people.length > 0.2) 37 | timezone.major = true; 38 | }); 39 | 40 | return timezones; 41 | 42 | }; -------------------------------------------------------------------------------- /app/views/app.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var TimezoneList = require('./timezoneList.jsx'); 3 | 4 | module.exports = React.createClass({ 5 | getInitialState: function() { 6 | return { 7 | timeFormat: 'h:mm a' 8 | }; 9 | }, 10 | toggleTimeFormat: function() { 11 | this.setState({ 12 | timeFormat: this.state.timeFormat === 'h:mm a' ? 'H:mm' : 'h:mm a' 13 | }); 14 | }, 15 | render: function() { 16 | return ( 17 |
18 | 21 |

Use left & right arrow keys to change the time

22 |
23 | ); 24 | } 25 | }); -------------------------------------------------------------------------------- /app/views/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Timezone 5 | 6 | 7 | 8 | 9 | 10 |
{{{body}}}
11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/views/person.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var moment = require('moment-timezone'); 3 | 4 | module.exports = React.createClass({ 5 | person: function(){ 6 | return this.props.model; 7 | }, 8 | 9 | hasAvatar: function() { 10 | return !!this.person().avatar; 11 | }, 12 | 13 | renderAvatarBlock: function() { 14 | if(this.hasAvatar()) { 15 | return ; 16 | } else { 17 | return {this.getNameInitials()}; 18 | } 19 | }, 20 | 21 | getNameInitials: function() { 22 | var hasInitial = new RegExp(/[A-Z]/); 23 | if (hasInitial.test(this.person().name)) { 24 | return this.person().name.replace(/[^A-Z]/g, ''); 25 | } else { 26 | return this.person().name.substring(0,1); 27 | } 28 | }, 29 | 30 | render: function() { 31 | var person = this.person(); 32 | 33 | return
34 | {this.renderAvatarBlock()} 35 |
36 |

{person.name}

37 |

{person.city}

38 |
39 |
; 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /app/views/timezone.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var moment = require('moment-timezone'); 3 | var Person = require('./person.jsx'); 4 | 5 | var PEOPLE_PER_COL = 7; 6 | 7 | module.exports = React.createClass({ 8 | 9 | getCountsOf: function(list, param) { 10 | return list 11 | .map(function(el) { 12 | return el[param]; 13 | }) 14 | .sort() 15 | .reduce(function(counts, el) { 16 | if (!counts[el]) 17 | counts[el] = 1; 18 | else 19 | counts[el]++; 20 | return counts; 21 | }, {}); 22 | }, 23 | 24 | getHighestOccuring: function(counts) { 25 | var keys = Object.keys(counts); 26 | return keys.reduce(function(prev, curr){ 27 | return counts[curr] > counts[prev] ? curr : prev; 28 | }, keys[0]); 29 | }, 30 | 31 | getTopTimezone: function() { 32 | var tzCounts = this.getCountsOf(this.props.model.people, 'tz'); 33 | var topTz = this.getHighestOccuring(tzCounts); 34 | 35 | return topTz.replace(/.+\//g, '').replace(/_/g,' '); 36 | }, 37 | 38 | getTopCity: function() { 39 | var cityCounts = this.getCountsOf(this.props.model.people, 'city'); 40 | var topCity = this.getHighestOccuring(cityCounts); 41 | 42 | return cityCounts[topCity] === 1 && this.props.model.people.length > 1 ? 43 | this.getTopTimezone() : 44 | topCity; 45 | }, 46 | 47 | getPeopleColumns: function() { 48 | this.props.model.people.sort(function(a, b){ 49 | return a.name > b.name ? 1 : -1; 50 | }); 51 | 52 | return this.props.model.people.reduce(function(cols, person){ 53 | if (cols[cols.length - 1] && 54 | cols[cols.length - 1].length < PEOPLE_PER_COL) 55 | cols[cols.length - 1].push(person); 56 | else 57 | cols.push([ person ]); 58 | return cols; 59 | }, []); 60 | }, 61 | 62 | render: function() { 63 | 64 | // We clone the time object itself so the this time is bound to 65 | // the global app time 66 | 67 | var localTime = moment( this.props.time ).tz( this.props.model.tz ), 68 | displayTime = localTime.format( this.props.timeFormat ), 69 | offset = localTime.format('Z'); 70 | 71 | var timezoneClasses = 'timezone timezone-hour-' + localTime.hour(); 72 | 73 | if (this.props.model.major) timezoneClasses += ' timezone-major'; 74 | 75 | var topCity = this.getTopCity(); 76 | var columns = this.getPeopleColumns(); 77 | 78 | return
79 |
80 |

{displayTime}

81 |

{topCity}

82 |

{offset}

83 |
84 |
85 | {columns.map(function(column, idx){ 86 | 87 | return
88 | {column.map(function(person, idx) { 89 | var key = person.tz + idx; 90 | return ; 91 | })} 92 |
93 | })} 94 |
95 |
; 96 | } 97 | }); 98 | -------------------------------------------------------------------------------- /app/views/timezoneList.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var Timezone = require('./timezone.jsx'); 3 | 4 | module.exports = React.createClass({ 5 | render: function() { 6 | return
7 | {this.props.timezones.map(function(timezone){ 8 | return ; 12 | }.bind(this))} 13 |
; 14 | } 15 | }); -------------------------------------------------------------------------------- /assets/stylesheets/index.styl: -------------------------------------------------------------------------------- 1 | 2 | // Variables 3 | 4 | $brandColor = #42A5F5 5 | $brandColorText = rgb(255, 255, 255) 6 | $textColor = rgb(50, 50, 50) 7 | 8 | $avatarSize = 60px 9 | 10 | 11 | // Basic 12 | 13 | * 14 | box-sizing: border-box 15 | 16 | html 17 | height: 100% 18 | 19 | body 20 | height: 100% 21 | margin: 0 22 | 23 | background: #fff 24 | 25 | font-family: 'Roboto', 'Open Sans', Helvetica, sans-serif 26 | font-weight: 300 27 | color: $textColor 28 | 29 | h1, h2, h3, h4, h5 30 | font-weight: normal 31 | 32 | 33 | // Page elements 34 | 35 | #app, 36 | .container 37 | height: 100% 38 | 39 | .instructions 40 | position: fixed 41 | bottom: 1em 42 | left: 0 43 | right: 0 44 | margin: 0 auto; 45 | font-size: 0.8em 46 | font-style: italic 47 | text-align: center 48 | 49 | .timezone-list 50 | display: -webkit-box 51 | display: -moz-box 52 | display: -ms-flexbox 53 | display: -webkit-flex 54 | display: flex 55 | height: 100%; 56 | justify-content: center 57 | 58 | .timezone 59 | padding: 1em 60 | 61 | // .timezone-major 62 | 63 | .timezone-people 64 | display: flex 65 | 66 | .timezone-people-column 67 | min-width: $avatarSize 68 | margin-right: 1em 69 | 70 | // Timezones that are in nighttime 71 | for hour in 0 1 2 3 4 5 6 7 22 23 72 | .timezone-hour-{hour} 73 | .avatar 74 | filter: grayscale(80%) 75 | 76 | .timezone-header 77 | margin: 0.5em 0 78 | width: 80px 79 | text-align: left 80 | white-space: nowrap; 81 | 82 | p 83 | margin: 0.3em 0 84 | overflow: hidden 85 | text-overflow: ellipsis 86 | 87 | .timezone-time 88 | margin: 0.3em 0 89 | 90 | .timezone-name 91 | font-size: 0.6em 92 | text-transform: uppercase 93 | font-weight: bold 94 | color: lighten($textColor, 20%) 95 | 96 | .timezone-offset 97 | margin: 0 98 | font-size: 0.7em 99 | color: #61676F 100 | 101 | .person 102 | position: relative 103 | margin: 0.8em 0 104 | 105 | &:hover 106 | .person-info 107 | opacity: 1 108 | 109 | .person-info 110 | position: absolute 111 | left: -20% 112 | right: -20% 113 | bottom: -2em 114 | z-index: 1000 115 | width: 140% 116 | max-width: 110px 117 | margin: 0 auto 118 | padding: 0.3em 0.4em 0.4em 119 | 120 | opacity: 0 121 | transition: all 200ms 122 | background: #fff 123 | border-radius: 2px 124 | box-shadow: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.24) 125 | font-size: 0.8em 126 | font-weight: bold 127 | text-align: center 128 | 129 | p 130 | margin: 0 131 | line-height: 1.1em 132 | 133 | .person-name 134 | margin-bottom: 0.3em 135 | 136 | .person-city 137 | font-size: 0.8em 138 | font-weight: normal 139 | white-space: nowrap 140 | text-overflow: ellipsis 141 | overflow: hidden 142 | 143 | avatar-block() 144 | display: block 145 | height: 100% 146 | min-height: $avatarSize 147 | width: 100% 148 | max-width: $avatarSize 149 | border-radius: 50% 150 | 151 | .avatar 152 | avatar-block() 153 | 154 | .pseudo-avatar 155 | avatar-block() 156 | line-height: $avatarSize 157 | text-align: center 158 | background-color: lightgray 159 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | var logger = require('morgan'); 4 | var stylus = require('stylus'); 5 | var autoprefixer = require('autoprefixer-stylus'); 6 | var React = require('react'); 7 | var moment = require('moment-timezone'); 8 | var fs = require('fs'); 9 | 10 | var people = require('./people.json'); 11 | var transform = require('./app/utils/transform.js'); 12 | 13 | // Allow direct requiring of .jsx files 14 | require('node-jsx').install({extension: '.jsx'}); 15 | 16 | // Should switch this out for proper Handlebars usage 17 | function template (body, done) { 18 | fs.readFile('./app/views/layout.hbs', 'utf8', function (err, layout) { 19 | if (err) done(err); 20 | done(null, layout 21 | .replace('{{{body}}}', body) 22 | .replace('{{{people}}}', JSON.stringify(people))); 23 | }); 24 | } 25 | 26 | app.use(logger('common')); 27 | 28 | // Stylus 29 | app.use( 30 | stylus.middleware({ 31 | src: __dirname + '/assets', 32 | dest: __dirname + '/public', 33 | compile: function (str, path, fn) { 34 | return stylus(str) 35 | .use(autoprefixer()) 36 | .set('filename', path); 37 | // .set('compress', true); 38 | } 39 | }) 40 | ); 41 | 42 | app.get('/', function(err, res){ 43 | 44 | var App = require('./app/views/app.jsx'); 45 | 46 | // Organize into timezones 47 | var time = moment(); 48 | var timezones = transform(time, people); 49 | 50 | var body = React.renderToString( 51 | React.createElement(App, { 52 | time: time, 53 | timezones: timezones 54 | }) 55 | ); 56 | 57 | template(body, function(err, html){ 58 | if (err) throw err; 59 | res.send(html); 60 | }); 61 | 62 | }); 63 | 64 | // Static files 65 | app.use(express.static(__dirname + '/public')); 66 | 67 | app.listen(process.env.PORT || 3000); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Timezone", 3 | "version": "0.2.0", 4 | "description": "Remote teams + Timezones", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "http://github.com/djfarrelly/timezone.git" 9 | }, 10 | "scripts": { 11 | "start": "node ./index.js", 12 | "build": "browserify -t reactify ./app/app.js -o ./public/js/bundle.js", 13 | "watch": "watchify -t reactify ./app/app.js -o ./public/js/bundle.js -v", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "author": "Dan Farrelly (http://danfarrelly.nyc)", 17 | "license": "MIT", 18 | "dependencies": { 19 | "autoprefixer-stylus": "^0.5.0", 20 | "express": "^4.10.7", 21 | "moment-timezone": "^0.3.1", 22 | "morgan": "^1.5.1", 23 | "node-jsx": "^0.12.4", 24 | "react": "^0.13.1", 25 | "react-tools": "^0.13.1", 26 | "stylus": "^0.50.0" 27 | }, 28 | "devDependencies": { 29 | "reactify": "^1.1.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djfarrelly/timezone/d95224d4acf87faa709cc6d3f01bbb0fc456141c/public/favicon.png -------------------------------------------------------------------------------- /public/images/arrow-keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djfarrelly/timezone/d95224d4acf87faa709cc6d3f01bbb0fc456141c/public/images/arrow-keys.png -------------------------------------------------------------------------------- /public/images/worldmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djfarrelly/timezone/d95224d4acf87faa709cc6d3f01bbb0fc456141c/public/images/worldmap.png --------------------------------------------------------------------------------