├── .meteor ├── .gitignore ├── release ├── platforms ├── .id ├── .finished-upgraders ├── packages └── versions ├── .eslintrc ├── .eslintignore ├── .gitignore ├── packages └── meteor-harvest-master │ ├── harvest.js │ ├── .gitignore │ ├── README.md │ ├── package.js │ └── LICENSE ├── collections ├── utils.js ├── projects.js ├── tasks.js ├── time-entries.js └── user.js ├── client ├── imports │ ├── startup │ │ └── accounts-config.js │ └── components │ │ ├── PieChartCard.jsx │ │ ├── MainFrame.jsx │ │ ├── LineChartCard.jsx │ │ ├── Login.jsx │ │ ├── BarChartCard.jsx │ │ ├── BarChartRadioGroup.jsx │ │ ├── Dashboard.jsx │ │ ├── JiraLinker.jsx │ │ ├── PieChart.jsx │ │ ├── LineChart.jsx │ │ └── BarChart.jsx ├── main.html ├── main.jsx ├── routes.jsx └── stylesheets │ ├── main.scss │ └── bootstrap.css ├── server ├── methods.js ├── publications.js ├── scheduler.js ├── accounts.js └── jira-connector.js ├── .travis.yml ├── tests └── app-should-exist.js └── package.json /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.3.2.2 2 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .design/ 2 | client/js/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | settings.json 4 | -------------------------------------------------------------------------------- /packages/meteor-harvest-master/harvest.js: -------------------------------------------------------------------------------- 1 | Harvest = Npm.require('harvest'); 2 | -------------------------------------------------------------------------------- /packages/meteor-harvest-master/.gitignore: -------------------------------------------------------------------------------- 1 | .meteor/local 2 | .meteor/meteorite 3 | .npm 4 | -------------------------------------------------------------------------------- /packages/meteor-harvest-master/README.md: -------------------------------------------------------------------------------- 1 | View the documentation at https://github.com/log0ymxm/node-harvest. 2 | 3 | The `Harvest` object is exposed via this package. -------------------------------------------------------------------------------- /collections/utils.js: -------------------------------------------------------------------------------- 1 | Utils = { 2 | startOf (interval){ 3 | return moment().startOf(interval).toDate(); 4 | }, 5 | 6 | endOf (interval){ 7 | return moment().endOf(interval).toDate(); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /client/imports/startup/accounts-config.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | import { Router, browserHistory} from 'react-router'; 3 | 4 | Accounts.onLogin((user) => { 5 | browserHistory.push('/'); 6 | }); 7 | -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | App 3 | 4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /server/methods.js: -------------------------------------------------------------------------------- 1 | 2 | Meteor.startup(() => { 3 | 4 | if (Meteor.isServer){ 5 | 6 | 7 | Meteor.methods({ 8 | 9 | createMeteorUser: function(data) { 10 | return Accounts.createUser({email: data.user.email, fromWorker: true}); 11 | } 12 | }) 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "5" 5 | before_install: 6 | - curl https://install.meteor.com | /bin/sh 7 | - export PATH="$HOME/.meteor:$PATH" 8 | - npm install -g chimp 9 | before_script: 10 | - nohup bash -c "meteor 2>&1 &" && sleep 30; cat nohup.out 11 | services: 12 | - mongodb 13 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 16qrb6q13b5i615cswz8 8 | -------------------------------------------------------------------------------- /server/publications.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | 3 | Meteor.startup(() => { 4 | 5 | Meteor.publish('TimeEntries.all', function() { 6 | if (this.userId) { 7 | return TimeEntries.find(); 8 | } 9 | return this.ready(); 10 | }); 11 | 12 | 13 | Meteor.publish('Users.me', function() { 14 | if (this.userId) { 15 | return this.user; 16 | } 17 | return this.ready(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /client/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { render } from 'react-dom'; 4 | import {renderRoutes} from './routes.jsx'; 5 | import Accounts from './imports/startup/accounts-config.js'; 6 | import injectTapEventPlugin from 'react-tap-event-plugin'; 7 | 8 | Meteor.startup(() => { 9 | injectTapEventPlugin(); 10 | render(renderRoutes(), document.getElementById('render-target')); 11 | }); 12 | -------------------------------------------------------------------------------- /server/scheduler.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Accounts } from 'meteor/accounts-base'; 3 | 4 | 5 | Jobs = new JobCollection('jobs'); 6 | Jobs.allow({ admin: () => true }); 7 | 8 | Meteor.startup(() => { 9 | // If there are no users in the db, add one 10 | 11 | // Start the job queue running 12 | Jobs.startJobServer(); 13 | 14 | // Create new job on startup 15 | const job = new Job(Jobs,'import', {}).save(); 16 | }); 17 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | -------------------------------------------------------------------------------- /packages/meteor-harvest-master/package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'poetic:harvest-local', 3 | version: '0.1.5-rc.1', 4 | summary: 'Packaged harvest plugin.', 5 | git: 'https://github.com/poetic/meteor-harvest.git', 6 | documentation: 'README.md' 7 | }); 8 | 9 | Package.onUse(function(api) { 10 | api.versionsFrom('1.2.0.2'); 11 | api.addFiles('harvest.js', 'server'); 12 | 13 | api.export( 'Harvest','server' ); 14 | }); 15 | 16 | Npm.depends({harvest: '0.1.4'}); 17 | -------------------------------------------------------------------------------- /collections/projects.js: -------------------------------------------------------------------------------- 1 | //import { SimpleSchema } from 'meteor/aldeed:simple-schema'; 2 | Projects = new Mongo.Collection('projects', {idGeneration: 'MONGO'}); 3 | 4 | 5 | ProjectSchema = new SimpleSchema({ 6 | name: { 7 | type: String, 8 | optional: true 9 | }, 10 | description: { 11 | type: String, 12 | optional: true 13 | }, 14 | harvestId: { 15 | type: Number, 16 | optional: true 17 | } 18 | }); 19 | 20 | 21 | Projects.attachSchema(ProjectSchema); 22 | if( Meteor.isServer ){ 23 | Projects._ensureIndex({harvestId: 1}); 24 | } 25 | -------------------------------------------------------------------------------- /tests/app-should-exist.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* eslint-disable func-names, prefer-arrow-callback */ 3 | 4 | // These are Chimp globals 5 | /* globals browser assert server */ 6 | 7 | // PLEASE READ: http://guide.meteor.com/testing.html#acceptance-testing 8 | describe('app should exist', function () { 9 | beforeEach(function () { 10 | browser.url('http://localhost:3000'); 11 | }); 12 | 13 | it('can create a list @watch', function () { 14 | const doesExist = browser.waitForExist('render-target'); 15 | 16 | assert.equal(doesExist, true); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/imports/components/PieChartCard.jsx: -------------------------------------------------------------------------------- 1 | import {Card, CardActions, CardHeader, CardMedia, CardTitle, CardText} from 'material-ui/Card'; 2 | import React, {Component} from 'react'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import PieChart from './PieChart.jsx'; 5 | 6 | const style = { 7 | margin: '30px' 8 | } 9 | 10 | export default class PieChartCard extends Component { 11 | 12 | render() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/routes.jsx: -------------------------------------------------------------------------------- 1 | import { Router, Route, browserHistory, IndexRoute } from 'react-router' 2 | import React, {Component} from 'react'; 3 | import MainFrame from './imports/components/MainFrame.jsx'; 4 | import Login from './imports/components/Login.jsx'; 5 | import Dashboard from './imports/components/Dashboard.jsx'; 6 | import JiraLinker from './imports/components/JiraLinker.jsx'; 7 | 8 | 9 | 10 | export const renderRoutes = () => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /client/imports/components/MainFrame.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import getMuiTheme from 'material-ui/styles/getMuiTheme'; 3 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; 4 | import darkBaseTheme from 'material-ui/styles/baseThemes/darkBaseTheme'; 5 | import AppBar from 'material-ui/AppBar'; 6 | 7 | const darkMuiTheme = getMuiTheme(darkBaseTheme); 8 | 9 | 10 | export default class MainFrame extends Component { 11 | 12 | render() { 13 | return ( 14 | 15 | 16 |
17 | 20 | {this.props.children} 21 | 22 |
23 | 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/imports/components/LineChartCard.jsx: -------------------------------------------------------------------------------- 1 | import {Card, CardActions, CardHeader, CardMedia, CardTitle, CardText} from 'material-ui/Card'; 2 | import React, {Component} from 'react'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import LineChart from './LineChart.jsx'; 5 | import { createContainer } from 'meteor/react-meteor-data'; 6 | 7 | const style = { 8 | margin: '30px' 9 | } 10 | 11 | class LineChartCard extends Component { 12 | 13 | render() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | } 26 | 27 | export default createContainer(() => { 28 | let handle = Meteor.subscribe("TimeEntries.all"); 29 | return { 30 | timeEntries: TimeEntries.find().fetch(), 31 | }; 32 | }, LineChartCard); 33 | -------------------------------------------------------------------------------- /client/imports/components/Login.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import ReactDOM from 'react-dom'; 4 | import { Template } from 'meteor/templating'; 5 | import { Blaze } from 'meteor/blaze'; 6 | import { Router, browserHistory} from 'react-router'; 7 | 8 | export default class Login extends Component { 9 | 10 | constructor(props, context) { 11 | super(props); 12 | } 13 | 14 | componentDidMount() { 15 | 16 | // Use Meteor Blaze to render login buttons 17 | this.view = Blaze.render(Template.loginButtons, 18 | ReactDOM.findDOMNode(this.refs.container)); 19 | } 20 | 21 | componentWillUnmount() { 22 | // Clean up Blaze view 23 | Blaze.remove(this.view); 24 | } 25 | 26 | render() { 27 | // Just render a placeholder container that will be filled in 28 | return ( 29 | 30 |
31 | 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "butler", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run", 6 | "test": "npm run lint && chimp --mocha --path=tests --browser=phantomjs", 7 | "lint": "eslint . --ext .jsx,.js", 8 | "fix": "eslint . --ext .jsx,.js --fix", 9 | "watch": "chimp --ddp=http://localhost:3000 --watch --mocha --path=tests" 10 | }, 11 | "dependencies": { 12 | "highcharts": "^4.2.5", 13 | "highcharts-more": "^0.1.2", 14 | "highcharts-solid-gauge": "^0.1.2", 15 | "jira-connector": "^2.3.0", 16 | "latest-version": "^2.0.0", 17 | "lodash": "^4.11.1", 18 | "material-ui": "^0.15.0", 19 | "meteor-node-stubs": "~0.2.0", 20 | "param-store": "poetic/param-store", 21 | "react": "^15.0.1", 22 | "react-addons-css-transition-group": "^15.0.2", 23 | "react-addons-pure-render-mixin": "^15.0.2", 24 | "react-dom": "^15.0.1", 25 | "react-highcharts": "^8.3.2", 26 | "react-router": "^2.4.0", 27 | "react-tap-event-plugin": "^1.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/imports/components/BarChartCard.jsx: -------------------------------------------------------------------------------- 1 | import {Card, CardActions, CardHeader, CardMedia, CardTitle, CardText} from 'material-ui/Card'; 2 | import React, {Component} from 'react'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import BarChart from './BarChart.jsx'; 5 | import BarChartRadioGroup from './BarChartRadioGroup.jsx'; 6 | 7 | const style = { 8 | margin: '30px' 9 | } 10 | 11 | export default class BarChartCard extends Component { 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | childValue: "week" 17 | } 18 | } 19 | 20 | handleToggle(childValue) { 21 | this.setState({ 22 | childValue: childValue 23 | }) 24 | } 25 | 26 | render() { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /collections/tasks.js: -------------------------------------------------------------------------------- 1 | Tasks = new Mongo.Collection("tasks",{idGeneration: 'MONGO'}); 2 | 3 | 4 | TaskSchema = new SimpleSchema({ 5 | name: { 6 | type: String, 7 | optional: true 8 | }, 9 | billable: { 10 | type: Boolean, 11 | defaultValue: true, 12 | optional: true 13 | }, 14 | harvestId: { 15 | type: Number, 16 | optional: true 17 | } 18 | }); 19 | 20 | Tasks.attachSchema(TaskSchema); 21 | if( Meteor.isServer ){ 22 | Tasks._ensureIndex({harvestId: 1}); 23 | } 24 | 25 | Tasks.groupTimeEntriesByTask = timeEntries => { 26 | let timeEntriesByTaskId = _.groupBy(timeEntries, entry => entry.taskId); 27 | 28 | return Object.keys(timeEntriesByTaskId).map(taskId => { 29 | let timeEntriesForTask = timeEntriesByTaskId[taskId]; 30 | 31 | return { 32 | taskName: Tasks.getName(taskId), 33 | duration: TimeEntries.getTotalDuration(timeEntriesForTask), 34 | }; 35 | }); 36 | }; 37 | 38 | Tasks.getName = taskId => { 39 | return Tasks.findOne({_id: taskId}).name; 40 | }; 41 | 42 | Tasks.getConcessedTaskIds = () => { 43 | return Tasks.find({name: {$regex: /concession/}}).map(task => task._id); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/meteor-harvest-master/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Poetic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /client/imports/components/BarChartRadioGroup.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import {RadioButton, RadioButtonGroup} from 'material-ui/RadioButton'; 4 | 5 | 6 | const style = { 7 | display: 'inline-block', 8 | width: '150px' 9 | } 10 | 11 | export default class BarChartRadioGroup extends Component { 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | period: "week" 17 | } 18 | } 19 | 20 | _toggleRadioButton(event, value) { 21 | this.props.toggle(value) 22 | this.setState({ 23 | period: value 24 | }) 25 | } 26 | 27 | render() { 28 | return ( 29 | 30 | 35 | 40 | 45 | 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/accounts.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | 3 | Accounts.onCreateUser((options, user) => { 4 | var harvestUser = {}; 5 | if (options.fromWorker === true) { 6 | harvestUser = user; 7 | } else { 8 | 9 | // restore default behavior 10 | if (options.profile) { user.profile = options.profile } 11 | 12 | // return user being created by harvest sync 13 | if (! user.services) { return user } 14 | 15 | let service = _.keys(user.services)[0]; 16 | let { email } = user.services[service]; 17 | harvestUser = Meteor.users.findOne({'emails.address': email}); 18 | 19 | // new employee that doesn't yet have harvest info 20 | if (! harvestUser) { throw new Meteor.Error('cannot log in', 'cannot log in') } 21 | 22 | // attach servies to existing harvest user 23 | if (! harvestUser.services) { 24 | harvestUser.services = { 25 | resume: {loginTokens: []}, 26 | [`${service}`]: user.services[service], 27 | }; 28 | } 29 | 30 | // remove the existing harvest user and return the user object to 31 | // let the accounts service reinsert 32 | Meteor.users.remove({_id: harvestUser._id}); 33 | //return harvestUser; 34 | } 35 | return harvestUser; 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base # Packages every Meteor app needs to have 8 | mobile-experience # Packages for a great mobile UX 9 | mongo # The database Meteor supports right now 10 | reactive-var # Reactive variable for tracker 11 | jquery # Helpful client-side library 12 | tracker # Meteor's client-side reactive programming library 13 | 14 | standard-minifier-js # JS minifier run for production mode 15 | es5-shim # ECMAScript 5 compatibility for older browsers. 16 | ecmascript # Enable ECMAScript2015+ syntax in app code 17 | 18 | static-html 19 | aldeed:simple-schema 20 | aldeed:collection2 21 | zimme:collection-behaviours 22 | dburles:collection-helpers 23 | poetic:trello 24 | accounts-password 25 | zimme:collection-timestampable 26 | percolate:synced-cron 27 | momentjs:moment 28 | matb33:collection-hooks 29 | risul:moment-timezone 30 | meteorhacks:aggregate 31 | mikowals:batch-insert 32 | poetic:harvest-local 33 | accounts-base 34 | accounts-ui 35 | accounts-google 36 | fourseven:scss 37 | seba:minifiers-autoprefixer 38 | vsivsi:job-collection 39 | twbs:bootstrap 40 | session 41 | react-meteor-data 42 | -------------------------------------------------------------------------------- /client/imports/components/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { createContainer } from 'meteor/react-meteor-data'; 4 | import { Router, browserHistory} from 'react-router'; 5 | import LineChartCard from './LineChartCard.jsx'; 6 | import BarChartCard from './BarChartCard.jsx'; 7 | import PieChartCard from './PieChartCard.jsx'; 8 | import CircularProgress from 'material-ui/CircularProgress'; 9 | 10 | class Dashboard extends Component { 11 | componentDidMount() { 12 | let { user } = this.props; 13 | 14 | if (!user){ 15 | browserHistory.push('/login'); 16 | } else if (user.profile.accessToken == null){ 17 | browserHistory.push('/jiraLinker'); 18 | } 19 | } 20 | 21 | render() { 22 | // Just render a placeholder container that will be filled in 23 | let { user } = this.props; 24 | return ( 25 |
26 |
27 | 28 |
29 |
30 | 31 | 32 |
33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default createContainer(() => { 45 | let handle = Meteor.subscribe("Users.me"); 46 | return { 47 | user: Meteor.user(), 48 | }; 49 | }, Dashboard); 50 | -------------------------------------------------------------------------------- /collections/time-entries.js: -------------------------------------------------------------------------------- 1 | //import { SimpleSchema } from 'meteor/aldeed:simple-schema'; 2 | TimeEntries = new Mongo.Collection("timeEntries",{idGeneration: 'MONGO'}); 3 | 4 | TimeEntrySchema = new SimpleSchema({ 5 | duration: { 6 | type: Number, 7 | decimal: true, 8 | defaultValue: 0, 9 | optional: true 10 | }, 11 | date: { 12 | type: Date, 13 | optional: true 14 | }, 15 | userId: { 16 | type: SimpleSchema.RegEx.id, 17 | optional: true 18 | }, 19 | projectId: { 20 | type: SimpleSchema.RegEx.id, 21 | optional: true 22 | }, 23 | taskId: { 24 | type: SimpleSchema.RegEx.id, 25 | optional: true 26 | }, 27 | notes: { 28 | type: String, 29 | optional: true 30 | }, 31 | harvestId: { 32 | type: Number, 33 | optional: true 34 | }, 35 | harvestTrelloCardName: { // Used for trello title recovery. Probably can be removed 36 | type: String, 37 | optional: true 38 | }, 39 | jiraId: { 40 | type: String, 41 | optional: true 42 | }, 43 | trelloId: { 44 | type: String, 45 | optional: true 46 | } 47 | }); 48 | 49 | TimeEntries.attachSchema( TimeEntrySchema ); 50 | if( Meteor.isServer ){ 51 | TimeEntries._ensureIndex({harvestId: 1}); 52 | } 53 | TimeEntries.attachBehaviour('timestampable'); 54 | 55 | TimeEntries.helpers({ 56 | taskName (){ 57 | return Tasks.findOne(this.taskId).name; 58 | }, 59 | }); 60 | 61 | 62 | TimeEntries.getTotalDuration = timeEntries => { 63 | let durations = _.compact(_.pluck(timeEntries, 'duration')); 64 | 65 | return durations.reduce((previous, current) => { 66 | return previous + current; 67 | }, 0); 68 | }; 69 | -------------------------------------------------------------------------------- /server/jira-connector.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | var JiraClient = require('jira-connector'); 3 | Future = Npm.require('fibers/future'); 4 | 5 | if (Meteor.isServer) { 6 | 7 | Meteor.methods({ 8 | getAuthorizeURL() { 9 | 10 | var future = new Future(); 11 | 12 | JiraClient.oauth_util.getAuthorizeURL({ 13 | host: Meteor.settings.jira.host, 14 | oauth: { 15 | consumer_key: Meteor.settings.jira.consumerKey, 16 | private_key: Meteor.settings.jira.privateKey 17 | } 18 | }, function (error, res) { 19 | future.return(res); 20 | }); 21 | return future.wait(); 22 | }, 23 | 24 | swapRequestTokenWithAccessToken(token, tokenSecret, oauthVerifier) { 25 | 26 | var future = new Future(); 27 | 28 | JiraClient.oauth_util.swapRequestTokenWithAccessToken({ 29 | host: Meteor.settings.jira.host, 30 | oauth: { 31 | token: token, 32 | token_secret: tokenSecret, 33 | oauth_verifier: oauthVerifier, 34 | consumer_key: Meteor.settings.jira.consumerKey, 35 | private_key: Meteor.settings.jira.privateKey 36 | } 37 | }, function (error, res) { 38 | future.return(res); 39 | }); 40 | return future.wait(); 41 | }, 42 | 43 | setAccessToken(accessToken, tokenSecret,userId) { 44 | var future = new Future(); 45 | 46 | Meteor.users.update({_id: userId}, {$set: {'profile.accessToken': accessToken, 'profile.tokenSecret': tokenSecret}}, function(err, res) { 47 | console.log(err); 48 | 49 | future.return(res); 50 | }); 51 | return future.wait(); 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.7 2 | accounts-google@1.0.9 3 | accounts-oauth@1.1.12 4 | accounts-password@1.1.8 5 | accounts-ui@1.1.9 6 | accounts-ui-unstyled@1.1.12 7 | aldeed:collection2@2.9.1 8 | aldeed:collection2-core@1.1.1 9 | aldeed:schema-deny@1.0.1 10 | aldeed:schema-index@1.0.1 11 | aldeed:simple-schema@1.5.3 12 | allow-deny@1.0.4 13 | autoupdate@1.2.9 14 | babel-compiler@6.6.4 15 | babel-runtime@0.1.8 16 | base64@1.0.8 17 | binary-heap@1.0.8 18 | blaze@2.1.7 19 | blaze-tools@1.0.8 20 | boilerplate-generator@1.0.8 21 | caching-compiler@1.0.4 22 | caching-html-compiler@1.0.6 23 | callback-hook@1.0.8 24 | check@1.2.1 25 | coffeescript@1.0.17 26 | dburles:collection-helpers@1.0.4 27 | ddp@1.2.5 28 | ddp-client@1.2.6 29 | ddp-common@1.2.5 30 | ddp-rate-limiter@1.0.4 31 | ddp-server@1.2.6 32 | deps@1.0.12 33 | diff-sequence@1.0.5 34 | ecmascript@0.4.3 35 | ecmascript-runtime@0.2.10 36 | ejson@1.0.11 37 | email@1.0.12 38 | es5-shim@4.5.10 39 | fastclick@1.0.11 40 | fourseven:scss@3.4.3 41 | geojson-utils@1.0.8 42 | google@1.1.11 43 | hot-code-push@1.0.4 44 | html-tools@1.0.9 45 | htmljs@1.0.9 46 | http@1.1.5 47 | id-map@1.0.7 48 | jquery@1.11.8 49 | launch-screen@1.0.11 50 | less@2.6.0 51 | livedata@1.0.18 52 | localstorage@1.0.9 53 | logging@1.0.12 54 | matb33:collection-hooks@0.7.15 55 | mdg:validation-error@0.2.0 56 | meteor@1.1.14 57 | meteor-base@1.0.4 58 | meteorhacks:aggregate@1.3.0 59 | meteorhacks:collection-utils@1.2.0 60 | mikowals:batch-insert@1.1.13 61 | minifier-css@1.1.11 62 | minifier-js@1.1.11 63 | minimongo@1.0.15 64 | mobile-experience@1.0.4 65 | mobile-status-bar@1.0.12 66 | modules@0.6.1 67 | modules-runtime@0.6.3 68 | momentjs:moment@2.13.1 69 | mongo@1.1.7 70 | mongo-id@1.0.4 71 | mongo-livedata@1.0.12 72 | mrt:later@1.6.1 73 | npm-bcrypt@0.8.5 74 | npm-mongo@1.4.43 75 | oauth@1.1.10 76 | oauth2@1.1.9 77 | observe-sequence@1.0.11 78 | ordered-dict@1.0.7 79 | percolate:synced-cron@1.3.2 80 | poetic:harvest-local@0.1.5-rc.1 81 | poetic:trello@0.0.3 82 | promise@0.6.7 83 | raix:eventemitter@0.1.3 84 | random@1.0.9 85 | rate-limit@1.0.4 86 | react-meteor-data@0.2.9 87 | reactive-dict@1.1.7 88 | reactive-var@1.0.9 89 | reload@1.1.8 90 | retry@1.0.7 91 | risul:moment-timezone@0.5.0_5 92 | routepolicy@1.0.10 93 | seba:minifiers-autoprefixer@1.0.1 94 | service-configuration@1.0.9 95 | session@1.1.5 96 | sha@1.0.7 97 | spacebars@1.0.11 98 | spacebars-compiler@1.0.11 99 | srp@1.0.8 100 | standard-minifier-js@1.0.6 101 | static-html@1.0.7 102 | templating@1.1.9 103 | templating-tools@1.0.4 104 | tmeasday:check-npm-versions@0.2.0 105 | tracker@1.0.13 106 | twbs:bootstrap@3.3.6 107 | ui@1.0.11 108 | underscore@1.0.8 109 | url@1.0.9 110 | vsivsi:job-collection@1.3.3 111 | webapp@1.2.8 112 | webapp-hashing@1.0.9 113 | zimme:collection-behaviours@1.1.3 114 | zimme:collection-timestampable@1.0.9 115 | -------------------------------------------------------------------------------- /client/imports/components/JiraLinker.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { Router, browserHistory} from 'react-router'; 4 | import RaisedButton from 'material-ui/RaisedButton'; 5 | import TextField from 'material-ui/TextField'; 6 | import CircularProgress from 'material-ui/CircularProgress'; 7 | 8 | const style = { 9 | marginTop: '50px' 10 | } 11 | 12 | 13 | export default class Dashboard extends Component { 14 | 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | url: '', 19 | token: '', 20 | tokenSecret: '', 21 | oauthVerifier: '', 22 | accessToken: '', 23 | step: 1 24 | } 25 | } 26 | 27 | handleTextChange(e){ 28 | this.setState({oauthVerifier: e.target.value}); 29 | } 30 | 31 | getAuthorizeURL() { 32 | var _this = this; 33 | this.setState({step: 2}); 34 | 35 | 36 | Meteor.call('getAuthorizeURL', function(error, res){ 37 | 38 | _this.setState({ 39 | url: res.url, 40 | token: res.token, 41 | tokenSecret: res.token_secret 42 | }) 43 | 44 | }); 45 | } 46 | 47 | swapRequestTokenWithAccessToken() { 48 | var _this = this; 49 | var token = this.state.token; 50 | var tokenSecret = this.state.tokenSecret; 51 | var oauthVerifier = this.state.oauthVerifier; 52 | 53 | Meteor.call('swapRequestTokenWithAccessToken', token, tokenSecret, oauthVerifier, function(error, res){ 54 | _this.setState({ 55 | accessToken: res 56 | }) 57 | 58 | Meteor.call('setAccessToken', _this.state.accessToken, _this.state.tokenSecret, Meteor.userId(), function(error, res) { 59 | console.log(res); 60 | if (res) { 61 | browserHistory.push('/'); 62 | } 63 | }); 64 | }); 65 | } 66 | 67 | renderStepOne() { 68 | if (this.state.step === 1) { 69 | return 70 | } 71 | } 72 | 73 | renderStepTwo() { 74 | if (this.state.step === 2) { 75 | if (this.state.url.length > 0) { 76 | return ( 77 |
78 |

Click on THIS link and copy the authentication code in the field below

79 | 80 |
81 | 82 |
83 |
84 | ) 85 | } else { 86 | return ( 87 | 88 | ) 89 | } 90 | 91 | } 92 | } 93 | 94 | render() { 95 | // Just render a placeholder container that will be filled in 96 | return ( 97 |
98 |
99 | {this.renderStepOne()} 100 | {this.renderStepTwo()} 101 |
102 |
103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /client/imports/components/PieChart.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import ReactHighcharts from 'react-highcharts'; 4 | import HighchartsMore from 'highcharts-more'; 5 | HighchartsMore(ReactHighcharts.Highcharts) 6 | import CircularProgress from 'material-ui/CircularProgress'; 7 | 8 | export default class PieChart extends Component { 9 | 10 | getChartConfig(user) { 11 | let hours = user.totalHrsThisYear(); 12 | console.log(hours); 13 | let date = new Date() 14 | let currentYear = moment(date).format("YYYY"); 15 | let totalHours = Math.round((hours/1617)*100); 16 | let footer = Math.round(hours); 17 | var categories = ['Billable', 'Remaining'] 18 | var data = [{ 19 | y: 56.33, 20 | drilldown: { 21 | name: 'Billable Hours', 22 | categories: ['Billable', 'Remaining'], 23 | data: [hours, 1617 - hours] 24 | } 25 | }] 26 | var browserData = [], 27 | hoursData = [],i,j, 28 | dataLen = data.length, 29 | drillDataLen, 30 | brightness; 31 | 32 | // Build the data arrays 33 | for (i = 0; i < dataLen; i += 1) { 34 | 35 | // add browser data 36 | browserData.push({ 37 | name: categories[i], 38 | y: data[i].y 39 | }); 40 | 41 | // add version data 42 | drillDataLen = data[i].drilldown.data.length; 43 | for (j = 0; j < drillDataLen; j += 1) { 44 | hoursData.push({ 45 | name: data[i].drilldown.categories[j], 46 | y: data[i].drilldown.data[j], 47 | color: j == 0 ? "#394154" : "#f3f3f3" 48 | }); 49 | } 50 | } 51 | 52 | const config = { 53 | chart: { 54 | type: 'pie' 55 | }, 56 | title: { 57 | text: currentYear + ' Billable Hours Progress' 58 | }, 59 | subtitle: { 60 | text: totalHours + '%', 61 | align: 'center', 62 | verticalAlign: 'middle', 63 | style: { 64 | "fontSize": '30px' 65 | }, 66 | y: 25 67 | }, 68 | yAxis: { 69 | title: { 70 | text: 'Total percent market share' 71 | } 72 | }, 73 | plotOptions: { 74 | pie: { 75 | shadow: false, 76 | center: ['50%', '50%'], 77 | states: { 78 | hover: { 79 | brightness: 0 80 | } 81 | } 82 | } 83 | }, 84 | credits: { 85 | position: { 86 | align: 'center' 87 | }, 88 | text: [footer]+'/1617 BILLABLE HOURS', 89 | href: null, 90 | position: { 91 | align: 'center', 92 | verticalAlign: 'bottom' 93 | }, 94 | style: { 95 | fontSize: '16px', 96 | fontFamily: 'Lato', 97 | cursor: 'initial', 98 | textTransform: 'uppercase', 99 | color: '#394154', 100 | letterSpacing: '2px', 101 | fontWeight: 'bold', 102 | paddingBottom: '20px' 103 | } 104 | }, 105 | series: [{ 106 | name: 'Hours', 107 | data: hoursData, 108 | size: '80%', 109 | innerSize: '60%' 110 | }] 111 | } 112 | return config; 113 | } 114 | 115 | render() { 116 | let { user } = this.props; 117 | if (user) { 118 | return ( 119 | 120 | ) 121 | } else { 122 | return (); 123 | } 124 | 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /client/imports/components/LineChart.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import ReactHighcharts from 'react-highcharts'; 3 | import HighchartsMore from 'highcharts-more'; 4 | HighchartsMore(ReactHighcharts.Highcharts) 5 | import CircularProgress from 'material-ui/CircularProgress'; 6 | 7 | 8 | export default class LineChart extends Component { 9 | 10 | getChartConfig(timeEntries) { 11 | 12 | const numDays = moment().isLeapYear() ? 366 : 365; 13 | const daysSoFar = moment().dayOfYear(); 14 | // const timeEntries = this.props.timeEntries; 15 | 16 | // Construct The Curve 17 | let theCurve = []; 18 | let curveRunningTotal = 0; 19 | for (let i = 0; i < daysSoFar-1; i++) { 20 | curveRunningTotal += 1617 / numDays; 21 | theCurve.push([i, curveRunningTotal]); 22 | } 23 | 24 | // Construct Progress line 25 | const totalsPerDay = {}; 26 | 27 | for (let i = 0; i < daysSoFar-1; i++) { 28 | totalsPerDay[i] = 0; 29 | } 30 | 31 | // For each time entry, increase duration of corresponding day entry in 32 | // totalsPerDay. 33 | timeEntries.forEach((timeEntry) => { 34 | dayOfYear = moment(timeEntry.date).dayOfYear(); 35 | totalsPerDay[dayOfYear] += timeEntry.duration; 36 | console.log(timeEntry.duration); 37 | console.log(totalsPerDay); 38 | }); 39 | 40 | // construct an array, which is consumed by highcharts. Create a running 41 | // total of hours worked. 42 | let dataArray = []; 43 | let runningTotal = 0; 44 | for (let prop in totalsPerDay) { 45 | if (totalsPerDay.hasOwnProperty(prop)) { 46 | runningTotal += totalsPerDay[prop]; 47 | dataArray.push([parseInt(prop), runningTotal]); 48 | } 49 | } 50 | 51 | const config = { 52 | title: { 53 | text: 'PTO Tracker', 54 | x: -20 //center 55 | }, 56 | plotOptions: { 57 | line: { 58 | marker: { 59 | enabled: false 60 | }, 61 | }, 62 | }, 63 | xAxis: { 64 | title: { 65 | text: 'Day of Year', 66 | }, 67 | allowDecimals: false, 68 | }, 69 | yAxis: { 70 | min: 0, 71 | title: { 72 | text: 'Hours' 73 | }, 74 | plotLines: [{ 75 | value: 0, 76 | width: 1, 77 | color: '#808080' 78 | }], 79 | allowDecimals: false, 80 | }, 81 | tooltip: { 82 | formatter: function() { 83 | const seriesName = this.point.series.name; 84 | const numHours = this.point.y.toFixed(1); 85 | const date = moment().dayOfYear(this.point.x); 86 | 87 | return `${date.format('MMM Do')}
${numHours} hours`; 88 | }, 89 | }, 90 | credits: { 91 | enabled: false, 92 | }, 93 | // legend: { 94 | // layout: 'vertical', 95 | // align: 'right', 96 | // verticalAlign: 'middle', 97 | // borderWidth: 0 98 | // }, 99 | series: [{ 100 | name: 'The Curve', 101 | data: theCurve, 102 | dashStyle: 'longDash', 103 | }, { 104 | name: 'Current Progress', 105 | data: dataArray, 106 | }] 107 | }; 108 | return config; 109 | } 110 | 111 | render() { 112 | 113 | let { timeEntries } = this.props; 114 | if (timeEntries) { 115 | console.log(timeEntries); 116 | return ( 117 | 118 | ) 119 | } else { 120 | return (); 121 | } 122 | 123 | 124 | 125 | 126 | return ( 127 | 128 | ) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /client/imports/components/BarChart.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import ReactHighcharts from 'react-highcharts'; 4 | import HighchartsMore from 'highcharts-more'; 5 | HighchartsMore(ReactHighcharts.Highcharts) 6 | import CircularProgress from 'material-ui/CircularProgress'; 7 | import {RadioButton, RadioButtonGroup} from 'material-ui/RadioButton'; 8 | 9 | 10 | export default class BarChart extends Component { 11 | 12 | getChartConfig(user) { 13 | //let { user } = this.props; 14 | let billable = Math.round(user.billableHrsThisWeek()); 15 | let concessed = Math.round(user.concessedHrsThisInterval('month')); 16 | let billableTimePeriod = this.props.value; 17 | // let month = Math.round(user.billableHrsThisMonth()); 18 | // let quarter = Math.round(user.billableHrsThisQuarter()); 19 | // console.log('month', month); 20 | // console.log('quarter', quarter); 21 | 22 | //default selection on the title 23 | 24 | //set the data according to the time period 25 | if (billableTimePeriod === 'month'){ 26 | billable = Math.round(user.billableHrsThisMonth()); 27 | concessed = Math.round(user.concessedHrsThisInterval('month')); 28 | } else if (billableTimePeriod === 'quarter'){ 29 | billable = Math.round(user.billableHrsThisQuarter()); 30 | concessed = Math.round(user.concessedHrsThisInterval('quarter')); 31 | } 32 | let max = billable + concessed; 33 | 34 | const config = { 35 | chart: { 36 | type: 'bar', 37 | }, 38 | title: { 39 | text: 'You’ve logged ' + "" +[max] + ' hours this '+[billableTimePeriod] 40 | }, 41 | marker: { 42 | symbol: 'circle' 43 | }, 44 | legend: { 45 | symbolRadius: 10, 46 | symbolHeight: 15 47 | }, 48 | xAxis: { 49 | gridLineWidth: 0, 50 | title: { 51 | text: '' 52 | }, 53 | labels: { 54 | enabled: false 55 | }, 56 | lineWidth: 0, 57 | minorGridLineWidth: 0, 58 | lineColor: 'transparent', 59 | minorTickLength: 0, 60 | tickLength: 0 61 | }, 62 | yAxis: { 63 | max: [max], 64 | tickInterval: 1, 65 | gridLineWidth: 0, 66 | reversedStacks: false, 67 | title: { 68 | text: '' 69 | }, 70 | labels: { 71 | enabled: false 72 | } 73 | }, 74 | plotOptions: { 75 | series: { 76 | stacking: 'normal' 77 | } 78 | }, 79 | labels: { 80 | enabled: false 81 | }, 82 | credits: { 83 | enabled: false 84 | }, 85 | series: [{ 86 | name: 'Billable Hours', 87 | data: [billable], 88 | pointWidth: 60, 89 | color: '#394154', 90 | dataLabels: { 91 | enabled: true, 92 | color: '#FFFFFF', 93 | align: 'center', 94 | style: { 95 | fontSize: '34px', 96 | fontFamily: 'Lato, sans-serif', 97 | fontWeight: '200', 98 | textShadow: 'none' 99 | }, 100 | formatter: function() { 101 | if (this.series.data[0].y != 0) { 102 | return this.series.data[0].y; 103 | } else { 104 | return null; 105 | } 106 | } 107 | } 108 | }, { 109 | name: 'Concessions', 110 | data: [concessed], 111 | pointWidth: 60, 112 | color: '#F5A623', 113 | dataLabels: { 114 | enabled: true, 115 | color: '#FFFFFF', 116 | align: 'center', 117 | style: { 118 | fontSize: '34px', 119 | fontFamily: 'Lato, sans-serif', 120 | fontWeight: '200', 121 | textShadow: 'none' 122 | }, 123 | formatter: function() { 124 | if (this.series.data[0].y != 0) { 125 | return this.series.data[0].y; 126 | } else { 127 | return null; 128 | } 129 | } 130 | } 131 | }] 132 | }; 133 | return config; 134 | } 135 | 136 | render() { 137 | let { user } = this.props; 138 | if (user) { 139 | return ( 140 | 141 | ) 142 | } else { 143 | return (); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /collections/user.js: -------------------------------------------------------------------------------- 1 | Schema = {}; 2 | /* 3 | * XXX After initial import from rails, we can remove railsId, and freshbooksId 4 | */ 5 | 6 | Schema.UserProfile = new SimpleSchema({ 7 | harvestId: { 8 | type: Number, 9 | optional: true 10 | }, 11 | railsId: { 12 | type: Number, 13 | optional: true 14 | }, 15 | freshbooksId: { 16 | type: Number, 17 | optional: true 18 | }, 19 | firstName: { 20 | type: String, 21 | optional: true 22 | }, 23 | lastName: { 24 | type: String, 25 | optional: true 26 | }, 27 | accessToken: { 28 | type: String, 29 | optional: true 30 | }, 31 | tokenSecret: { 32 | type: String, 33 | optional: true 34 | } 35 | }); 36 | 37 | Schema.User = new SimpleSchema({ 38 | username: { 39 | type: String, 40 | // For accounts-password, either emails or username is required, but not both. It is OK to make this 41 | // optional here because the accounts-password package does its own validation. 42 | // Third-party login packages may not require either. Adjust this schema as necessary for your usage. 43 | optional: true 44 | }, 45 | emails: { 46 | type: Array, 47 | // For accounts-password, either emails or username is required, but not both. It is OK to make this 48 | // optional here because the accounts-password package does its own validation. 49 | // Third-party login packages may not require either. Adjust this schema as necessary for your usage. 50 | optional: true 51 | }, 52 | "emails.$": { 53 | type: Object 54 | }, 55 | "emails.$.address": { 56 | type: String, 57 | regEx: SimpleSchema.RegEx.Email 58 | }, 59 | "emails.$.verified": { 60 | type: Boolean 61 | }, 62 | createdAt: { 63 | type: Date, 64 | optional: true 65 | }, 66 | profile: { 67 | type: Schema.UserProfile, 68 | optional: true 69 | }, 70 | // Make sure this services field is in your schema if you're using any of the accounts packages 71 | services: { 72 | type: Object, 73 | optional: true, 74 | blackbox: true 75 | }, 76 | // Add `roles` to your schema if you use the meteor-roles package. 77 | // Option 1: Object type 78 | // If you specify that type as Object, you must also specify the 79 | // `Roles.GLOBAL_GROUP` group whenever you add a user to a role. 80 | // Example: 81 | // Roles.addUsersToRoles(userId, ["admin"], Roles.GLOBAL_GROUP); 82 | // You can't mix and match adding with and without a group since 83 | // you will fail validation in some cases. 84 | roles: { 85 | type: Object, 86 | optional: true, 87 | blackbox: true 88 | }, 89 | // Option 2: [String] type 90 | // If you are sure you will never need to use role groups, then 91 | // you can specify [String] as the type 92 | roles: { 93 | type: [String], 94 | optional: true 95 | } 96 | }); 97 | 98 | Meteor.users.attachSchema(Schema.User); 99 | 100 | if( Meteor.isServer ){ 101 | Meteor.users._ensureIndex({'profile.railsId': 1}); 102 | Meteor.users._ensureIndex({'profile.freshbooksId': 1}); 103 | Meteor.users._ensureIndex({'profile.harvestId': 1}); 104 | } 105 | 106 | Meteor.users.helpers({ 107 | update: function(modifier){ 108 | Meteor.users.update({'_id': this._id},modifier); 109 | }, 110 | 111 | totalHrsThisYear (){ 112 | let entriesThisYear = TimeEntries.find({ 113 | userId: this._id, 114 | date: {$gte: Utils.startOf('year'), $lte: Utils.endOf('year')} 115 | }).fetch(); 116 | 117 | return TimeEntries.getTotalDuration(entriesThisYear); 118 | }, 119 | 120 | // concessedHrsThisYear (){ 121 | // let concessedTaskIds = Tasks.getConcessedTaskIds(); 122 | // let concessedEntriesThisYear = TimeEntries.find({ 123 | // userId: this._id, 124 | // date: {$gte: Utils.startOf('year'), $lte: Utils.endOf('year')}, 125 | // taskId: {$in: concessedTaskIds}, 126 | // }).fetch(); 127 | 128 | // return TimeEntries.getTotalDuration(concessedEntriesThisYear); 129 | // }, 130 | 131 | totalHrsThisWeek (){ 132 | let entriesThisWeek = TimeEntries.find({ 133 | userId: this._id, 134 | date: {$gte: Utils.startOf('week'), $lte: Utils.endOf('week')} 135 | }).fetch(); 136 | 137 | return TimeEntries.getTotalDuration(entriesThisWeek); 138 | }, 139 | 140 | // concessedHrsThisWeek (){ 141 | // let concessedTaskIds = Tasks.getConcessedTaskIds(); 142 | // let concessedEntriesThisWeek = TimeEntries.find({ 143 | // userId: this._id, 144 | // date: {$gte: Utils.startOf('week'), $lte: Utils.endOf('week')}, 145 | // taskId: {$in: concessedTaskIds}, 146 | // }).fetch(); 147 | 148 | // return TimeEntries.getTotalDuration(concessedEntriesThisWeek); 149 | // }, 150 | 151 | totalHrsThisMonth (){ 152 | let entriesThisMonth = TimeEntries.find({ 153 | userId: this._id, 154 | date: {$gte: Utils.startOf('month'), $lte: Utils.endOf('month')}, 155 | }).fetch(); 156 | 157 | return TimeEntries.getTotalDuration(entriesThisMonth); 158 | }, 159 | 160 | concessedHrsThisInterval (interval){ 161 | let intervals = ['week', 'month', 'quarter', 'year']; 162 | 163 | if (! _.contains(intervals, interval)) { 164 | throw new Error('interval must be one of week, month, quarter, or year'); 165 | } 166 | 167 | let concessedTaskIds = Tasks.getConcessedTaskIds(); 168 | let concessedEntriesThisInterval = TimeEntries.find({ 169 | userId: this._id, 170 | date: {$gte: Utils.startOf(interval), $lte: Utils.endOf(interval)}, 171 | taskId: {$in: concessedTaskIds}, 172 | }).fetch(); 173 | 174 | return TimeEntries.getTotalDuration(concessedEntriesThisInterval); 175 | }, 176 | 177 | totalHrsThisQuarter (){ 178 | let entriesThisQuarter = TimeEntries.find({ 179 | userId: this._id, 180 | date: {$gte: Utils.startOf('quarter'), $lte: Utils.endOf('quarter')}, 181 | }).fetch(); 182 | 183 | return TimeEntries.getTotalDuration(entriesThisQuarter); 184 | }, 185 | 186 | billableHrsThisYear (){ 187 | return this.totalHrsThisYear() - this.concessedHrsThisInterval('year'); 188 | }, 189 | 190 | billableHrsThisWeek (){ 191 | return this.totalHrsThisWeek() - this.concessedHrsThisInterval('week'); 192 | }, 193 | 194 | billableHrsThisMonth (){ 195 | return this.totalHrsThisMonth() - this.concessedHrsThisInterval('month'); 196 | }, 197 | 198 | billableHrsThisQuarter (){ 199 | return this.totalHrsThisQuarter() - this.concessedHrsThisInterval('quarter'); 200 | }, 201 | 202 | primaryEmail (){ 203 | return this.emails[0].address; 204 | }, 205 | 206 | userName (){ 207 | return this.services.google.given_name + " " + this.services.google.family_name; 208 | }, 209 | 210 | userPicture (){ 211 | return this.services.google.picture; 212 | }, 213 | 214 | }); 215 | 216 | 217 | Meteor.users.profile = function( userId ){ 218 | var user = Meteor.users.findOne({'_id': userId}); 219 | if( user ){ 220 | return user.profile; 221 | } 222 | }; 223 | -------------------------------------------------------------------------------- /client/stylesheets/main.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | body{ 6 | background-color: #F5F5F5; 7 | margin: 0; 8 | } 9 | 10 | .loginWrapper{ 11 | background: white; 12 | width: 100%; 13 | text-align: right; 14 | padding: 20px 0; 15 | margin-bottom: 70px; 16 | -webkit-box-shadow: -1px 1px 17px -2px rgba(0,0,0,0.38); 17 | -moz-box-shadow: -1px 1px 17px -2px rgba(0,0,0,0.38); 18 | box-shadow: -1px 1px 17px -2px rgba(0,0,0,0.38); 19 | } 20 | 21 | .innerWrapper{ 22 | max-width: 1024px; 23 | margin: 0 auto; 24 | padding: 0 15px; 25 | } 26 | .butler{ 27 | display: inline-table; 28 | float: left; 29 | padding: 8px 10px 0 10px; 30 | img{ 31 | display: inline-table; 32 | vertical-align: middle; 33 | } 34 | } 35 | 36 | .butlerText, 37 | .butlerText2{ 38 | display: inline-table; 39 | font-family: 'Lato'; 40 | font-weight: bold; 41 | font-size: 24px; 42 | vertical-align: middle; 43 | margin: 0; 44 | text-transform: uppercase; 45 | letter-spacing: 3px; 46 | padding-left: 20px; 47 | color: #32A5A9; 48 | } 49 | 50 | .headerLinks { 51 | font-family: "Lato"; 52 | font-size: 17px; 53 | font-weight: 200; 54 | margin-left: 50px; 55 | display: inline-block; 56 | float: left; 57 | padding: 20px 0px; 58 | a { 59 | text-decoration: none; 60 | color: #32A5A9; 61 | } 62 | } 63 | .user-picture img{ 64 | width: 35px; 65 | border-radius: 20px; 66 | } 67 | #login-buttons, 68 | .user-picture{ 69 | display: inline-table; 70 | vertical-align: middle; 71 | } 72 | #login-buttons{ 73 | padding-left: 10px; 74 | cursor: pointer; 75 | } 76 | .userWrapper{ 77 | display: inline-table; 78 | margin-right: 0.2px; 79 | line-height: 1; 80 | padding: 12px 0; 81 | vertical-align: top; 82 | font-family: "Lato"; 83 | font-size: 17px; 84 | font-weight: 200; 85 | position: relative; 86 | } 87 | 88 | .login-link-text{ 89 | padding-right: 20px; 90 | font-family: "Lato"; 91 | font-weight: 200; 92 | text-decoration: none !important; 93 | } 94 | 95 | .arrowDown{ 96 | width: 15px; 97 | position: relative; 98 | left: 6px; 99 | } 100 | .logout{ 101 | display: none; 102 | position: absolute; 103 | z-index: 999; 104 | background-color: white; 105 | // border: 1px red solid; 106 | top: 58px; 107 | right: 0px; 108 | padding: 0 41px; 109 | -webkit-box-shadow: -1px 1px 17px -2px rgba(0,0,0,0.38); 110 | -moz-box-shadow: -1px 1px 17px -2px rgba(0,0,0,0.38); 111 | box-shadow: -1px 1px 17px -2px rgba(0,0,0,0.38); 112 | cursor: pointer; 113 | } 114 | .btn-google{ 115 | font-size: 23px; 116 | color: #32A5A9; 117 | font-family: 'Lato'; 118 | text-transform: lowercase; 119 | } 120 | 121 | .activate{ 122 | display: block !important; 123 | } 124 | 125 | .wrapper{ 126 | width: 30%; 127 | display: inline-block; 128 | margin-right: 20px; 129 | } 130 | .wrapper2{ 131 | width: 67%; 132 | display: inline-block; 133 | vertical-align: top; 134 | } 135 | .wrapper3{ 136 | max-width: 1024px; 137 | margin: 0 auto; 138 | } 139 | .lineChartWrapper { 140 | display: inline-block; 141 | width: 75%; 142 | background-color: white; 143 | border: 1px #DCDCDC solid; 144 | /* height: 278px; */ 145 | margin-bottom: 20px; 146 | overflow: hidden; 147 | padding: 20px 0; 148 | } 149 | .vacationHoursTracker { 150 | display: inline-block; 151 | height: 400px; 152 | margin-bottom: 20px; 153 | overflow: hidden; 154 | padding: 20px 0; 155 | padding-left: 20px; 156 | font-size: 30px !important; 157 | font-family: 'Lato'; 158 | font-weight: 200; 159 | * { 160 | display: inline-block; 161 | vertical-align: middle; 162 | line-height: normal; 163 | } 164 | div { 165 | padding-top: 70px; 166 | } 167 | span { 168 | font-size: 50px; 169 | font-weight: bold; 170 | } 171 | } 172 | .hours-diff, 173 | .ahead-or-behind { 174 | font-size: 30px; 175 | display: block; 176 | } 177 | .tab-navigation{ 178 | max-width: 1024px; 179 | margin: 0 auto; 180 | padding: 0 15px 20px 15px; 181 | a{ 182 | font-family: 'Lato'; 183 | color: black; 184 | text-decoration: none; 185 | margin-right: 20px; 186 | padding: 5px 15px; 187 | } 188 | .active{ 189 | color: white; 190 | border: 1px solid #32A5A9; 191 | background: #32A5A9; 192 | cursor: default; 193 | } 194 | } 195 | .donutChartWrapper, 196 | .barChartWrapper{ 197 | background-color: white; 198 | border: 1px #DCDCDC solid; 199 | height: 278px; 200 | margin-bottom: 20px; 201 | overflow: hidden; 202 | padding: 20px 0; 203 | } 204 | .barChartRadio{ 205 | background-color: white; 206 | border: 1px #DCDCDC solid; 207 | margin-bottom: 20px; 208 | overflow: hidden; 209 | padding: 20px 0; 210 | } 211 | .info{ 212 | clear: both; 213 | } 214 | 215 | #donut-chart .highcharts-title, 216 | #bar-chart .highcharts-title, 217 | #line-chart .highcharts-title { 218 | 219 | font-size: 30px !important; 220 | font-family: 'Lato'; 221 | font-weight: 200; 222 | } 223 | 224 | #bar-chart .highcharts-title tspan:nth-child(2){ 225 | font-weight: bold; 226 | } 227 | 228 | .highcharts-legend-item text{ 229 | font-size: 16px !important; 230 | font-family: 'Lato'; 231 | letter-spacing: 1px; 232 | text-transform: uppercase; 233 | } 234 | 235 | .radio{ 236 | text-align: center; 237 | font-size: 23px; 238 | color: #32A5A9; 239 | font-family: 'Lato'; 240 | text-transform: lowercase; 241 | padding: 20px; 242 | } 243 | 244 | .login-wrapper{ 245 | width: 200px; 246 | margin: 150px auto; 247 | #login-sign-in-link{ 248 | font-weight: 400 !important; 249 | } 250 | #login-dropdown-list{ 251 | width: 185px; 252 | margin-top: 0px; 253 | } 254 | .login-close-text{ 255 | font-family: 'Lato'; 256 | text-decoration: none !important; 257 | } 258 | #login-buttons{ 259 | #login-buttons-google{ 260 | background-color: #32A5A9; 261 | padding: 10px 8px; 262 | .text-besides-image{ 263 | font-size: 16px; 264 | font-family: 'Lato'; 265 | margin-left: 0; 266 | } 267 | } 268 | margin-top: 15px; 269 | .login-form, 270 | .or, 271 | #login-buttons-image-google{ 272 | display: none; 273 | } 274 | 275 | } 276 | } 277 | input:focus{ 278 | outline: red auto; 279 | } 280 | 281 | .adminWrapper{ 282 | max-width: 1024px; 283 | margin: 0 auto; 284 | .adminList{ 285 | width: 30%; 286 | display: inline-table; 287 | padding: 0; 288 | padding-right: 31px; 289 | li{ 290 | list-style: none; 291 | } 292 | } 293 | } 294 | 295 | @media screen and (max-width: 1000px){ 296 | .donutChartWrapper svg text{ 297 | font-size: 13px !important; 298 | } 299 | } 300 | 301 | @media screen and (max-width: 885px){ 302 | .wrapper, 303 | .wrapper2{ 304 | width: 100%; 305 | display: block; 306 | margin-right: 0; 307 | } 308 | .donutChartWrapper svg text{ 309 | font-size: 16px !important; 310 | } 311 | } 312 | 313 | @media screen and (max-width: 415px){ 314 | .loginWrapper{ 315 | margin-bottom: 20px; 316 | padding: 0; 317 | } 318 | .butlerText{ 319 | display: none; 320 | } 321 | .logout{ 322 | top: 58px; 323 | } 324 | .radio{ 325 | font-size: 16px; 326 | } 327 | #bar-chart .highcharts-container text{ 328 | font-size: 18px !important; 329 | } 330 | } 331 | 332 | @media screen and (max-width: 740px) { 333 | .lineChartWrapper { 334 | width: 100% 335 | } 336 | .vacationHoursTracker div { 337 | padding-top: 5px; 338 | width: 100%; 339 | text-align: center; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /client/stylesheets/bootstrap.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.5 (http://getbootstrap.com) 3 | * Copyright 2011-2016 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | /*! 8 | * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=1a7085fccf75a8bf8c5eb54e2fe90c72) 9 | * Config saved to config.json and https://gist.github.com/1a7085fccf75a8bf8c5eb54e2fe90c72 10 | */ 11 | /*! 12 | * Bootstrap v3.3.6 (http://getbootstrap.com) 13 | * Copyright 2011-2015 Twitter, Inc. 14 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 15 | */ 16 | /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ 17 | html { 18 | font-family: sans-serif; 19 | -ms-text-size-adjust: 100%; 20 | -webkit-text-size-adjust: 100%; 21 | } 22 | body { 23 | margin: 0; 24 | } 25 | article, 26 | aside, 27 | details, 28 | figcaption, 29 | figure, 30 | footer, 31 | header, 32 | hgroup, 33 | main, 34 | menu, 35 | nav, 36 | section, 37 | summary { 38 | display: block; 39 | } 40 | audio, 41 | canvas, 42 | progress, 43 | video { 44 | display: inline-block; 45 | vertical-align: baseline; 46 | } 47 | audio:not([controls]) { 48 | display: none; 49 | height: 0; 50 | } 51 | [hidden], 52 | template { 53 | display: none; 54 | } 55 | a { 56 | background-color: transparent; 57 | } 58 | a:active, 59 | a:hover { 60 | outline: 0; 61 | } 62 | abbr[title] { 63 | border-bottom: 1px dotted; 64 | } 65 | b, 66 | strong { 67 | font-weight: bold; 68 | } 69 | dfn { 70 | font-style: italic; 71 | } 72 | h1 { 73 | font-size: 2em; 74 | margin: 0.67em 0; 75 | } 76 | mark { 77 | background: #ff0; 78 | color: #000; 79 | } 80 | small { 81 | font-size: 80%; 82 | } 83 | sub, 84 | sup { 85 | font-size: 75%; 86 | line-height: 0; 87 | position: relative; 88 | vertical-align: baseline; 89 | } 90 | sup { 91 | top: -0.5em; 92 | } 93 | sub { 94 | bottom: -0.25em; 95 | } 96 | img { 97 | border: 0; 98 | } 99 | svg:not(:root) { 100 | overflow: hidden; 101 | } 102 | figure { 103 | margin: 1em 40px; 104 | } 105 | hr { 106 | -webkit-box-sizing: content-box; 107 | -moz-box-sizing: content-box; 108 | box-sizing: content-box; 109 | height: 0; 110 | } 111 | pre { 112 | overflow: auto; 113 | } 114 | code, 115 | kbd, 116 | pre, 117 | samp { 118 | font-family: monospace, monospace; 119 | font-size: 1em; 120 | } 121 | button, 122 | input, 123 | optgroup, 124 | select, 125 | textarea { 126 | color: inherit; 127 | font: inherit; 128 | margin: 0; 129 | } 130 | button { 131 | overflow: visible; 132 | } 133 | button, 134 | select { 135 | text-transform: none; 136 | } 137 | button, 138 | html input[type="button"], 139 | input[type="reset"], 140 | input[type="submit"] { 141 | -webkit-appearance: button; 142 | cursor: pointer; 143 | } 144 | button[disabled], 145 | html input[disabled] { 146 | cursor: default; 147 | } 148 | button::-moz-focus-inner, 149 | input::-moz-focus-inner { 150 | border: 0; 151 | padding: 0; 152 | } 153 | input { 154 | line-height: normal; 155 | } 156 | input[type="checkbox"], 157 | input[type="radio"] { 158 | -webkit-box-sizing: border-box; 159 | -moz-box-sizing: border-box; 160 | box-sizing: border-box; 161 | padding: 0; 162 | } 163 | input[type="number"]::-webkit-inner-spin-button, 164 | input[type="number"]::-webkit-outer-spin-button { 165 | height: auto; 166 | } 167 | input[type="search"] { 168 | -webkit-appearance: textfield; 169 | -webkit-box-sizing: content-box; 170 | -moz-box-sizing: content-box; 171 | box-sizing: content-box; 172 | } 173 | input[type="search"]::-webkit-search-cancel-button, 174 | input[type="search"]::-webkit-search-decoration { 175 | -webkit-appearance: none; 176 | } 177 | fieldset { 178 | border: 1px solid #c0c0c0; 179 | margin: 0 2px; 180 | padding: 0.35em 0.625em 0.75em; 181 | } 182 | legend { 183 | border: 0; 184 | padding: 0; 185 | } 186 | textarea { 187 | overflow: auto; 188 | } 189 | optgroup { 190 | font-weight: bold; 191 | } 192 | table { 193 | border-collapse: collapse; 194 | border-spacing: 0; 195 | } 196 | td, 197 | th { 198 | padding: 0; 199 | } 200 | * { 201 | -webkit-box-sizing: border-box; 202 | -moz-box-sizing: border-box; 203 | box-sizing: border-box; 204 | } 205 | *:before, 206 | *:after { 207 | -webkit-box-sizing: border-box; 208 | -moz-box-sizing: border-box; 209 | box-sizing: border-box; 210 | } 211 | html { 212 | font-size: 10px; 213 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 214 | } 215 | body { 216 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 217 | font-size: 14px; 218 | line-height: 1.42857143; 219 | color: #333333; 220 | background-color: #ffffff; 221 | } 222 | input, 223 | button, 224 | select, 225 | textarea { 226 | font-family: inherit; 227 | font-size: inherit; 228 | line-height: inherit; 229 | } 230 | a { 231 | color: #337ab7; 232 | text-decoration: none; 233 | } 234 | a:hover, 235 | a:focus { 236 | color: #23527c; 237 | text-decoration: underline; 238 | } 239 | a:focus { 240 | outline: thin dotted; 241 | outline: 5px auto -webkit-focus-ring-color; 242 | outline-offset: -2px; 243 | } 244 | figure { 245 | margin: 0; 246 | } 247 | img { 248 | vertical-align: middle; 249 | } 250 | .img-responsive { 251 | display: block; 252 | max-width: 100%; 253 | height: auto; 254 | } 255 | .img-rounded { 256 | border-radius: 6px; 257 | } 258 | .img-thumbnail { 259 | padding: 4px; 260 | line-height: 1.42857143; 261 | background-color: #ffffff; 262 | border: 1px solid #dddddd; 263 | border-radius: 4px; 264 | -webkit-transition: all 0.2s ease-in-out; 265 | -o-transition: all 0.2s ease-in-out; 266 | transition: all 0.2s ease-in-out; 267 | display: inline-block; 268 | max-width: 100%; 269 | height: auto; 270 | } 271 | .img-circle { 272 | border-radius: 50%; 273 | } 274 | hr { 275 | margin-top: 20px; 276 | margin-bottom: 20px; 277 | border: 0; 278 | border-top: 1px solid #eeeeee; 279 | } 280 | .sr-only { 281 | position: absolute; 282 | width: 1px; 283 | height: 1px; 284 | margin: -1px; 285 | padding: 0; 286 | overflow: hidden; 287 | clip: rect(0, 0, 0, 0); 288 | border: 0; 289 | } 290 | .sr-only-focusable:active, 291 | .sr-only-focusable:focus { 292 | position: static; 293 | width: auto; 294 | height: auto; 295 | margin: 0; 296 | overflow: visible; 297 | clip: auto; 298 | } 299 | [role="button"] { 300 | cursor: pointer; 301 | } 302 | .container { 303 | margin-right: auto; 304 | margin-left: auto; 305 | padding-left: 15px; 306 | padding-right: 15px; 307 | } 308 | @media (min-width: 768px) { 309 | .container { 310 | width: 750px; 311 | } 312 | } 313 | @media (min-width: 992px) { 314 | .container { 315 | width: 970px; 316 | } 317 | } 318 | @media (min-width: 1200px) { 319 | .container { 320 | width: 1170px; 321 | } 322 | } 323 | .container-fluid { 324 | margin-right: auto; 325 | margin-left: auto; 326 | padding-left: 15px; 327 | padding-right: 15px; 328 | } 329 | .row { 330 | margin-left: -15px; 331 | margin-right: -15px; 332 | } 333 | .col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { 334 | position: relative; 335 | min-height: 1px; 336 | padding-left: 15px; 337 | padding-right: 15px; 338 | } 339 | .col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 { 340 | float: left; 341 | } 342 | .col-xs-12 { 343 | width: 100%; 344 | } 345 | .col-xs-11 { 346 | width: 91.66666667%; 347 | } 348 | .col-xs-10 { 349 | width: 83.33333333%; 350 | } 351 | .col-xs-9 { 352 | width: 75%; 353 | } 354 | .col-xs-8 { 355 | width: 66.66666667%; 356 | } 357 | .col-xs-7 { 358 | width: 58.33333333%; 359 | } 360 | .col-xs-6 { 361 | width: 50%; 362 | } 363 | .col-xs-5 { 364 | width: 41.66666667%; 365 | } 366 | .col-xs-4 { 367 | width: 33.33333333%; 368 | } 369 | .col-xs-3 { 370 | width: 25%; 371 | } 372 | .col-xs-2 { 373 | width: 16.66666667%; 374 | } 375 | .col-xs-1 { 376 | width: 8.33333333%; 377 | } 378 | .col-xs-pull-12 { 379 | right: 100%; 380 | } 381 | .col-xs-pull-11 { 382 | right: 91.66666667%; 383 | } 384 | .col-xs-pull-10 { 385 | right: 83.33333333%; 386 | } 387 | .col-xs-pull-9 { 388 | right: 75%; 389 | } 390 | .col-xs-pull-8 { 391 | right: 66.66666667%; 392 | } 393 | .col-xs-pull-7 { 394 | right: 58.33333333%; 395 | } 396 | .col-xs-pull-6 { 397 | right: 50%; 398 | } 399 | .col-xs-pull-5 { 400 | right: 41.66666667%; 401 | } 402 | .col-xs-pull-4 { 403 | right: 33.33333333%; 404 | } 405 | .col-xs-pull-3 { 406 | right: 25%; 407 | } 408 | .col-xs-pull-2 { 409 | right: 16.66666667%; 410 | } 411 | .col-xs-pull-1 { 412 | right: 8.33333333%; 413 | } 414 | .col-xs-pull-0 { 415 | right: auto; 416 | } 417 | .col-xs-push-12 { 418 | left: 100%; 419 | } 420 | .col-xs-push-11 { 421 | left: 91.66666667%; 422 | } 423 | .col-xs-push-10 { 424 | left: 83.33333333%; 425 | } 426 | .col-xs-push-9 { 427 | left: 75%; 428 | } 429 | .col-xs-push-8 { 430 | left: 66.66666667%; 431 | } 432 | .col-xs-push-7 { 433 | left: 58.33333333%; 434 | } 435 | .col-xs-push-6 { 436 | left: 50%; 437 | } 438 | .col-xs-push-5 { 439 | left: 41.66666667%; 440 | } 441 | .col-xs-push-4 { 442 | left: 33.33333333%; 443 | } 444 | .col-xs-push-3 { 445 | left: 25%; 446 | } 447 | .col-xs-push-2 { 448 | left: 16.66666667%; 449 | } 450 | .col-xs-push-1 { 451 | left: 8.33333333%; 452 | } 453 | .col-xs-push-0 { 454 | left: auto; 455 | } 456 | .col-xs-offset-12 { 457 | margin-left: 100%; 458 | } 459 | .col-xs-offset-11 { 460 | margin-left: 91.66666667%; 461 | } 462 | .col-xs-offset-10 { 463 | margin-left: 83.33333333%; 464 | } 465 | .col-xs-offset-9 { 466 | margin-left: 75%; 467 | } 468 | .col-xs-offset-8 { 469 | margin-left: 66.66666667%; 470 | } 471 | .col-xs-offset-7 { 472 | margin-left: 58.33333333%; 473 | } 474 | .col-xs-offset-6 { 475 | margin-left: 50%; 476 | } 477 | .col-xs-offset-5 { 478 | margin-left: 41.66666667%; 479 | } 480 | .col-xs-offset-4 { 481 | margin-left: 33.33333333%; 482 | } 483 | .col-xs-offset-3 { 484 | margin-left: 25%; 485 | } 486 | .col-xs-offset-2 { 487 | margin-left: 16.66666667%; 488 | } 489 | .col-xs-offset-1 { 490 | margin-left: 8.33333333%; 491 | } 492 | .col-xs-offset-0 { 493 | margin-left: 0%; 494 | } 495 | @media (min-width: 768px) { 496 | .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 { 497 | float: left; 498 | } 499 | .col-sm-12 { 500 | width: 100%; 501 | } 502 | .col-sm-11 { 503 | width: 91.66666667%; 504 | } 505 | .col-sm-10 { 506 | width: 83.33333333%; 507 | } 508 | .col-sm-9 { 509 | width: 75%; 510 | } 511 | .col-sm-8 { 512 | width: 66.66666667%; 513 | } 514 | .col-sm-7 { 515 | width: 58.33333333%; 516 | } 517 | .col-sm-6 { 518 | width: 50%; 519 | } 520 | .col-sm-5 { 521 | width: 41.66666667%; 522 | } 523 | .col-sm-4 { 524 | width: 33.33333333%; 525 | } 526 | .col-sm-3 { 527 | width: 25%; 528 | } 529 | .col-sm-2 { 530 | width: 16.66666667%; 531 | } 532 | .col-sm-1 { 533 | width: 8.33333333%; 534 | } 535 | .col-sm-pull-12 { 536 | right: 100%; 537 | } 538 | .col-sm-pull-11 { 539 | right: 91.66666667%; 540 | } 541 | .col-sm-pull-10 { 542 | right: 83.33333333%; 543 | } 544 | .col-sm-pull-9 { 545 | right: 75%; 546 | } 547 | .col-sm-pull-8 { 548 | right: 66.66666667%; 549 | } 550 | .col-sm-pull-7 { 551 | right: 58.33333333%; 552 | } 553 | .col-sm-pull-6 { 554 | right: 50%; 555 | } 556 | .col-sm-pull-5 { 557 | right: 41.66666667%; 558 | } 559 | .col-sm-pull-4 { 560 | right: 33.33333333%; 561 | } 562 | .col-sm-pull-3 { 563 | right: 25%; 564 | } 565 | .col-sm-pull-2 { 566 | right: 16.66666667%; 567 | } 568 | .col-sm-pull-1 { 569 | right: 8.33333333%; 570 | } 571 | .col-sm-pull-0 { 572 | right: auto; 573 | } 574 | .col-sm-push-12 { 575 | left: 100%; 576 | } 577 | .col-sm-push-11 { 578 | left: 91.66666667%; 579 | } 580 | .col-sm-push-10 { 581 | left: 83.33333333%; 582 | } 583 | .col-sm-push-9 { 584 | left: 75%; 585 | } 586 | .col-sm-push-8 { 587 | left: 66.66666667%; 588 | } 589 | .col-sm-push-7 { 590 | left: 58.33333333%; 591 | } 592 | .col-sm-push-6 { 593 | left: 50%; 594 | } 595 | .col-sm-push-5 { 596 | left: 41.66666667%; 597 | } 598 | .col-sm-push-4 { 599 | left: 33.33333333%; 600 | } 601 | .col-sm-push-3 { 602 | left: 25%; 603 | } 604 | .col-sm-push-2 { 605 | left: 16.66666667%; 606 | } 607 | .col-sm-push-1 { 608 | left: 8.33333333%; 609 | } 610 | .col-sm-push-0 { 611 | left: auto; 612 | } 613 | .col-sm-offset-12 { 614 | margin-left: 100%; 615 | } 616 | .col-sm-offset-11 { 617 | margin-left: 91.66666667%; 618 | } 619 | .col-sm-offset-10 { 620 | margin-left: 83.33333333%; 621 | } 622 | .col-sm-offset-9 { 623 | margin-left: 75%; 624 | } 625 | .col-sm-offset-8 { 626 | margin-left: 66.66666667%; 627 | } 628 | .col-sm-offset-7 { 629 | margin-left: 58.33333333%; 630 | } 631 | .col-sm-offset-6 { 632 | margin-left: 50%; 633 | } 634 | .col-sm-offset-5 { 635 | margin-left: 41.66666667%; 636 | } 637 | .col-sm-offset-4 { 638 | margin-left: 33.33333333%; 639 | } 640 | .col-sm-offset-3 { 641 | margin-left: 25%; 642 | } 643 | .col-sm-offset-2 { 644 | margin-left: 16.66666667%; 645 | } 646 | .col-sm-offset-1 { 647 | margin-left: 8.33333333%; 648 | } 649 | .col-sm-offset-0 { 650 | margin-left: 0%; 651 | } 652 | } 653 | @media (min-width: 992px) { 654 | .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 { 655 | float: left; 656 | } 657 | .col-md-12 { 658 | width: 100%; 659 | } 660 | .col-md-11 { 661 | width: 91.66666667%; 662 | } 663 | .col-md-10 { 664 | width: 83.33333333%; 665 | } 666 | .col-md-9 { 667 | width: 75%; 668 | } 669 | .col-md-8 { 670 | width: 66.66666667%; 671 | } 672 | .col-md-7 { 673 | width: 58.33333333%; 674 | } 675 | .col-md-6 { 676 | width: 50%; 677 | } 678 | .col-md-5 { 679 | width: 41.66666667%; 680 | } 681 | .col-md-4 { 682 | width: 33.33333333%; 683 | } 684 | .col-md-3 { 685 | width: 25%; 686 | } 687 | .col-md-2 { 688 | width: 16.66666667%; 689 | } 690 | .col-md-1 { 691 | width: 8.33333333%; 692 | } 693 | .col-md-pull-12 { 694 | right: 100%; 695 | } 696 | .col-md-pull-11 { 697 | right: 91.66666667%; 698 | } 699 | .col-md-pull-10 { 700 | right: 83.33333333%; 701 | } 702 | .col-md-pull-9 { 703 | right: 75%; 704 | } 705 | .col-md-pull-8 { 706 | right: 66.66666667%; 707 | } 708 | .col-md-pull-7 { 709 | right: 58.33333333%; 710 | } 711 | .col-md-pull-6 { 712 | right: 50%; 713 | } 714 | .col-md-pull-5 { 715 | right: 41.66666667%; 716 | } 717 | .col-md-pull-4 { 718 | right: 33.33333333%; 719 | } 720 | .col-md-pull-3 { 721 | right: 25%; 722 | } 723 | .col-md-pull-2 { 724 | right: 16.66666667%; 725 | } 726 | .col-md-pull-1 { 727 | right: 8.33333333%; 728 | } 729 | .col-md-pull-0 { 730 | right: auto; 731 | } 732 | .col-md-push-12 { 733 | left: 100%; 734 | } 735 | .col-md-push-11 { 736 | left: 91.66666667%; 737 | } 738 | .col-md-push-10 { 739 | left: 83.33333333%; 740 | } 741 | .col-md-push-9 { 742 | left: 75%; 743 | } 744 | .col-md-push-8 { 745 | left: 66.66666667%; 746 | } 747 | .col-md-push-7 { 748 | left: 58.33333333%; 749 | } 750 | .col-md-push-6 { 751 | left: 50%; 752 | } 753 | .col-md-push-5 { 754 | left: 41.66666667%; 755 | } 756 | .col-md-push-4 { 757 | left: 33.33333333%; 758 | } 759 | .col-md-push-3 { 760 | left: 25%; 761 | } 762 | .col-md-push-2 { 763 | left: 16.66666667%; 764 | } 765 | .col-md-push-1 { 766 | left: 8.33333333%; 767 | } 768 | .col-md-push-0 { 769 | left: auto; 770 | } 771 | .col-md-offset-12 { 772 | margin-left: 100%; 773 | } 774 | .col-md-offset-11 { 775 | margin-left: 91.66666667%; 776 | } 777 | .col-md-offset-10 { 778 | margin-left: 83.33333333%; 779 | } 780 | .col-md-offset-9 { 781 | margin-left: 75%; 782 | } 783 | .col-md-offset-8 { 784 | margin-left: 66.66666667%; 785 | } 786 | .col-md-offset-7 { 787 | margin-left: 58.33333333%; 788 | } 789 | .col-md-offset-6 { 790 | margin-left: 50%; 791 | } 792 | .col-md-offset-5 { 793 | margin-left: 41.66666667%; 794 | } 795 | .col-md-offset-4 { 796 | margin-left: 33.33333333%; 797 | } 798 | .col-md-offset-3 { 799 | margin-left: 25%; 800 | } 801 | .col-md-offset-2 { 802 | margin-left: 16.66666667%; 803 | } 804 | .col-md-offset-1 { 805 | margin-left: 8.33333333%; 806 | } 807 | .col-md-offset-0 { 808 | margin-left: 0%; 809 | } 810 | } 811 | @media (min-width: 1200px) { 812 | .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 { 813 | float: left; 814 | } 815 | .col-lg-12 { 816 | width: 100%; 817 | } 818 | .col-lg-11 { 819 | width: 91.66666667%; 820 | } 821 | .col-lg-10 { 822 | width: 83.33333333%; 823 | } 824 | .col-lg-9 { 825 | width: 75%; 826 | } 827 | .col-lg-8 { 828 | width: 66.66666667%; 829 | } 830 | .col-lg-7 { 831 | width: 58.33333333%; 832 | } 833 | .col-lg-6 { 834 | width: 50%; 835 | } 836 | .col-lg-5 { 837 | width: 41.66666667%; 838 | } 839 | .col-lg-4 { 840 | width: 33.33333333%; 841 | } 842 | .col-lg-3 { 843 | width: 25%; 844 | } 845 | .col-lg-2 { 846 | width: 16.66666667%; 847 | } 848 | .col-lg-1 { 849 | width: 8.33333333%; 850 | } 851 | .col-lg-pull-12 { 852 | right: 100%; 853 | } 854 | .col-lg-pull-11 { 855 | right: 91.66666667%; 856 | } 857 | .col-lg-pull-10 { 858 | right: 83.33333333%; 859 | } 860 | .col-lg-pull-9 { 861 | right: 75%; 862 | } 863 | .col-lg-pull-8 { 864 | right: 66.66666667%; 865 | } 866 | .col-lg-pull-7 { 867 | right: 58.33333333%; 868 | } 869 | .col-lg-pull-6 { 870 | right: 50%; 871 | } 872 | .col-lg-pull-5 { 873 | right: 41.66666667%; 874 | } 875 | .col-lg-pull-4 { 876 | right: 33.33333333%; 877 | } 878 | .col-lg-pull-3 { 879 | right: 25%; 880 | } 881 | .col-lg-pull-2 { 882 | right: 16.66666667%; 883 | } 884 | .col-lg-pull-1 { 885 | right: 8.33333333%; 886 | } 887 | .col-lg-pull-0 { 888 | right: auto; 889 | } 890 | .col-lg-push-12 { 891 | left: 100%; 892 | } 893 | .col-lg-push-11 { 894 | left: 91.66666667%; 895 | } 896 | .col-lg-push-10 { 897 | left: 83.33333333%; 898 | } 899 | .col-lg-push-9 { 900 | left: 75%; 901 | } 902 | .col-lg-push-8 { 903 | left: 66.66666667%; 904 | } 905 | .col-lg-push-7 { 906 | left: 58.33333333%; 907 | } 908 | .col-lg-push-6 { 909 | left: 50%; 910 | } 911 | .col-lg-push-5 { 912 | left: 41.66666667%; 913 | } 914 | .col-lg-push-4 { 915 | left: 33.33333333%; 916 | } 917 | .col-lg-push-3 { 918 | left: 25%; 919 | } 920 | .col-lg-push-2 { 921 | left: 16.66666667%; 922 | } 923 | .col-lg-push-1 { 924 | left: 8.33333333%; 925 | } 926 | .col-lg-push-0 { 927 | left: auto; 928 | } 929 | .col-lg-offset-12 { 930 | margin-left: 100%; 931 | } 932 | .col-lg-offset-11 { 933 | margin-left: 91.66666667%; 934 | } 935 | .col-lg-offset-10 { 936 | margin-left: 83.33333333%; 937 | } 938 | .col-lg-offset-9 { 939 | margin-left: 75%; 940 | } 941 | .col-lg-offset-8 { 942 | margin-left: 66.66666667%; 943 | } 944 | .col-lg-offset-7 { 945 | margin-left: 58.33333333%; 946 | } 947 | .col-lg-offset-6 { 948 | margin-left: 50%; 949 | } 950 | .col-lg-offset-5 { 951 | margin-left: 41.66666667%; 952 | } 953 | .col-lg-offset-4 { 954 | margin-left: 33.33333333%; 955 | } 956 | .col-lg-offset-3 { 957 | margin-left: 25%; 958 | } 959 | .col-lg-offset-2 { 960 | margin-left: 16.66666667%; 961 | } 962 | .col-lg-offset-1 { 963 | margin-left: 8.33333333%; 964 | } 965 | .col-lg-offset-0 { 966 | margin-left: 0%; 967 | } 968 | } 969 | .clearfix:before, 970 | .clearfix:after, 971 | .container:before, 972 | .container:after, 973 | .container-fluid:before, 974 | .container-fluid:after, 975 | .row:before, 976 | .row:after { 977 | content: " "; 978 | display: table; 979 | } 980 | .clearfix:after, 981 | .container:after, 982 | .container-fluid:after, 983 | .row:after { 984 | clear: both; 985 | } 986 | .center-block { 987 | display: block; 988 | margin-left: auto; 989 | margin-right: auto; 990 | } 991 | .pull-right { 992 | float: right !important; 993 | } 994 | .pull-left { 995 | float: left !important; 996 | } 997 | .hide { 998 | display: none !important; 999 | } 1000 | .show { 1001 | display: block !important; 1002 | } 1003 | .invisible { 1004 | visibility: hidden; 1005 | } 1006 | .text-hide { 1007 | font: 0/0 a; 1008 | color: transparent; 1009 | text-shadow: none; 1010 | background-color: transparent; 1011 | border: 0; 1012 | } 1013 | .hidden { 1014 | display: none !important; 1015 | } 1016 | .affix { 1017 | position: fixed; 1018 | } 1019 | @-ms-viewport { 1020 | width: device-width; 1021 | } 1022 | .visible-xs, 1023 | .visible-sm, 1024 | .visible-md, 1025 | .visible-lg { 1026 | display: none !important; 1027 | } 1028 | .visible-xs-block, 1029 | .visible-xs-inline, 1030 | .visible-xs-inline-block, 1031 | .visible-sm-block, 1032 | .visible-sm-inline, 1033 | .visible-sm-inline-block, 1034 | .visible-md-block, 1035 | .visible-md-inline, 1036 | .visible-md-inline-block, 1037 | .visible-lg-block, 1038 | .visible-lg-inline, 1039 | .visible-lg-inline-block { 1040 | display: none !important; 1041 | } 1042 | @media (max-width: 767px) { 1043 | .visible-xs { 1044 | display: block !important; 1045 | } 1046 | table.visible-xs { 1047 | display: table !important; 1048 | } 1049 | tr.visible-xs { 1050 | display: table-row !important; 1051 | } 1052 | th.visible-xs, 1053 | td.visible-xs { 1054 | display: table-cell !important; 1055 | } 1056 | } 1057 | @media (max-width: 767px) { 1058 | .visible-xs-block { 1059 | display: block !important; 1060 | } 1061 | } 1062 | @media (max-width: 767px) { 1063 | .visible-xs-inline { 1064 | display: inline !important; 1065 | } 1066 | } 1067 | @media (max-width: 767px) { 1068 | .visible-xs-inline-block { 1069 | display: inline-block !important; 1070 | } 1071 | } 1072 | @media (min-width: 768px) and (max-width: 991px) { 1073 | .visible-sm { 1074 | display: block !important; 1075 | } 1076 | table.visible-sm { 1077 | display: table !important; 1078 | } 1079 | tr.visible-sm { 1080 | display: table-row !important; 1081 | } 1082 | th.visible-sm, 1083 | td.visible-sm { 1084 | display: table-cell !important; 1085 | } 1086 | } 1087 | @media (min-width: 768px) and (max-width: 991px) { 1088 | .visible-sm-block { 1089 | display: block !important; 1090 | } 1091 | } 1092 | @media (min-width: 768px) and (max-width: 991px) { 1093 | .visible-sm-inline { 1094 | display: inline !important; 1095 | } 1096 | } 1097 | @media (min-width: 768px) and (max-width: 991px) { 1098 | .visible-sm-inline-block { 1099 | display: inline-block !important; 1100 | } 1101 | } 1102 | @media (min-width: 992px) and (max-width: 1199px) { 1103 | .visible-md { 1104 | display: block !important; 1105 | } 1106 | table.visible-md { 1107 | display: table !important; 1108 | } 1109 | tr.visible-md { 1110 | display: table-row !important; 1111 | } 1112 | th.visible-md, 1113 | td.visible-md { 1114 | display: table-cell !important; 1115 | } 1116 | } 1117 | @media (min-width: 992px) and (max-width: 1199px) { 1118 | .visible-md-block { 1119 | display: block !important; 1120 | } 1121 | } 1122 | @media (min-width: 992px) and (max-width: 1199px) { 1123 | .visible-md-inline { 1124 | display: inline !important; 1125 | } 1126 | } 1127 | @media (min-width: 992px) and (max-width: 1199px) { 1128 | .visible-md-inline-block { 1129 | display: inline-block !important; 1130 | } 1131 | } 1132 | @media (min-width: 1200px) { 1133 | .visible-lg { 1134 | display: block !important; 1135 | } 1136 | table.visible-lg { 1137 | display: table !important; 1138 | } 1139 | tr.visible-lg { 1140 | display: table-row !important; 1141 | } 1142 | th.visible-lg, 1143 | td.visible-lg { 1144 | display: table-cell !important; 1145 | } 1146 | } 1147 | @media (min-width: 1200px) { 1148 | .visible-lg-block { 1149 | display: block !important; 1150 | } 1151 | } 1152 | @media (min-width: 1200px) { 1153 | .visible-lg-inline { 1154 | display: inline !important; 1155 | } 1156 | } 1157 | @media (min-width: 1200px) { 1158 | .visible-lg-inline-block { 1159 | display: inline-block !important; 1160 | } 1161 | } 1162 | @media (max-width: 767px) { 1163 | .hidden-xs { 1164 | display: none !important; 1165 | } 1166 | } 1167 | @media (min-width: 768px) and (max-width: 991px) { 1168 | .hidden-sm { 1169 | display: none !important; 1170 | } 1171 | } 1172 | @media (min-width: 992px) and (max-width: 1199px) { 1173 | .hidden-md { 1174 | display: none !important; 1175 | } 1176 | } 1177 | @media (min-width: 1200px) { 1178 | .hidden-lg { 1179 | display: none !important; 1180 | } 1181 | } 1182 | .visible-print { 1183 | display: none !important; 1184 | } 1185 | @media print { 1186 | .visible-print { 1187 | display: block !important; 1188 | } 1189 | table.visible-print { 1190 | display: table !important; 1191 | } 1192 | tr.visible-print { 1193 | display: table-row !important; 1194 | } 1195 | th.visible-print, 1196 | td.visible-print { 1197 | display: table-cell !important; 1198 | } 1199 | } 1200 | .visible-print-block { 1201 | display: none !important; 1202 | } 1203 | @media print { 1204 | .visible-print-block { 1205 | display: block !important; 1206 | } 1207 | } 1208 | .visible-print-inline { 1209 | display: none !important; 1210 | } 1211 | @media print { 1212 | .visible-print-inline { 1213 | display: inline !important; 1214 | } 1215 | } 1216 | .visible-print-inline-block { 1217 | display: none !important; 1218 | } 1219 | @media print { 1220 | .visible-print-inline-block { 1221 | display: inline-block !important; 1222 | } 1223 | } 1224 | @media print { 1225 | .hidden-print { 1226 | display: none !important; 1227 | } 1228 | } 1229 | --------------------------------------------------------------------------------