├── .gitignore ├── Dockerfile-db ├── Dockerfile-frontend ├── Dockerfile-server ├── client ├── Gruntfile.js ├── changelog.jade ├── css │ ├── cssreset-min.css │ ├── font-awesome-4.2.0.min.css │ ├── landing-page-background.jpg │ ├── markdown.less │ ├── noise-3percent.png │ └── style.less ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ └── fontawesome-webfont.woff ├── index.jade ├── js │ ├── Duration.js │ ├── ModalDialogView.js │ ├── ModalOverlayView.js │ ├── ModalTaskEditDialogView.js │ ├── ProjectTreeSubTaskListView.js │ ├── ProjectTreeSubTaskView.js │ ├── ProjectTreeView.js │ ├── ProjectView.js │ ├── guid.js │ ├── main.js │ ├── task.js │ └── vendor │ │ ├── backbone-1.1.2.min.js │ │ ├── backbone-nested-0.7.0.min.js │ │ ├── jquery-2.1.3.min.js │ │ ├── jquery-ui-1.11.2.drag-drop.min.js │ │ └── underscore-1.7.0.min.js ├── landing-page-illustrations │ ├── bell.svg │ ├── drag.svg │ └── tree.svg ├── manual.jade ├── package.json └── task.json ├── docker-compose.yml ├── license.md ├── readme.md └── server ├── app.js ├── bin └── www ├── config.json ├── package.json ├── routes └── api.js ├── schema.sql ├── table-definitions.js └── views └── error.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_store 3 | *.css 4 | *.html 5 | -------------------------------------------------------------------------------- /Dockerfile-db: -------------------------------------------------------------------------------- 1 | FROM postgres:10.4-alpine 2 | 3 | VOLUME /var/lib/postgresql/data 4 | 5 | COPY /server/schema.sql /docker-entrypoint-initdb.d/schema.sql 6 | 7 | WORKDIR / 8 | 9 | EXPOSE 5432 10 | -------------------------------------------------------------------------------- /Dockerfile-frontend: -------------------------------------------------------------------------------- 1 | FROM node:8.11-alpine 2 | 3 | RUN npm install -g http-server grunt-cli 4 | 5 | RUN mkdir /estimator 6 | 7 | COPY /client /estimator/client 8 | 9 | WORKDIR /estimator/client 10 | 11 | RUN npm install && npm install grunt 12 | 13 | WORKDIR /estimator/client 14 | 15 | RUN grunt less && grunt autoprefixer && grunt jade 16 | 17 | EXPOSE 80 18 | 19 | CMD ['http-server'] 20 | -------------------------------------------------------------------------------- /Dockerfile-server: -------------------------------------------------------------------------------- 1 | FROM node:4-alpine 2 | 3 | RUN mkdir /estimator 4 | 5 | COPY /server /estimator/server 6 | 7 | WORKDIR /estimator/server 8 | 9 | RUN sed -in 's/username:password@localhost/postgres:postgres@db/' config.json 10 | 11 | RUN cat config.json 12 | 13 | RUN npm install -g yarn 14 | 15 | RUN yarn install 16 | 17 | EXPOSE 3000 18 | 19 | CMD ['node', './bin/www'] 20 | -------------------------------------------------------------------------------- /client/Gruntfile.js: -------------------------------------------------------------------------------- 1 | 2 | // Inspired by http://anthonydel.com/my-personal-gruntfile-for-front-end-experiments/ 3 | 4 | module.exports = function(grunt) { 5 | 6 | grunt.initConfig({ 7 | 8 | // running `grunt less` will compile once 9 | less: { 10 | development: { 11 | options: { 12 | paths: ['./css'], 13 | yuicompress: false 14 | }, 15 | files: { 16 | './css/style.css': './css/style.less', 17 | './css/markdown.css': './css/markdown.less' 18 | } 19 | } 20 | }, 21 | 22 | // configure autoprefixing for compiled output css 23 | autoprefixer: { 24 | build: { 25 | // expand: true, 26 | // cwd: BUILD_DIR, 27 | src: ['./css/style.css', './css/markdown.css'], 28 | // dest: BUILD_DIR 29 | } 30 | }, 31 | 32 | jade: { 33 | compile: { 34 | options: { 35 | data: { 36 | pretty: true, 37 | debug: true, 38 | // apiBaseUrl: 'http://estimator.topmost.se:8084' 39 | apiBaseUrl: 'http://localhost:3000' 40 | } 41 | }, 42 | files: { 43 | 'index.html': 'index.jade', 44 | 'manual.html': 'manual.jade', 45 | 'changelog.html': 'changelog.jade' 46 | } 47 | } 48 | }, 49 | 50 | // running `grunt watch` will watch for changes 51 | watch: { 52 | 53 | stylesless: { 54 | options: { livereload: true }, 55 | files: ['./css/*.less'], 56 | tasks: ['less:development', 'autoprefixer'] 57 | }, 58 | 59 | jade: { 60 | options: { livereload: true }, 61 | files: ['*.jade'], 62 | tasks: ['jade'] 63 | } 64 | }, 65 | 66 | }); 67 | 68 | grunt.loadNpmTasks('grunt-contrib-less'); 69 | grunt.loadNpmTasks('grunt-autoprefixer'); 70 | grunt.loadNpmTasks('grunt-contrib-jade'); 71 | grunt.loadNpmTasks('grunt-contrib-watch'); 72 | }; 73 | 74 | -------------------------------------------------------------------------------- /client/changelog.jade: -------------------------------------------------------------------------------- 1 | doctype html5 2 | head 3 | link(rel='stylesheet', href='css/markdown.css') 4 | body 5 | :markdown 6 | 7 | 2015-02-05 8 | ========== 9 | 10 | As requested in feedback: https://news.ycombinator.com/item?id=8980292 11 | 12 | * Changed the edit button icon to a pen instead of cog wheel. 13 | * Removed the hover-to-see on the edit/create buttons. 14 | * Added mouse pointer CSS. 15 | * Made delete button less prominent. 16 | * Added explicit save button. Closing without manually saving now cancels. 17 | * Propagate changes to "Done" up and down the tree. 18 | * Wrote "[manual](manual.html)". 19 | 20 | 21 | 2015-02-01 22 | ========== 23 | 24 | First public release. -------------------------------------------------------------------------------- /client/css/cssreset-min.css: -------------------------------------------------------------------------------- 1 | /* 2 | YUI 3.18.1 (build f7e7bcb) 3 | Copyright 2014 Yahoo! Inc. All rights reserved. 4 | Licensed under the BSD License. 5 | http://yuilibrary.com/license/ 6 | */ 7 | 8 | html{color:#000;background:#FFF}body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea,p,blockquote,th,td{margin:0;padding:0}table{border-collapse:collapse;border-spacing:0}fieldset,img{border:0}address,caption,cite,code,dfn,em,strong,th,var{font-style:normal;font-weight:normal}ol,ul{list-style:none}caption,th{text-align:left}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal}q:before,q:after{content:''}abbr,acronym{border:0;font-variant:normal}sup{vertical-align:text-top}sub{vertical-align:text-bottom}input,textarea,select{font-family:inherit;font-size:inherit;font-weight:inherit;*font-size:100%}legend{color:#000}#yui3-css-stamp.cssreset{display:none} 9 | -------------------------------------------------------------------------------- /client/css/font-awesome-4.2.0.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.2.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.2.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.2.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff?v=4.2.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.2.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.2.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"} -------------------------------------------------------------------------------- /client/css/landing-page-background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geon/estimator/369824b8b22e5c9c238c8b8ff439534f7c049378/client/css/landing-page-background.jpg -------------------------------------------------------------------------------- /client/css/markdown.less: -------------------------------------------------------------------------------- 1 | 2 | @background-color: #fff; 3 | @text-color: #444; 4 | @accent-color-primary: #5cf; 5 | @accent-color-secondary: #f4a; 6 | 7 | @font-size: 16px; 8 | @line-height: 1.5em; 9 | 10 | 11 | 12 | @import (css) 'cssreset-min.css'; 13 | 14 | // Set global font for everything. 15 | * { 16 | 17 | font-family: inherit; 18 | font-family: inherit; 19 | letter-spacing: inherit; 20 | } 21 | html { 22 | 23 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif, FontAwesome; 24 | font-size: @font-size; 25 | letter-spacing: 0.03em; // The font is just a *tiiiny* bit too cramped. 26 | 27 | background: @background-color; 28 | } 29 | 30 | h1, h2, h3, p, ul, pre, hr { 31 | 32 | margin-top: @line-height; 33 | } 34 | 35 | h1, h2, h3 { 36 | 37 | font-family: Georgia, serif; 38 | color: @accent-color-primary; 39 | font-weight: normal; 40 | } 41 | 42 | h1 { 43 | 44 | font-size: 2.25em; 45 | } 46 | 47 | h2 { 48 | 49 | font-size: 1.5em; 50 | } 51 | 52 | h3 { 53 | 54 | font-size: 1em; 55 | font-weight: bold; 56 | } 57 | 58 | h3 + p { 59 | 60 | margin-top: 0; 61 | } 62 | 63 | p, li { 64 | 65 | line-height: @line-height; 66 | } 67 | 68 | a { 69 | 70 | text-decoration: none; 71 | color: @accent-color-secondary; 72 | 73 | &:visited { 74 | 75 | color: @accent-color-primary; 76 | } 77 | 78 | &:hover { 79 | 80 | text-decoration: underline; 81 | } 82 | } 83 | 84 | ul { 85 | 86 | > li { 87 | 88 | margin-left: 1em; 89 | 90 | &:before { 91 | 92 | @size: 8px; 93 | 94 | content: ''; 95 | 96 | display: inline-block; 97 | width: @size; 98 | height: @size; 99 | 100 | background: @accent-color-primary; 101 | border-radius: @size/2; 102 | 103 | margin-right: .5em; 104 | margin-bottom: calc(~'@{line-height} / 4 - @{size} / 2'); 105 | } 106 | } 107 | } 108 | 109 | pre { 110 | 111 | margin-left: 1em; 112 | } 113 | 114 | code { 115 | 116 | @padding-h: 0.25em; 117 | @padding-v: 0.1em; 118 | 119 | background: #eee; 120 | padding: @padding-v @padding-h; 121 | border-radius: @padding-h; 122 | } 123 | 124 | 125 | 126 | // "Layout" 127 | body { 128 | 129 | width: 600px; 130 | margin: 50px auto; 131 | } 132 | -------------------------------------------------------------------------------- /client/css/noise-3percent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geon/estimator/369824b8b22e5c9c238c8b8ff439534f7c049378/client/css/noise-3percent.png -------------------------------------------------------------------------------- /client/css/style.less: -------------------------------------------------------------------------------- 1 | 2 | @landing-page-background-color: #001f23; 3 | @project-background-color: #8097AD; 4 | 5 | @text-color-dark: #000; 6 | @text-color-light: #fff; 7 | 8 | @font-size: 16px; 9 | @line-height: 1.5em; 10 | 11 | @task-color-white: #fff; 12 | @task-color-black: #333; 13 | @task-color-blue: #38f; 14 | @task-color-purple: #a29; 15 | @task-color-red: #f45; 16 | @task-color-orange: #f82; 17 | @task-color-yellow: #ff5; 18 | @task-color-green: #6f2; 19 | 20 | 21 | 22 | 23 | // Mixins 24 | 25 | .return-contrasting-color(@color, @dark: @text-color-dark, @light: @text-color-light) when (luma(@color) > 50%) { 26 | @contrasting-color: @dark; 27 | } 28 | .return-contrasting-color(@color, @dark: @text-color-dark, @light: @text-color-light) when (luma(@color) =< 50%) { 29 | @contrasting-color: @light; 30 | } 31 | .contrasting-color(@color, @dark: @text-color-dark, @light: @text-color-light) when (luma(@color) > 50%) { 32 | 33 | color: @dark; 34 | } 35 | .contrasting-color(@color, @dark: @text-color-dark, @light: @text-color-light) when (luma(@color) =< 50%) { 36 | 37 | // Webkit on OS X has a rendering bug, causing light text on dark to be slightly bolder. (Not just the illusion.) 38 | // https://web.archive.org/web/20131019233655/http://tanookilabs.com/your-fonts-look-bad-in-chrome-heres-the-fix 39 | -webkit-font-smoothing: antialiased; 40 | 41 | color: @light; 42 | } 43 | .contrasting-color-faded(@color, @amount: 75%, @dark: @text-color-dark, @light: @text-color-light) { 44 | 45 | .contrasting-color(@color, fade(@dark, @amount), fade(@light, @amount)); 46 | } 47 | .contrasting-color-mixed(@color, @amount: 75%, @dark: @text-color-dark, @light: @text-color-light) { 48 | 49 | .contrasting-color(@color, mix(@dark, @color, @amount), mix(@light, @color, @amount)); 50 | } 51 | 52 | 53 | 54 | 55 | @import (css) 'cssreset-min.css'; 56 | 57 | // Set global font for everything. 58 | * { 59 | 60 | font-family: inherit; 61 | font-family: inherit; 62 | letter-spacing: inherit; 63 | } 64 | html { 65 | 66 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif, FontAwesome; 67 | font-size: @font-size; 68 | letter-spacing: 0.03em; // The font is just a *tiiiny* bit too cramped. 69 | 70 | background: @landing-page-background-color; 71 | } 72 | 73 | h1, h2, h3, p, hr { 74 | 75 | margin-top: @line-height; 76 | } 77 | 78 | h1 { 79 | 80 | font-size: 1.5em; 81 | } 82 | 83 | h2 { 84 | 85 | font-size: 1.25em; 86 | } 87 | 88 | h3 { 89 | 90 | font-size: 1em; 91 | font-weight: bold; 92 | } 93 | 94 | h3 + p { 95 | 96 | margin-top: 0; 97 | } 98 | 99 | p { 100 | 101 | line-height: @line-height; 102 | } 103 | 104 | a { 105 | 106 | // TODO: color 107 | text-decoration: none; 108 | 109 | &:hover { 110 | 111 | text-decoration: underline; 112 | } 113 | } 114 | 115 | button { 116 | 117 | cursor: pointer; 118 | } 119 | 120 | 121 | 122 | 123 | body[data-current-page='landing'] { 124 | 125 | background: @landing-page-background-color url(landing-page-background.jpg); 126 | background-repeat: no-repeat; 127 | background-position: center top; 128 | } 129 | 130 | .landing { 131 | 132 | .contrasting-color(@landing-page-background-color); 133 | 134 | // Prevents the margins from collapsing; 135 | &::before{ 136 | 137 | content: ' '; //   138 | display: block; 139 | overflow: hidden; 140 | width: 0; 141 | height: 0; 142 | } 143 | 144 | h1, h2, p, a.button { 145 | 146 | font-weight: normal; 147 | letter-spacing: 1.3; 148 | line-height: 1.3em; 149 | } 150 | 151 | p, a.button { 152 | 153 | font-size: 20px; 154 | } 155 | 156 | h1.logo { 157 | 158 | font-family: Georgia, Times, Times New Roman, serif; 159 | font-weight: normal; 160 | text-align: center; 161 | 162 | // @media only screen and (max-width : 959px) { 163 | // font-size: 400px; 164 | // line-height: 200px; 165 | // margin: 50px 0; 166 | // } 167 | 168 | // @media only screen and (min-width : 960px) { 169 | font-size: 500px; 170 | line-height: 300px; 171 | margin: 150px 0 50px 0; 172 | // } 173 | } 174 | 175 | p.tagline { 176 | 177 | text-align: center; 178 | margin: 0 20px; 179 | } 180 | 181 | a.button.create { 182 | 183 | width: 150px; 184 | text-align: center; 185 | display: block; 186 | margin: 0 auto; 187 | font-size: 20px; 188 | color: white; 189 | text-decoration: none; 190 | border: 1px solid; 191 | border-radius: 10px; 192 | padding: 10px 20px; 193 | transition: background .1s; 194 | 195 | &:hover { 196 | 197 | background: rgba(255, 255, 255, .15); 198 | transition: background .2s; 199 | } 200 | 201 | // @media only screen and (max-width : 959px) { 202 | 203 | // margin: 50px auto 150px auto; 204 | // } 205 | 206 | // @media only screen and (min-width : 960px) { 207 | 208 | margin: 150px auto; 209 | // } 210 | } 211 | 212 | div.columns { 213 | 214 | margin: 0 auto; 215 | overflow: auto; 216 | 217 | // @media only screen and (min-width : 960px) { 218 | 219 | display: flex; 220 | align-items: flex-start; 221 | width: 250px * 3 + 50px * 2; 222 | margin: 0 auto 100px auto; 223 | 224 | > div:last-child { 225 | 226 | margin-right: 0; 227 | } 228 | // } 229 | 230 | h2 { 231 | 232 | font-size: 20px; 233 | font-weight: bold; 234 | text-align: center; 235 | margin-top: 50px; 236 | } 237 | 238 | p { 239 | 240 | font-size: 16px; 241 | } 242 | 243 | img { 244 | 245 | display: block; 246 | margin: 0 auto; 247 | } 248 | 249 | > div { 250 | 251 | // @media only screen and (max-width : 959px) { 252 | 253 | // margin: 0 20px 120px 20px; 254 | // } 255 | 256 | // @media only screen and (min-width : 960px) { 257 | 258 | width: 250px; 259 | margin: 0; 260 | margin-right: 50px; 261 | // } 262 | } 263 | } 264 | 265 | .markdown-links { 266 | 267 | font-size: 16px; 268 | text-align: center; 269 | margin-bottom: 100px; 270 | 271 | a { 272 | 273 | color: inherit; 274 | font-weight: bold; 275 | 276 | &:hover { 277 | 278 | text-decoration: underline; 279 | } 280 | } 281 | } 282 | } 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | body[data-current-page='project'] { 292 | 293 | background-size: 50px 50px; 294 | background-color: @project-background-color; 295 | @stripe-color: desaturate(darken(@project-background-color, 3%), 0%); 296 | background-image: linear-gradient(-45deg, 297 | @stripe-color 25%, 298 | transparent 25%, 299 | transparent 50%, 300 | @stripe-color 50%, 301 | @stripe-color 75%, 302 | transparent 75% 303 | ); 304 | } 305 | 306 | .project { 307 | 308 | padding: 50px; 309 | 310 | .contrasting-color(@project-background-color); 311 | 312 | input.title { 313 | 314 | font-size: 2em; 315 | 316 | background: transparent; 317 | color: @text-color-light; 318 | 319 | border-radius: 3px; 320 | border: 1px solid transparent; 321 | 322 | padding: 8px; 323 | margin-left: -11px; 324 | 325 | &:focus { 326 | 327 | // Disable the default focus-style. 328 | outline: none; 329 | 330 | border-color: inherit; 331 | } 332 | } 333 | 334 | > p.estimate { 335 | 336 | margin-top: 0; 337 | margin-bottom: 2em; 338 | 339 | color: @text-color-light; 340 | } 341 | 342 | .tree-view { 343 | 344 | @task-width: 200px; 345 | @task-margin: 16px; 346 | @task-radius: 5px; 347 | 348 | margin-top: 2em; 349 | 350 | ul { 351 | 352 | margin: 0; 353 | padding: 0; 354 | list-style: none; 355 | 356 | li { 357 | 358 | min-height: 30px; 359 | 360 | margin: 0; 361 | padding: 0; 362 | 363 | display: flex; 364 | align-items: stretch; 365 | 366 | &.insert.after { 367 | 368 | margin-bottom: 10px; 369 | } 370 | 371 | > div.task { 372 | 373 | cursor: move; 374 | 375 | display: inline-block; 376 | 377 | width: @task-width; 378 | 379 | // box-sizing: border-box; 380 | // padding: 10px; 381 | 382 | margin: 0 @task-margin @task-margin 0; 383 | border-radius: @task-radius; 384 | 385 | @title-margin-h: 14px; 386 | @title-margin-v: 10px; 387 | 388 | h1, input { 389 | 390 | cursor: text; 391 | 392 | font-size: 1.25em; 393 | 394 | padding: @title-margin-h/2 - 1px @title-margin-v/2 - 1px; 395 | margin: @title-margin-h/2 @title-margin-v/2; 396 | } 397 | 398 | h1 { 399 | 400 | border: 1px solid transparent; 401 | } 402 | 403 | div.description { 404 | 405 | font-size: 14px; 406 | 407 | margin: @title-margin-h @title-margin-v; 408 | margin-top: 0; 409 | } 410 | 411 | input { 412 | 413 | display: none; 414 | 415 | width: calc(~"100% -" @title-margin-v); 416 | 417 | background: transparent; 418 | color: inherit; 419 | 420 | border-radius: 3px; 421 | border: 1px solid; 422 | 423 | // Disable the default focus-style. 424 | &:focus { 425 | 426 | outline: none; 427 | } 428 | } 429 | 430 | &.editing-title h1 { 431 | 432 | display: none; 433 | } 434 | &.editing-title input { 435 | 436 | display: block; 437 | } 438 | 439 | // Estimates + buttons 440 | @bottom-row-height: 30px; 441 | @bottom-row-bottom-margin: 8px - 2px; // 2px: Task box shadow. 442 | padding-bottom: @bottom-row-height + @bottom-row-bottom-margin; 443 | .bottom-row { 444 | 445 | height: @bottom-row-height; 446 | width: 100%; 447 | position: absolute; 448 | bottom: @bottom-row-bottom-margin; 449 | 450 | > .durations { 451 | 452 | margin: 0 10px; 453 | 454 | > p { 455 | 456 | margin: 0; 457 | line-height: @bottom-row-height; 458 | } 459 | 460 | > ::before { 461 | 462 | margin-right: 5px; 463 | } 464 | 465 | .estimate::before { 466 | 467 | // fa-dot-circle-o 468 | content: '\f192'; 469 | } 470 | 471 | .projection::before { 472 | 473 | // fa-arrow-circle-right 474 | content: '\f0a9'; 475 | } 476 | 477 | .actual::before { 478 | 479 | // fa-check-circle 480 | content: '\f058'; 481 | } 482 | } 483 | 484 | // Buttons 485 | button { 486 | 487 | display: block; 488 | font-size: @bottom-row-height; 489 | line-height: @bottom-row-height; 490 | float: right; 491 | 492 | padding: 0; 493 | border: none; 494 | color: inherit; 495 | background: transparent; 496 | 497 | display: inline; 498 | 499 | &::before { 500 | 501 | margin: 0; 502 | display: inline; 503 | } 504 | } 505 | .add-sub-task { 506 | 507 | &::before { 508 | 509 | // fa-plus-square 510 | content: '\f0fe'; 511 | } 512 | 513 | margin-right: 10px; 514 | } 515 | .task-details { 516 | 517 | &::before { 518 | 519 | // fa-pencil-square 520 | content: '\f14b'; 521 | } 522 | 523 | margin-right: 5px; 524 | } 525 | } 526 | 527 | 528 | // Colors 529 | .task-color (@color) { 530 | 531 | .set-colors (@color, @title-fade, @description-fade) { 532 | 533 | .contrasting-color-faded(@color, @title-fade); 534 | background: @color; // url(noise-5percent.png); 535 | 536 | // background-image: linear-gradient(to bottom, @color 0%, darken(@color, 2%) 100%); 537 | box-shadow: 0px 2px 0px 0px darken(desaturate(@color, 30%), 10%); 538 | 539 | // Make the description text less prominent, except for links. 540 | div.description { 541 | 542 | .contrasting-color-faded(@color, @description-fade); 543 | 544 | a { 545 | .contrasting-color(@color); 546 | text-decoration: none; 547 | } 548 | } 549 | 550 | .bottom-row { 551 | 552 | button { 553 | 554 | transition: opacity .2s; 555 | 556 | &:not(:hover) { 557 | 558 | opacity: .3; 559 | transition: opacity .1s; 560 | } 561 | } 562 | } 563 | } 564 | 565 | &:not(.done) { 566 | 567 | .set-colors (@color, 100%, 75%); 568 | } 569 | 570 | &.done { 571 | 572 | opacity: .5; 573 | .set-colors (lighten(desaturate(@color, 10%), 25%), 40%, 30%); 574 | } 575 | } 576 | // Use a default color, or they will fade in from transparent when they are created. :-/. 577 | .task-color(@task-color-white); 578 | &[data-color='black'] { 579 | .task-color(@task-color-black); 580 | } 581 | &[data-color='blue'] { 582 | .task-color(@task-color-blue); 583 | } 584 | &[data-color='purple'] { 585 | .task-color(@task-color-purple); 586 | } 587 | &[data-color='red'] { 588 | .task-color(@task-color-red); 589 | } 590 | &[data-color='orange'] { 591 | .task-color(@task-color-orange); 592 | } 593 | &[data-color='yellow'] { 594 | .task-color(@task-color-yellow); 595 | } 596 | &[data-color='green'] { 597 | .task-color(@task-color-green); 598 | } 599 | @fade-time: .3s; 600 | transition: color @fade-time, background-color @fade-time, box-shadow @fade-time; 601 | 602 | 603 | // Drop targets 604 | position: relative; 605 | >.drop-target { 606 | 607 | position: absolute; 608 | 609 | width: @task-width; 610 | 611 | &.before { 612 | 613 | top: -@task-margin/2; 614 | bottom: 50%; 615 | } 616 | &.after { 617 | 618 | top: 50%; 619 | bottom: -@task-margin/2; 620 | } 621 | &.child { 622 | 623 | // Set it up to cover the entire area where a child 624 | // would go, but show it only for leaf nodes. 625 | 626 | display: none; 627 | 628 | width: (@task-margin + @task-width); 629 | right: -(@task-margin + @task-width); 630 | 631 | top: -@task-margin/2; 632 | bottom: -@task-margin/2; 633 | 634 | // Make hovering not show the task buttons. 635 | pointer-events: none; 636 | } 637 | } 638 | } 639 | 640 | &.leaf > .task > .drop-target.child { 641 | 642 | display: block; 643 | } 644 | } 645 | } 646 | 647 | // Make the drop targets only show up while dragging. 648 | // (Leaf nodes child-targets are still visible, but they don't overlap the task.) 649 | .drop-target { 650 | 651 | display: none; 652 | } 653 | &.dragging .drop-target { 654 | 655 | display: block; 656 | } 657 | 658 | > button.add-sub-task { 659 | 660 | display: block; 661 | height: 52px; 662 | width: @task-width; 663 | 664 | font-size: 1.25em; 665 | 666 | @letterpress-amount: 10%; 667 | 668 | & when (luma(@project-background-color) > 50%) { 669 | 670 | text-shadow: 0 -1px 0 darken(@project-background-color, @letterpress-amount); 671 | } 672 | & when (luma(@project-background-color) <= 50%) { 673 | 674 | text-shadow: 0 1px 0 lighten(@project-background-color, @letterpress-amount); 675 | } 676 | 677 | color: @text-color-light; 678 | // -webkit-font-smoothing: antialiased; 679 | background: transparent; 680 | border-radius: @task-radius; 681 | border: 3px dotted; 682 | } 683 | } 684 | 685 | p.manual a { 686 | 687 | color: inherit; 688 | 689 | &:hover { 690 | 691 | text-decoration: underline; 692 | } 693 | } 694 | } 695 | 696 | 697 | // Modal 698 | body { 699 | 700 | #modal-overlay { 701 | 702 | display: none; 703 | 704 | position: fixed; 705 | top: 0; 706 | left: 0; 707 | width: 100%; 708 | height: 100%; 709 | 710 | background:rgba(255, 255, 255, .5) url('noise-3percent.png'); 711 | 712 | 713 | // Flexbox stuff to center `.dialog`. 714 | align-items: center; 715 | justify-content: center; 716 | 717 | .dialog { 718 | 719 | margin: 0 auto; 720 | 721 | background: white; 722 | 723 | padding: 30px; 724 | border-radius: 10px; 725 | box-shadow: 0px 2px 20px 0px rgba(0, 0, 0, .2); 726 | } 727 | } 728 | 729 | &.modal-overlay { 730 | 731 | #page { 732 | 733 | // Just eyekandy. IE support doesn't matter. 734 | -webkit-filter: blur(5px); 735 | filter: blur(5px); 736 | } 737 | 738 | #modal-overlay { 739 | 740 | display: flex; 741 | } 742 | } 743 | } 744 | 745 | 746 | 747 | .dialog.edit-task { 748 | 749 | width: 400px; 750 | 751 | input.title { 752 | 753 | font-size: 1.25em; 754 | width: 100%; 755 | } 756 | 757 | .estimates { 758 | 759 | margin: 20px 0 0 0; 760 | 761 | display: flex; 762 | align-items: center; 763 | flex-direction: column; 764 | 765 | label { 766 | 767 | width: 280px; 768 | text-align: right; 769 | 770 | font-size: 14px; 771 | 772 | input { 773 | 774 | width: 130px; 775 | margin-left: 20px; 776 | } 777 | } 778 | } 779 | 780 | .done { 781 | 782 | font-size: 14px; 783 | 784 | display: block; 785 | text-align: center; 786 | 787 | margin-top: 20px; 788 | } 789 | 790 | textarea { 791 | 792 | margin: 20px 0; 793 | padding: 10px; 794 | 795 | font-size: 14px; 796 | 797 | width: 100%; 798 | height: 100px; 799 | resize: none; 800 | 801 | &:focus { 802 | 803 | resize: vertical; 804 | } 805 | } 806 | 807 | input.title, .estimates input, textarea { 808 | 809 | background: transparent; 810 | color: inherit; 811 | 812 | border-radius: 3px; 813 | border: 1px solid transparent; 814 | 815 | &:focus { 816 | 817 | // Disable the default focus-style. 818 | outline: none; 819 | 820 | border-color: inherit; 821 | } 822 | } 823 | 824 | .estimates input, textarea { 825 | 826 | font-size: 14px; 827 | } 828 | 829 | .colors { 830 | 831 | margin: 20px 0; 832 | 833 | display: flex; 834 | justify-content: center; 835 | 836 | label { 837 | 838 | @size: 16px; 839 | @selected-size: 24px; 840 | 841 | // Make it large. 842 | width: @selected-size; 843 | height: @selected-size; 844 | 845 | margin: 0; 846 | 847 | input { 848 | 849 | // Hide the default widget. 850 | // Opacity makes it tabbable & arrowable. 851 | opacity: 0; 852 | width: 0; 853 | height: 0; 854 | margin: 0; 855 | } 856 | 857 | // Colors 858 | .task-color (@color) { 859 | 860 | span { 861 | 862 | cursor: pointer; 863 | 864 | content: ''; 865 | display: block; 866 | background: @color; 867 | 868 | width: @size; 869 | height: @size; 870 | border-radius: @size/2; 871 | margin: (@selected-size - @size)/2; 872 | 873 | transition: transform .2s; 874 | 875 | // Add extra contrast around bleak colors. 876 | & when (luma(@color) > 50%) { 877 | 878 | box-shadow: inset 0px 0px 2px 0px darken(desaturate(@color, 20%), 20%); 879 | } 880 | } 881 | 882 | // The sibling selector lets me apply a :checked style to a 883 | // completely separate element where I control the content. 884 | // Much more flexible than input::before. 885 | // Thanks, Heydon! http://www.sitepoint.com/replacing-radio-buttons-without-replacing-radio-buttons/ 886 | span:hover { 887 | 888 | transform: scale(unit((@selected-size+@size)/2/@size)); 889 | transition: transform .1s; 890 | } 891 | input:checked + span { 892 | 893 | transform: scale(unit(@selected-size/@size)); 894 | } 895 | } 896 | &.white { 897 | .task-color(@task-color-white); 898 | } 899 | &.black { 900 | .task-color(@task-color-black); 901 | } 902 | &.blue { 903 | .task-color(@task-color-blue); 904 | } 905 | &.purple { 906 | .task-color(@task-color-purple); 907 | } 908 | &.red { 909 | .task-color(@task-color-red); 910 | } 911 | &.orange { 912 | .task-color(@task-color-orange); 913 | } 914 | &.yellow { 915 | .task-color(@task-color-yellow); 916 | } 917 | &.green { 918 | .task-color(@task-color-green); 919 | } 920 | } 921 | } 922 | 923 | .bottom-buttons { 924 | 925 | button { 926 | 927 | margin: 0 auto; 928 | display: block; 929 | 930 | font-size: 1.25em; 931 | font-weight: bold; 932 | 933 | border-radius: 5px; 934 | padding: 6px 20px; 935 | border: none; 936 | 937 | .color (@color) { 938 | 939 | @bulge: 6%; 940 | @letterpress-amount: 10%; 941 | 942 | & when (luma(@color) > 50%) { 943 | 944 | text-shadow: 0 1px 0 lighten(@color, @letterpress-amount); 945 | box-shadow: inset 0px 0px 2px 0px darken(@color, @letterpress-amount); 946 | } 947 | & when (luma(@color) <= 50%) { 948 | 949 | text-shadow: 0 -1px 0 darken(@color, @letterpress-amount); 950 | } 951 | .contrasting-color(@color); 952 | background: @color linear-gradient(to top, darken(@color, @bulge), @color, lighten(@color, @bulge)); 953 | 954 | &:active { 955 | 956 | background: @color; 957 | } 958 | 959 | &:focus { 960 | 961 | outline: none; 962 | } 963 | } 964 | 965 | &.delete { 966 | 967 | .color (#f31); 968 | 969 | float: left; 970 | } 971 | 972 | &.save { 973 | 974 | .color (#f8f8f8); 975 | 976 | float: right; 977 | } 978 | } 979 | } 980 | 981 | position: relative; 982 | i.close { 983 | 984 | position: absolute; 985 | right: 10px; 986 | top: 10px; 987 | font-size: 22px; 988 | 989 | color: #ccc; 990 | transition: color 0.1s; 991 | &:hover { 992 | 993 | color: #aaa; 994 | transition: color 0.2s; 995 | } 996 | } 997 | } 998 | -------------------------------------------------------------------------------- /client/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geon/estimator/369824b8b22e5c9c238c8b8ff439534f7c049378/client/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /client/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geon/estimator/369824b8b22e5c9c238c8b8ff439534f7c049378/client/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /client/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geon/estimator/369824b8b22e5c9c238c8b8ff439534f7c049378/client/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /client/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geon/estimator/369824b8b22e5c9c238c8b8ff439534f7c049378/client/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /client/index.jade: -------------------------------------------------------------------------------- 1 | doctype html5 2 | head 3 | link(rel='stylesheet', href='css/style.css') 4 | link(rel='stylesheet', href='css/font-awesome-4.2.0.min.css') 5 | body 6 | #page 7 | #modal-overlay 8 | 9 | 10 | script.js-landing(type='template') 11 | .landing 12 | h1.logo ≈ 13 | p.tagline Super easy project planning and estimation 14 | a.button.create(href='#createProject') Create project 15 | .columns 16 | div 17 | img(width='160', src='landing-page-illustrations/tree.svg') 18 | h2 Break it down 19 | p 20 | | Add tasks to your project and break them down into subtasks. The tree layout highlights the structure of your project. 21 | div 22 | img(width='160', src='landing-page-illustrations/drag.svg') 23 | h2 Drag & drop 24 | p 25 | | Reorganize your project easily and intuitively. Does some subtask deserve more focus? Is some task prioritized? Just drag it over. 26 | div 27 | img(width='160', src='landing-page-illustrations/bell.svg') 28 | h2 Estimate 29 | p 30 | | Get detailed estimates for your project, even with incomplete data. Just enter whatever estimates and actual values you have. 31 | p.markdown-links 32 | | New here? Check out the 33 | a(href='manual.html') manual 34 | | . Coming back? There's a 35 | a(href='changelog.html') changelog 36 | | for you. 37 | 38 | script.js-project(type='template') 39 | .project 40 | input.title.js-title(placeholder='Project title') 41 | p.estimate.js-estimate 42 | .tree-view 43 | ul 44 | button.add-sub-task.js-add-sub-task: i.fa.fa-plus 45 | p.manual: a(href='../manual.html') Manual 46 | 47 | 48 | script.js-task(type='template') 49 | li 50 | .task.js-task 51 | h1 52 | input(placeholder='Task title') 53 | .description.js-description 54 | 55 | .bottom-row 56 | button.add-sub-task.js-add-sub-task 57 | button.task-details.js-task-details 58 | .durations 59 | p.estimate.js-estimate(title='Estimate') 60 | p.projection.js-projection(title='Projection') 61 | p.actual.js-actual(title='Actual') 62 | .drop-target.before(rel='before') 63 | .drop-target.after(rel='after') 64 | .drop-target.child(rel='child') 65 | ul 66 | 67 | 68 | script.js-edit-task-dialog(type='template') 69 | .dialog.edit-task 70 | 71 | input.title.js-title(placeholder='Task title') 72 | 73 | .colors 74 | each color in ['white', 'black', 'red', 'orange', 'yellow', 'green', 'blue', 'purple'] 75 | label(class=color) 76 | input(type='radio', name='color', value=color) 77 | span 78 | 79 | .estimates.js-estimates 80 | label 81 | span Min estimate 82 | input.from.js-from(placeholder='Unknown') 83 | label 84 | span Max estimate 85 | input.to.js-to(placeholder='Unknown') 86 | label 87 | span Actual 88 | input.actual.js-actual(placeholder='Unknown') 89 | 90 | label.done 91 | input(type='checkbox').js-done 92 | | Done 93 | 94 | textarea.js-description(placeholder='Description') 95 | 96 | .bottom-buttons 97 | button.delete.js-delete 98 | i.fa.fa-trash 99 | | Delete 100 | button.save.js-save 101 | i.fa.fa-check 102 | | Save 103 | 104 | i.fa.fa-close.button.close.js-close 105 | 106 | 107 | - var apiBaseUrlQuoted = JSON.stringify(apiBaseUrl || ''); 108 | script. 109 | var apiBaseUrl = !{apiBaseUrlQuoted}; 110 | 111 | script(src='js/vendor/jquery-2.1.3.min.js') 112 | script(src='js/vendor/jquery-ui-1.11.2.drag-drop.min.js') 113 | script(src='js/vendor/underscore-1.7.0.min.js') 114 | script(src='js/vendor/backbone-1.1.2.min.js') 115 | script(src='js/vendor/backbone-nested-0.7.0.min.js') 116 | 117 | script(src='js/ModalOverlayView.js') 118 | script(src='js/ModalDialogView.js') 119 | script(src='js/ModalTaskEditDialogView.js') 120 | 121 | script(src='js/ProjectView.js') 122 | script(src='js/ProjectTreeView.js') 123 | script(src='js/ProjectTreeSubTaskView.js') 124 | script(src='js/ProjectTreeSubTaskListView.js') 125 | script(src='js/guid.js') 126 | script(src='js/Duration.js') 127 | script(src='js/task.js') 128 | script(src='js/main.js') 129 | -------------------------------------------------------------------------------- /client/js/Duration.js: -------------------------------------------------------------------------------- 1 | 2 | var Duration = {}; 3 | 4 | var units = [ 5 | { 6 | names: ['y', 'year'], 7 | plurals: ['y', 'years'], 8 | multiple: 45, 9 | basedOn: 'week' 10 | }, 11 | { 12 | names: ['mn', 'month'], 13 | plurals: ['mn', 'months'], 14 | multiple: 4, 15 | basedOn: 'week' 16 | }, 17 | { 18 | names: ['w', 'week'], 19 | plurals: ['w', 'weeks'], 20 | multiple: 5, 21 | basedOn: 'day' 22 | }, 23 | { 24 | names: ['d', 'day'], 25 | plurals: ['d', 'days'], 26 | multiple: 8, 27 | basedOn: 'h' 28 | }, 29 | { 30 | names: ['h', 'hour'], 31 | plurals: ['h', 'hours'], 32 | multiple: 60, 33 | basedOn: 'm' 34 | }, 35 | { 36 | names: ['min', 'm', 'minute'], 37 | plurals: ['min', 'm', 'minutes'], 38 | size: 60 39 | } 40 | ]; 41 | var unitsByName = {} 42 | units.forEach(function (unit) { 43 | 44 | function addName (name) { 45 | 46 | unitsByName[name] = unit; 47 | } 48 | 49 | unit.names.forEach(addName); 50 | unit.plurals.forEach(addName); 51 | }); 52 | units.forEach(function (unit) { 53 | 54 | function findSizeRecursively (unit) { 55 | 56 | return unit.size || 57 | unit.multiple * 58 | findSizeRecursively(unitsByName[unit.basedOn]); 59 | } 60 | 61 | unit.size = findSizeRecursively(unit); 62 | }); 63 | 64 | 65 | Duration.parse = function (text) { 66 | 67 | text = text.trim(); 68 | 69 | // Default is null. (Empty string.) 70 | var sum = null; 71 | 72 | var timePattern = /((\d+)([.,:]))?(\d+)(\s*(years?|y|months?|mn|weeks?|w|days?|d|h|min|m))?\s*,?\s*/g; 73 | var matches; 74 | while ((matches = timePattern.exec(text)) != null) { 75 | 76 | var value; 77 | if (matches[2]) { 78 | 79 | var integer = matches[2]; 80 | var separator = matches[3]; 81 | var decimals = matches[4]; 82 | 83 | if (separator == ':') { 84 | 85 | value = parseInt(integer, 10) + parseInt(decimals, 10) / 60; 86 | 87 | } else { 88 | 89 | value = parseFloat(integer+'.'+decimals, 10); 90 | } 91 | 92 | } else { 93 | 94 | value = parseInt(matches[4], 10); 95 | } 96 | 97 | var parsedUnit = unitsByName[matches[6] || 'h']; 98 | if (parsedUnit) { 99 | 100 | sum += value * parsedUnit.size; 101 | 102 | } else { 103 | 104 | // TODO: Return null on invalid data, so the field can show an error. 105 | } 106 | 107 | } 108 | 109 | return sum; 110 | }; 111 | 112 | 113 | Duration.format = function (seconds) { 114 | 115 | return Duration.formatParts(Duration.splitToParts(seconds)); 116 | }; 117 | 118 | 119 | Duration.formatRounded = function (seconds) { 120 | 121 | if (seconds == null) { 122 | 123 | return Duration.formatParts(seconds); 124 | } 125 | 126 | var parts = Duration.splitToParts(seconds); 127 | 128 | var firstUsedPartIndex; 129 | for (var i = 0; i < parts.length; i++) { 130 | 131 | if (parts[i].value) { 132 | 133 | firstUsedPartIndex = i; 134 | break; 135 | } 136 | } 137 | 138 | var first = parts[firstUsedPartIndex]; 139 | var second = parts[firstUsedPartIndex + 1]; 140 | var third = parts[firstUsedPartIndex + 2]; 141 | 142 | if (second && third) { 143 | 144 | second.value = Math.round(second.value + third.value / second.unit.multiple); 145 | } 146 | 147 | var roundedParts = []; 148 | if (first) { 149 | 150 | roundedParts.push(first); 151 | } 152 | if (second) { 153 | 154 | roundedParts.push(second); 155 | } 156 | 157 | // TODO: Bubble up unit overflows. 158 | 159 | return Duration.formatParts(roundedParts); 160 | }; 161 | 162 | 163 | Duration.formatParts = function (parts) { 164 | 165 | if (!parts) { 166 | 167 | return ''; 168 | } 169 | 170 | var partsWithValue = _.filter(parts, function (part) { return !!part.value; }); 171 | 172 | if (!partsWithValue.length) { 173 | 174 | return '0 h'; 175 | } 176 | 177 | return partsWithValue.map(function (duration) { 178 | 179 | return duration.value + ' ' + (duration.value == 1 ? duration.unit.names[0] : duration.unit.plurals[0]); 180 | 181 | }).join(', '); 182 | }; 183 | 184 | 185 | Duration.splitToParts = function (seconds) { 186 | 187 | if (seconds == null) { 188 | 189 | return null; 190 | } 191 | 192 | var parts = []; 193 | var left = seconds; 194 | 195 | units.forEach(function (unit) { 196 | 197 | var value = Math.floor(left / unit.size); 198 | 199 | parts.push({value: value, unit: unit}); 200 | 201 | left -= value * unit.size; 202 | }); 203 | 204 | return parts; 205 | }; 206 | -------------------------------------------------------------------------------- /client/js/ModalDialogView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ModalDialogView = Backbone.View.extend({ 4 | 5 | events: { 6 | 7 | 'click .js-close': 'hide', 8 | 'click': 'stopClickRomPropagatingToOverlay' 9 | }, 10 | 11 | 12 | initialize: function (options) { 13 | 14 | // Listen globally. Kind of ugly, but OK. 15 | this.boundHide = this.hide.bind(this); 16 | this.boundOnKeyDown = this.onKeyDown.bind(this); 17 | $('#modal-overlay').on('click', this.boundHide); 18 | $(document ).on('keydown', this.boundOnKeyDown); 19 | 20 | this.overlay = ModalOverlayView.getInstance(); 21 | }, 22 | 23 | 24 | stopClickRomPropagatingToOverlay: function (event) { 25 | 26 | // Clicks within the dialog should not propagate further, causing it to close. 27 | event.stopPropagation(); 28 | }, 29 | 30 | 31 | onKeyDown: function (event) { 32 | 33 | event.stopPropagation(); 34 | 35 | // 27: esc 36 | if (event.keyCode == 27) { 37 | 38 | this.hide(); 39 | } 40 | }, 41 | 42 | 43 | show: function () { 44 | 45 | this.$el.show(); 46 | this.overlay.show(); 47 | }, 48 | 49 | 50 | hide: function (options) { 51 | 52 | if (!(options && options.silent)) { 53 | 54 | this.trigger('close', options); 55 | } 56 | 57 | this.remove(); 58 | this.overlay.hide(); 59 | 60 | // Remove global event handlers. 61 | $('#modal-overlay').off('click', this.boundHide); 62 | $(document ).off('keydown', this.boundOnKeyDown); 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /client/js/ModalOverlayView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ModalOverlayView = Backbone.View.extend({ 4 | 5 | initialize: function (options) { 6 | 7 | this.depthCount = 0; 8 | }, 9 | 10 | 11 | show: function () { 12 | 13 | this.depthCount++; 14 | this.$el.toggleClass('modal-overlay', !!this.depthCount); 15 | }, 16 | 17 | 18 | hide: function () { 19 | 20 | this.depthCount--; 21 | this.$el.toggleClass('modal-overlay', !!this.depthCount); 22 | 23 | if (this.depthCount < 0) { 24 | 25 | console.error('Last modal overlay already hidden.'); 26 | } 27 | } 28 | }); 29 | 30 | 31 | ModalOverlayView.getInstance = function () { 32 | 33 | if (!this._instance) { 34 | 35 | this._instance = new this({ 36 | el: $('body') 37 | }); 38 | } 39 | 40 | return this._instance; 41 | } 42 | -------------------------------------------------------------------------------- /client/js/ModalTaskEditDialogView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ModalTaskEditDialogView = ModalDialogView.extend({ 4 | 5 | // Can't define events here. Add them in initialization. 6 | 7 | 8 | $template: $($.parseHTML( 9 | $('script.js-edit-task-dialog[type=template]').text() 10 | )), 11 | 12 | 13 | initialize: function (options) { 14 | 15 | // Copy model attributes for cancel. 16 | this.oldModelAttributes = _.extend({}, this.model.attributes); 17 | 18 | _.extend(this.events, { 19 | // Update the other views in realtime. 20 | 'change input.js-title, textarea.js-description, .js-estimates input, input[name="color"], input.js-done': 'collectData', 21 | 'keyup input.js-title, textarea.js-description': 'collectData', 22 | 'paste input.js-title, textarea.js-description': 'collectData', 23 | 24 | 'click button.js-delete': 'onClickDelete', 25 | 'click button.js-save' : 'onClickSave' 26 | }); 27 | 28 | // TODO: Send $el as option to the superclass constructor instead. 29 | this.$el = this.$template.clone(); 30 | this.el = this.$el.get(0); 31 | 32 | ModalDialogView.prototype.initialize.apply(this, arguments); 33 | 34 | this.$title = this.$('input.js-title'); 35 | this.$description = this.$('textarea.js-description'); 36 | this.$colorInputs = this.$('input[name="color"]'); 37 | 38 | this.$from = this.$('input.js-from'); 39 | this.$to = this.$('input.js-to'); 40 | this.$actual = this.$('input.js-actual'); 41 | 42 | this.$done = this.$('input.js-done'); 43 | 44 | this.applyModel(); 45 | 46 | $('#modal-overlay').append(this.$el); 47 | 48 | 49 | this.boundOnModelDestroy = function() { 50 | 51 | this.model = null; 52 | this.hide(); 53 | 54 | }.bind(this); 55 | this.model.once('destroy', this.boundOnModelDestroy); 56 | 57 | this.model.on('change:done', this.onChangeDone, this); 58 | this.model.on('change:actual', this.onChangeActual, this); 59 | this.model.on('change:from change:to change:actual change:done', this.onChangeEstimateAndActualAndDone, this); 60 | 61 | this.once('close', this.cancel, this); 62 | }, 63 | 64 | 65 | applyModel: function () { 66 | 67 | this.$title.val(this.model.get('title')); 68 | this.$description.val(this.model.get('description')); 69 | this.$colorInputs.filter('[value="' + this.model.get('color') + '"]').prop('checked', true); 70 | 71 | this.onChangeEstimateAndActualAndDone(); 72 | 73 | // this.$task.attr('data-color', this.model.get('color')); 74 | }, 75 | 76 | 77 | hide: function () { 78 | 79 | if (this.model) { 80 | 81 | // Clean up. 82 | this.model.off(null, this.boundOnModelDestroy); 83 | } 84 | 85 | ModalDialogView.prototype.hide.apply(this, arguments); 86 | }, 87 | 88 | 89 | collectData: function (){ 90 | 91 | this.model.set({ 92 | title: this.$title.val(), 93 | description: this.$description.val(), 94 | color: this.$colorInputs.filter(':checked').val(), 95 | from: Duration.parse(this.$from.val()), 96 | to: Duration.parse(this.$to.val()), 97 | actual: Duration.parse(this.$actual.val()), 98 | done: this.$done.prop('checked') 99 | }); 100 | }, 101 | 102 | 103 | onChangeDone: function () { 104 | 105 | if (this.model.get('done')) { 106 | 107 | this.$actual.focus(); 108 | } 109 | }, 110 | 111 | 112 | onChangeActual: function () { 113 | 114 | if (this.model.get('actual') != null) { 115 | 116 | this.model.set({'done': true}); 117 | } 118 | }, 119 | 120 | 121 | onChangeEstimateAndActualAndDone: function () { 122 | 123 | this.$from.val(Duration.format(this.model.get('from'))); 124 | var defaultEstimateMax = this.model.getDefaultEstimateMax(); 125 | this.$to 126 | .attr('placeholder', defaultEstimateMax ? Duration.format(defaultEstimateMax) : 'Unknown') 127 | .val(Duration.format(this.model.get('to'))); 128 | this.$actual.val(Duration.format(this.model.get('actual'))); 129 | 130 | this.$done.prop('checked', this.model.get('done')); 131 | }, 132 | 133 | 134 | onClickDelete: function () { 135 | 136 | var count = this.model.numTasksRecursive(); 137 | if (count <= 1 || confirm('Really delete ' + count + ' tasks?')) { 138 | 139 | this.model.destroy(); 140 | } 141 | }, 142 | 143 | 144 | onClickSave: function () { 145 | 146 | this.hide({silent: true}); 147 | this.model.save(); 148 | }, 149 | 150 | 151 | cancel: function () { 152 | 153 | this.model && this.model.set(this.oldModelAttributes); 154 | } 155 | }); 156 | -------------------------------------------------------------------------------- /client/js/ProjectTreeSubTaskListView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ProjectTreeSubTaskListView = Backbone.View.extend({ 4 | 5 | $template: $($.parseHTML( 6 | $('script.js-task[type=template]').text() 7 | )), 8 | 9 | 10 | initialize: function (options) { 11 | 12 | this.treeEventReciever = options.treeEventReciever; 13 | 14 | this.collection.map(this.addSubTaskView, this); 15 | 16 | this.collection.on('add', this.addSubTaskView, this); 17 | }, 18 | 19 | 20 | addSubTaskView: function (model) { 21 | 22 | var $el = this.$template.clone(); 23 | 24 | // Find the DOM element Where the view element should be inserted. 25 | var elAtIndex = this.$el.children().get(model.index()); 26 | if (elAtIndex) { 27 | 28 | // Insert at index. 29 | $(elAtIndex).before($el) 30 | 31 | } else { 32 | 33 | // Just append. 34 | this.$el.append($el) 35 | } 36 | 37 | var view = new ProjectTreeSubTaskView({ 38 | el: $el, 39 | model: model, 40 | treeEventReciever: this.treeEventReciever 41 | }); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /client/js/ProjectTreeSubTaskView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ProjectTreeSubTaskView = Backbone.View.extend({ 4 | 5 | events: { 6 | 7 | 'click h1': 'onTitleClick', 8 | 'blur input': 'onInputBlur', 9 | 'keydown input': 'onInputKeyDown', 10 | 'paste input': 'collectData', 11 | 12 | 'click .js-add-sub-task': 'addSubtask', 13 | 'click .js-task-details': 'taskDetails' 14 | }, 15 | 16 | 17 | initialize: function (options) { 18 | 19 | this.$task = this.$el.find('.js-task'); 20 | 21 | this.$title = this.$task.find('h1'); 22 | this.$input = this.$task.find('input'); 23 | this.$description = this.$task.find('.js-description'); 24 | this.$estimate = this.$task.find('.js-estimate'); 25 | this.$projection = this.$task.find('.js-projection'); 26 | this.$actual = this.$task.find('.js-actual'); 27 | 28 | this.treeEventReciever = options.treeEventReciever; 29 | 30 | this.setUpDragNDrop(); 31 | 32 | this.applyModel(); 33 | 34 | this.model.on('change', this.applyModel, this); 35 | // `add` & `remove` on `tasks` doesn't trigger change. 36 | this.model.get('tasks').on('add', this.applyModel, this); 37 | this.model.get('tasks').on('remove', this.applyModel, this); 38 | 39 | this.model.on('focus', this.onTitleClick, this); 40 | this.model.on('remove', this.remove, this); 41 | 42 | this.subTaskListView = new ProjectTreeSubTaskListView({ 43 | el: this.$el.find('ul'), 44 | collection: this.model.get('tasks'), 45 | treeEventReciever: this.treeEventReciever 46 | }); 47 | 48 | this.editDialog = null; 49 | }, 50 | 51 | 52 | onTitleClick: function (event) { 53 | 54 | if (event) { 55 | 56 | event.stopPropagation(); 57 | } 58 | 59 | this.$input.val(this.model.get('title')); 60 | this.$task.toggleClass('editing-title', true); 61 | 62 | this.$input.focus(); 63 | }, 64 | 65 | 66 | onInputBlur: function (event) { 67 | 68 | event.stopPropagation(); 69 | 70 | this.collectData(); 71 | this.$task.toggleClass('editing-title', false); 72 | }, 73 | 74 | 75 | onInputKeyDown: function (event) { 76 | 77 | if (event.keyCode == 13) { 78 | 79 | this.onInputBlur(event); 80 | } 81 | }, 82 | 83 | 84 | addSubtask: function (event) { 85 | 86 | event.stopPropagation(); 87 | 88 | this.model.get('tasks').create(); 89 | }, 90 | 91 | 92 | taskDetails: function (event) { 93 | 94 | event.stopPropagation(); 95 | 96 | var editDialog = new ModalTaskEditDialogView({ 97 | model: this.model 98 | }); 99 | 100 | editDialog.show(); 101 | }, 102 | 103 | 104 | setUpDragNDrop: function () { 105 | 106 | this.$el.draggable({ 107 | 108 | // handle: '.handle', 109 | zIndex: 100, 110 | revert: true, 111 | revertDuration: 200, 112 | 113 | start: function () { 114 | 115 | this.treeEventReciever.trigger('dragStart'); 116 | }.bind(this), 117 | 118 | stop: function () { 119 | 120 | this.treeEventReciever.trigger('dragStop'); 121 | }.bind(this) 122 | }); 123 | 124 | this.$el.data('model', this.model); 125 | 126 | var THIS = this; 127 | this.$task.find('.drop-target').droppable({ 128 | 129 | tolerance: 'pointer', 130 | 131 | drop: function(event, ui) { 132 | 133 | // Wait until next frame, or the draggable `stop` event doesn't fire. Ugly. 134 | setTimeout(function () { 135 | 136 | var $dropTarget = $(this); 137 | 138 | // Stop indicating drop target. 139 | $dropTarget.toggleClass('drop-hover', false); 140 | 141 | // Place dragged before/after drop target. 142 | 143 | // Remove from current collection. 144 | var model = ui.draggable.data('model'); 145 | model.collection.remove(model); 146 | 147 | // Insert into new collection. 148 | var dropTargetIndex = THIS.model.index(); 149 | switch ($dropTarget.attr('rel')) { 150 | 151 | case 'before': { 152 | 153 | THIS.model.collection.add( 154 | model, 155 | {at: dropTargetIndex} 156 | ); 157 | 158 | } break; 159 | 160 | case 'after': { 161 | 162 | THIS.model.collection.add( 163 | model, 164 | {at: dropTargetIndex + 1} 165 | ); 166 | 167 | } break; 168 | 169 | case 'child': { 170 | 171 | THIS.model.get('tasks').add(model); 172 | 173 | } break; 174 | } 175 | }.bind(this), 0); 176 | } 177 | }); 178 | }, 179 | 180 | 181 | applyModel: function () { 182 | 183 | this.$title.text(this.model.get('title') || String.fromCharCode(160)); // 160:   184 | this.$task.attr('data-color', this.model.get('color')); 185 | this.$task.toggleClass('done', !!this.model.get('done')); 186 | this.$el.toggleClass('leaf', !this.model.get('tasks').length); 187 | this.$description 188 | .html(escapeHtml(this.model.get('description')).replace(/\n/g, '
')) 189 | .toggle(!!this.model.get('description')); 190 | 191 | var estimate = this.model.getEstimate(); 192 | var projection = this.model.get('projection'); 193 | var projectionEqualsEstimate = (projection && estimate && projection.min == estimate.min && projection.max == estimate.max); 194 | 195 | this.$estimate 196 | .text(estimate ? ( 197 | Duration.formatRounded((estimate.min + estimate.max) / 2) 198 | ) : 'No estimate') 199 | .toggle(!this.model.get('actual') && !!estimate && projectionEqualsEstimate) 200 | ; 201 | 202 | this.$projection 203 | .text(projection ? ( 204 | Duration.formatRounded((projection.min + projection.max) / 2) 205 | ) : 'No projection') 206 | .toggle(!this.model.get('actual') && !!projection && !projectionEqualsEstimate) 207 | ; 208 | 209 | this.$actual 210 | .text(Duration.formatRounded(this.model.get('actual')) || 'No actual') 211 | .toggle(!!this.model.get('actual')) 212 | ; 213 | }, 214 | 215 | 216 | collectData: function (){ 217 | 218 | // Save data. 219 | this.model.save({ 220 | title: this.$input.val() 221 | }); 222 | } 223 | }); 224 | 225 | 226 | function escapeHtml(str) { 227 | var div = document.createElement('div'); 228 | div.appendChild(document.createTextNode(str)); 229 | return div.innerHTML; 230 | }; 231 | -------------------------------------------------------------------------------- /client/js/ProjectTreeView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ProjectTreeView = Backbone.View.extend({ 4 | 5 | events: { 6 | 7 | 'click button.add-sub-task': 'onClickAdd' 8 | }, 9 | 10 | 11 | initialize: function (options) { 12 | 13 | this.treeEventReciever = _.extend({}, Backbone.Events); 14 | 15 | this.treeEventReciever.on('dragStart', this.onDragStart, this); 16 | this.treeEventReciever.on('dragStop', this.onDragStop, this); 17 | 18 | this.subTaskListView = new ProjectTreeSubTaskListView({ 19 | el: this.$el.find('ul'), 20 | collection: this.model.get('tasks'), 21 | treeEventReciever: this.treeEventReciever 22 | }); 23 | }, 24 | 25 | 26 | onClickAdd: function () { 27 | 28 | this.model.get('tasks').create(); 29 | }, 30 | 31 | 32 | onDragStart: function () { 33 | 34 | this.$el.toggleClass('dragging', true); 35 | }, 36 | 37 | 38 | onDragStop: function () { 39 | 40 | this.$el.toggleClass('dragging', false); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /client/js/ProjectView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var ProjectView = Backbone.View.extend({ 4 | 5 | events: { 6 | 7 | 'blur input.js-title': 'collectData', 8 | 'keydown input.js-title': 'onInputKeyDown' 9 | }, 10 | 11 | 12 | initialize: function (options) { 13 | 14 | this.$title = this.$el.find('input.js-title'); 15 | this.$projection = this.$el.find('p.js-estimate'); 16 | 17 | this.treeEventReciever = _.extend({}, Backbone.Events); 18 | 19 | this.applyModel(); 20 | this.model.on('change', this.applyModel, this); 21 | 22 | // TODO: Does this cause leaks? Use this.listenTo(model, 'eventName', callback) instead? 23 | this.model.on('focus', function () { this.$title.focus(); }, this); 24 | this.model.on('close', this.remove, this); 25 | 26 | this.treeView = new ProjectTreeView({ 27 | el: this.$('.tree-view'), 28 | model: this.model 29 | }); 30 | this.model.once('sync', function (model) { 31 | 32 | model.calculateProjection(); 33 | }) 34 | }, 35 | 36 | 37 | onInputKeyDown: function (event) { 38 | 39 | if (event.keyCode == 13) { 40 | 41 | this.collectData(); 42 | this.$title.blur(); 43 | } 44 | }, 45 | 46 | 47 | onDragStart: function () { 48 | 49 | this.$el.toggleClass('dragging', true); 50 | }, 51 | 52 | 53 | onDragStop: function () { 54 | 55 | this.$el.toggleClass('dragging', false); 56 | }, 57 | 58 | 59 | applyModel: function () { 60 | 61 | this.$title.val(this.model.get('title')); 62 | 63 | var projection = this.model.get('projection'); 64 | this.$projection 65 | .text(projection ? ( 66 | Duration.formatRounded(projection.min) + 67 | ' - ' + Duration.formatRounded(projection.max) 68 | ) : 'No projection') 69 | .toggle(!!projection); 70 | }, 71 | 72 | 73 | collectData: function (){ 74 | 75 | // Save data. 76 | this.model.save({ 77 | title: this.$title.val() 78 | }); 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /client/js/guid.js: -------------------------------------------------------------------------------- 1 | // http://stackoverflow.com/a/2117523/446536 2 | 3 | function makeGuid () { 4 | 5 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 6 | var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); 7 | return v.toString(16); 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /client/js/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | $(function () { 4 | 5 | var $projectTemplate = $($.parseHTML( 6 | $('script.js-project[type=template]').text() 7 | )); 8 | var $landingTemplate = $($.parseHTML( 9 | $('script.js-landing[type=template]').text() 10 | )); 11 | 12 | 13 | var currentProject = null; 14 | function closeCurrentProject () { 15 | 16 | if (currentProject) { 17 | 18 | currentProject.trigger('close'); 19 | } 20 | } 21 | 22 | 23 | function openProject () { 24 | 25 | $('#page').children().remove(); 26 | 27 | closeCurrentProject(); 28 | 29 | currentProject = new Task(); 30 | 31 | new ProjectView({ 32 | el: $projectTemplate.clone().appendTo($('#page')), 33 | model: currentProject 34 | }); 35 | $('body').attr('data-current-page', 'project'); 36 | 37 | return currentProject; 38 | } 39 | 40 | 41 | var router = new (Backbone.Router.extend({ 42 | 43 | routes: { 44 | "": "landingPage", 45 | "createProject": "createProject", 46 | "projects/:id": "project" 47 | }, 48 | 49 | 50 | landingPage: function () { 51 | 52 | closeCurrentProject(); 53 | 54 | $landingTemplate.clone().appendTo($('#page')); 55 | $('body').attr('data-current-page', 'landing'); 56 | }, 57 | 58 | 59 | createProject: function() { 60 | 61 | var project = openProject(); 62 | 63 | project.createProject(); 64 | 65 | // Show the URL of the new project in the address bar. 66 | router.navigate("projects/"+project.id, {trigger: false, replace: true}); 67 | }, 68 | 69 | 70 | project: function(id) { 71 | 72 | var project = openProject(); 73 | 74 | project.fetch({ 75 | url: apiBaseUrl+'/api/projects/'+id, 76 | dataType: 'json' 77 | }).then(null, function (error) { 78 | 79 | // Does not exist yet, so create it. 80 | if (error.status == 404) { 81 | 82 | project.createProject(id); 83 | } 84 | }); 85 | } 86 | 87 | }))(); 88 | Backbone.history.start(); 89 | }); 90 | -------------------------------------------------------------------------------- /client/js/task.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Pre-create a plain Collection so it can be used in the model's relations. 4 | var Tasks = Backbone.Collection.extend({ 5 | 6 | initialize: function () { 7 | 8 | // Track parent. 9 | // (Setting parent to null on remove is dangerous and pointless.) 10 | this.on('add', function (model) { 11 | 12 | var changed = false; 13 | if (model.get('parentId') != this.parent.id) { 14 | 15 | // New place in the the project tree. 16 | model.set({parentId: this.parent.id}); 17 | changed = true; 18 | } 19 | 20 | // The model will probably be saved in updateOrdering, so 21 | // check if the order changed (was saved), otherwise save. 22 | // Don't wanna save twice. 23 | var oldOrdering = model.get('ordering'); 24 | setTimeout(function () { 25 | 26 | var saved = true; 27 | 28 | if (oldOrdering == model.get('ordering')) { 29 | 30 | saved = false; 31 | } 32 | 33 | if (changed && !saved) { 34 | 35 | model.save() 36 | } 37 | }, 0); 38 | 39 | }.bind(this)); 40 | 41 | // Track relative order. 42 | this.on('add', this.updateOrdering, this); 43 | this.on('remove', this.updateOrdering, this); 44 | 45 | // Re-project. 46 | this.on('add', function (model) { 47 | 48 | model.projectRoot().calculateProjection(); 49 | }); 50 | 51 | 52 | // Make done propagate up. 53 | this.on('change:done', function () { 54 | 55 | if (this.parent) { 56 | 57 | // I'd be happier if this wasn't saved until the changed child was. 58 | this.parent.save({'done': _.every(this.pluck('done'))}); 59 | } 60 | 61 | }.bind(this)); 62 | }, 63 | 64 | 65 | create: function () { 66 | 67 | // Auto-create the guid locally, and trigger 'focus' event. 68 | 69 | var newTask = new Task({ 70 | id: makeGuid(), 71 | parentId: this.parent.id, 72 | projectId: this.parent.projectRoot().id 73 | }); 74 | 75 | this.add(newTask); 76 | 77 | newTask.trigger('focus'); 78 | }, 79 | 80 | 81 | updateOrdering: function () { 82 | 83 | this.models.forEach(function (model, index) { 84 | 85 | if (model.get('ordering') != index) { 86 | 87 | model.save({ordering: index}); 88 | } 89 | }); 90 | } 91 | }); 92 | 93 | var Task = Backbone.Model.extend({ 94 | 95 | defaults: { 96 | color: 'white', 97 | tasks: [] 98 | }, 99 | 100 | 101 | relations: { 102 | tasks: Tasks 103 | }, 104 | 105 | 106 | url: function () { 107 | 108 | return apiBaseUrl+'/api/tasks/'+this.id; 109 | }, 110 | 111 | parse: function(resp, options) { 112 | 113 | // Initialize the models with the attributes implied by the tree structure. 114 | function setImpliedAttributesOnChildren (task) { 115 | 116 | if (task.tasks) { 117 | 118 | task.tasks.forEach(function (child, index) { 119 | 120 | child.ordering = index; 121 | child.parentId = task.id; 122 | 123 | setImpliedAttributesOnChildren(child); 124 | }); 125 | } 126 | } 127 | resp.ordering = 0; 128 | setImpliedAttributesOnChildren(resp); 129 | 130 | return Backbone.Model.prototype.parse.apply(this, arguments); 131 | }, 132 | 133 | 134 | save: function(changedAttributes, options) { 135 | 136 | // The model data as they would look after saving. 137 | var attributes = _.extend(_.clone(this.attributes), changedAttributes); 138 | 139 | // But some stuff shouldn't be sent. 140 | delete attributes.tasks; 141 | delete attributes.projection; 142 | 143 | // Override what gets sent to the server. 144 | options = options || {}; 145 | options.data = JSON.stringify(attributes); 146 | options.contentType = "application/json" 147 | 148 | // Proxy the call to the original save function. (Actually saving the model data, doing server requests, triggering events, etc.) 149 | Backbone.Model.prototype.save.call(this, changedAttributes, options); 150 | }, 151 | 152 | 153 | initialize: function () { 154 | 155 | // So the subtasks can find their parentId. 156 | this.get('tasks').parent = this; 157 | 158 | this.on('change:from change:to change:actual', function (model) { 159 | 160 | // Recalculate all projections from the top. 161 | model.projectRoot().calculateProjection(); 162 | }); 163 | 164 | this.on('destroy', function (model) { 165 | 166 | var root = model.projectRoot(); 167 | 168 | // WARNING: Ugly. 169 | // Wait until *after* the object got destroyed to recalculate. 170 | setTimeout(function () { 171 | 172 | root.calculateProjection(); 173 | 174 | }, 0); 175 | }); 176 | 177 | // Make done:true propagate down. 178 | this.on('change:done', function () { 179 | 180 | var tasks = this.get('tasks'); 181 | if (tasks && this.get('done')) { 182 | 183 | tasks.each(function (task) { 184 | 185 | task.set('done', true); 186 | }); 187 | } 188 | 189 | }.bind(this)); 190 | }, 191 | 192 | 193 | index: function () { 194 | 195 | // If it doesn't belong to a collection. 196 | if (!this.collection) { 197 | 198 | return 0; 199 | } 200 | 201 | var indexOf = this.collection.models.indexOf(this); 202 | 203 | // If it hasn't been added just yet. (During creation.) 204 | if (indexOf === -1) { 205 | 206 | return undefined; 207 | } 208 | 209 | return indexOf; 210 | }, 211 | 212 | 213 | numTasksRecursive: function () { 214 | 215 | return this.get('tasks') 216 | .map(function (child) { return child.numTasksRecursive(); }) 217 | .reduce(function (soFar, next) { return soFar + next; }, 0) 218 | + 1; 219 | }, 220 | 221 | 222 | createProject: function (id) { 223 | 224 | if (!this.isNew()) { 225 | 226 | throw new Error('You need an unused Taks to create a project.'); 227 | } 228 | 229 | var projectId = id || makeGuid(); 230 | 231 | this.set({ 232 | id: projectId, 233 | projectId: projectId, 234 | parentId: null 235 | }); 236 | 237 | this.trigger('focus'); 238 | }, 239 | 240 | 241 | projectRoot: function () { 242 | 243 | var currentTask = this; 244 | 245 | while (true) { 246 | 247 | if (!currentTask.collection) { 248 | 249 | break; 250 | } 251 | 252 | currentTask = currentTask.collection.parent; 253 | } 254 | 255 | return currentTask; 256 | }, 257 | 258 | 259 | getDefaultEstimateMax: function () { 260 | 261 | return this.get('from') * 1.5; 262 | }, 263 | 264 | 265 | getEstimate: function () { 266 | 267 | return this.get('from') ? { 268 | min: this.get('from'), 269 | max: this.get('to') || this.getDefaultEstimateMax() 270 | } : null; 271 | }, 272 | 273 | 274 | calculateProjection: function () { 275 | 276 | // Calculate the projections of the child tasks. 277 | this.get('tasks').each(function (child) { 278 | 279 | child.calculateProjection(); 280 | }); 281 | 282 | 283 | // Sum up the childrens projections. 284 | var childProjections = _.filter(this.get('tasks').pluck('projection'), function (projection) { return !!projection; }); 285 | var numProjected = childProjections.length; 286 | var factorProjectedChildren = numProjected / this.get('tasks').length; 287 | var childProjectionSum = childProjections.length ? { 288 | min: _.pluck(childProjections, 'min').reduce(function (a, b) { return a + b; }, 0), 289 | max: _.pluck(childProjections, 'max').reduce(function (a, b) { return a + b; }, 0) 290 | } : null; 291 | if (childProjectionSum) { 292 | 293 | // Add the extrapolated time from un-estimated siblings. 294 | childProjectionSum.min /= factorProjectedChildren; 295 | childProjectionSum.max /= factorProjectedChildren; 296 | } 297 | 298 | 299 | var estimate = this.getEstimate(); 300 | 301 | 302 | var actual = this.get('actual'); 303 | 304 | 305 | // Use actual data, otherwise the largest numbers of whatever information is available. 306 | var projection; 307 | if (actual != null) { 308 | 309 | projection = { 310 | min: actual, 311 | max: actual 312 | }; 313 | 314 | } else { 315 | 316 | if (childProjectionSum && estimate) { 317 | 318 | projection = { 319 | min: Math.max(estimate.min, childProjectionSum.min), 320 | max: Math.max(estimate.min, childProjectionSum.max) 321 | }; 322 | 323 | } else if (estimate) { 324 | 325 | projection = estimate; 326 | 327 | } else { 328 | 329 | projection = childProjectionSum; 330 | } 331 | } 332 | this.set('projection', projection); 333 | 334 | 335 | // Set the projections for the undefined children. 336 | var unprojectedTotal = projection ? { 337 | min: projection.min * (1 - factorProjectedChildren), 338 | max: projection.max * (1 - factorProjectedChildren) 339 | } : null; 340 | this.calculateProjectionDown(unprojectedTotal); 341 | }, 342 | 343 | calculateProjectionDown: function (unprojectedTotal) { 344 | 345 | if (!unprojectedTotal) { 346 | 347 | return; 348 | } 349 | 350 | // Find the child-tasks without projections. 351 | var unprojected = this.get('tasks').filter(function (task) { 352 | 353 | return !task.get('projection'); 354 | }); 355 | 356 | // Spread equally. 357 | var unprojectedDivided = { 358 | min: unprojectedTotal.min / unprojected.length, 359 | max: unprojectedTotal.max / unprojected.length 360 | }; 361 | unprojected.forEach(function (task) { 362 | 363 | task.set('projection', unprojectedDivided); 364 | task.calculateProjectionDown(unprojectedDivided); 365 | }); 366 | } 367 | }); 368 | 369 | 370 | // Complete the collection after the model's relations has used it. 371 | Tasks.prototype.model = Task; 372 | -------------------------------------------------------------------------------- /client/js/vendor/backbone-1.1.2.min.js: -------------------------------------------------------------------------------- 1 | (function(t,e){if(typeof define==="function"&&define.amd){define(["underscore","jquery","exports"],function(i,r,s){t.Backbone=e(t,s,i,r)})}else if(typeof exports!=="undefined"){var i=require("underscore");e(t,exports,i)}else{t.Backbone=e(t,{},t._,t.jQuery||t.Zepto||t.ender||t.$)}})(this,function(t,e,i,r){var s=t.Backbone;var n=[];var a=n.push;var o=n.slice;var h=n.splice;e.VERSION="1.1.2";e.$=r;e.noConflict=function(){t.Backbone=s;return this};e.emulateHTTP=false;e.emulateJSON=false;var u=e.Events={on:function(t,e,i){if(!c(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,r){if(!c(this,"once",t,[e,r])||!e)return this;var s=this;var n=i.once(function(){s.off(t,n);e.apply(this,arguments)});n._callback=e;return this.on(t,n,r)},off:function(t,e,r){var s,n,a,o,h,u,l,f;if(!this._events||!c(this,"off",t,[e,r]))return this;if(!t&&!e&&!r){this._events=void 0;return this}o=t?[t]:i.keys(this._events);for(h=0,u=o.length;h").attr(t);this.setElement(r,false)}else{this.setElement(i.result(this,"el"),false)}}});e.sync=function(t,r,s){var n=T[t];i.defaults(s||(s={}),{emulateHTTP:e.emulateHTTP,emulateJSON:e.emulateJSON});var a={type:n,dataType:"json"};if(!s.url){a.url=i.result(r,"url")||M()}if(s.data==null&&r&&(t==="create"||t==="update"||t==="patch")){a.contentType="application/json";a.data=JSON.stringify(s.attrs||r.toJSON(s))}if(s.emulateJSON){a.contentType="application/x-www-form-urlencoded";a.data=a.data?{model:a.data}:{}}if(s.emulateHTTP&&(n==="PUT"||n==="DELETE"||n==="PATCH")){a.type="POST";if(s.emulateJSON)a.data._method=n;var o=s.beforeSend;s.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",n);if(o)return o.apply(this,arguments)}}if(a.type!=="GET"&&!s.emulateJSON){a.processData=false}if(a.type==="PATCH"&&k){a.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var h=s.xhr=e.ajax(i.extend(a,s));r.trigger("request",r,h,s);return h};var k=typeof window!=="undefined"&&!!window.ActiveXObject&&!(window.XMLHttpRequest&&(new XMLHttpRequest).dispatchEvent);var T={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};e.ajax=function(){return e.$.ajax.apply(e.$,arguments)};var $=e.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var S=/\((.*?)\)/g;var H=/(\(\?)?:\w+/g;var A=/\*\w+/g;var I=/[\-{}\[\]+?.,\\\^$|#\s]/g;i.extend($.prototype,u,{initialize:function(){},route:function(t,r,s){if(!i.isRegExp(t))t=this._routeToRegExp(t);if(i.isFunction(r)){s=r;r=""}if(!s)s=this[r];var n=this;e.history.route(t,function(i){var a=n._extractParameters(t,i);n.execute(s,a);n.trigger.apply(n,["route:"+r].concat(a));n.trigger("route",r,a);e.history.trigger("route",n,r,a)});return this},execute:function(t,e){if(t)t.apply(this,e)},navigate:function(t,i){e.history.navigate(t,i);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=i.result(this,"routes");var t,e=i.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(I,"\\$&").replace(S,"(?:$1)?").replace(H,function(t,e){return e?t:"([^/?]+)"}).replace(A,"([^?]*?)");return new RegExp("^"+t+"(?:\\?([\\s\\S]*))?$")},_extractParameters:function(t,e){var r=t.exec(e).slice(1);return i.map(r,function(t,e){if(e===r.length-1)return t||null;return t?decodeURIComponent(t):null})}});var N=e.History=function(){this.handlers=[];i.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var R=/^[#\/]|\s+$/g;var O=/^\/+|\/+$/g;var P=/msie [\w.]+/;var C=/\/$/;var j=/#.*$/;N.started=false;i.extend(N.prototype,u,{interval:50,atRoot:function(){return this.location.pathname.replace(/[^\/]$/,"$&/")===this.root},getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=decodeURI(this.location.pathname+this.location.search);var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.slice(i.length)}else{t=this.getHash()}}return t.replace(R,"")},start:function(t){if(N.started)throw new Error("Backbone.history has already been started");N.started=true;this.options=i.extend({root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var r=this.getFragment();var s=document.documentMode;var n=P.exec(navigator.userAgent.toLowerCase())&&(!s||s<=7);this.root=("/"+this.root+"/").replace(O,"/");if(n&&this._wantsHashChange){var a=e.$('