├── .gitignore ├── screenshots.png ├── static ├── alert.mp3 ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── mstile-150x150.png ├── android-chrome-192x192.png ├── browserconfig.xml ├── manifest.json ├── sw.js └── safari-pinned-tab.svg ├── src ├── styles │ ├── variables.scss │ ├── components │ │ ├── process.scss │ │ ├── target.scss │ │ ├── pauses.scss │ │ ├── play.scss │ │ ├── themes.scss │ │ ├── tabs.scss │ │ ├── about.scss │ │ ├── timer.scss │ │ ├── applicaiton.scss │ │ ├── controls.scss │ │ ├── settings.scss │ │ ├── layout.scss │ │ ├── heatmap.scss │ │ └── clock.scss │ ├── themes │ │ ├── darcula.scss │ │ ├── one-dark.scss │ │ ├── monokai.scss │ │ ├── solarized-dark.scss │ │ ├── one-light.scss │ │ └── solarized-light.scss │ ├── index.scss │ ├── device.scss │ └── plugins.scss ├── index.js ├── components │ ├── Tabs.vue │ ├── SettingsField.vue │ ├── Timer.vue │ ├── Themes.vue │ ├── About.vue │ ├── Clock.vue │ ├── Controls.vue │ ├── Heatmap.vue │ ├── Process.vue │ ├── Target.vue │ ├── Application.vue │ └── Settings.vue └── lib │ ├── notify.js │ ├── utils.js │ ├── utils.spec.js │ ├── pomodoro.js │ ├── favicon.js │ ├── pomodoro.spec.js │ ├── index.js │ └── index.spec.js ├── jsconfig.json ├── karma.js ├── .eslintrc.js ├── poi.config.js ├── LICENSE ├── README.md ├── package.json ├── index.ejs └── CODE_OF_CONDUCT.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/screenshots.png -------------------------------------------------------------------------------- /static/alert.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/alert.mp3 -------------------------------------------------------------------------------- /src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $COLOR_BACKGROUND: #323949; 2 | $COLOR_FOREGROUND: #f5f5f5; 3 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/mstile-150x150.png -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatyshev/pomidorus/HEAD/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /karma.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const specContext = require.context('@', true, /\.spec.js$/); 4 | specContext.keys().forEach(specContext); 5 | -------------------------------------------------------------------------------- /src/styles/components/process.scss: -------------------------------------------------------------------------------- 1 | .b-process__value { 2 | transition: fill 200ms 200ms; 3 | fill: #e4582b; 4 | } 5 | 6 | .b-process__bar { 7 | fill: transparent; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Application from '@/components/Application'; 3 | import 'd3-transition'; 4 | 5 | Vue.config.productionTip = false; 6 | 7 | export default new Vue({ 8 | el: '#app', 9 | render: h => h(Application), 10 | }); 11 | -------------------------------------------------------------------------------- /static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #b91d47 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/styles/components/target.scss: -------------------------------------------------------------------------------- 1 | .b-target__item { 2 | fill: rgba(#fff, 0.15); 3 | } 4 | 5 | .b-target__item--finished { 6 | fill: #39b6eb; 7 | } 8 | 9 | .b-target__item--extra { 10 | fill: lighten(#39b6eb, 20%); 11 | } 12 | 13 | .b-target__item--skipped { 14 | fill: rgba(#39b6eb, 0.5); 15 | } 16 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pomidorus", 3 | "icons": [ 4 | { 5 | "src": "android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image/png" 8 | } 9 | ], 10 | "theme_color": "#323949", 11 | "background_color": "#323949", 12 | "display": "standalone" 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/components/pauses.scss: -------------------------------------------------------------------------------- 1 | .b-pauses { 2 | transition: opacity 200ms; 3 | margin: 5px 0; 4 | font-size: .9em; 5 | transform: translateY(10px) translateX(-2px); 6 | color: #d27d52; 7 | display: inline-flex; 8 | opacity: 1; 9 | background: red; 10 | line-height: 0; 11 | } 12 | 13 | .b-pauses--hidden { 14 | opacity: 0; 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | plugins: ['html'], 4 | parser: 'babel-eslint', 5 | extends: 'airbnb-base', 6 | env: { 7 | browser: true, 8 | jasmine: true, 9 | }, 10 | rules: { 11 | 'import/no-unresolved': [0], 12 | 'import/extensions': [0], 13 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /static/sw.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | self.addEventListener('notificationclick', function(event) { 4 | event.notification.close(); 5 | 6 | event 7 | .waitUntil(clients.matchAll({type: 'window'}) 8 | .then(function(clientList) { 9 | for (var i = 0; i < clientList.length; i++) { 10 | var client = clientList[i]; 11 | if ('focus' in client) return client.focus(); 12 | } 13 | }) 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /poi.config.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: 0 */ 2 | /* eslint import/no-extraneous-dependencies: 0 */ 3 | 4 | const isProd = process.env.NODE_ENV === 'production'; 5 | 6 | module.exports = { 7 | entry: 'src/index.js', 8 | sourceMap: isProd ? false : 'source-map', 9 | homepage: '', 10 | karma: { 11 | frameworks: ['jasmine'], 12 | browsers: ['ChromeHeadless'], 13 | reporters: ['spec', 'kjhtml'], 14 | proxies: {}, 15 | }, 16 | presets: [ 17 | require('poi-preset-karma')({ 18 | files: ['./karma.js'], 19 | }), 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/styles/components/play.scss: -------------------------------------------------------------------------------- 1 | .b-play { 2 | background: lighten(#323949, 4%); 3 | color: inherit; 4 | border: 1px solid transparent; 5 | cursor: pointer; 6 | border-radius: 100%; 7 | width: 20px; 8 | height: 20px; 9 | position: relative; 10 | text-align: center; 11 | 12 | &:hover { 13 | background: lighten(#323949, 10%); 14 | } 15 | 16 | &:focus { 17 | // box-shadow: 0 0 1px 1px rgba(#39b6ea, 0.8); 18 | outline: none; 19 | } 20 | 21 | &:active { 22 | top: 1px; 23 | } 24 | 25 | > svg { 26 | width: 10px; 27 | height: 10px; 28 | position: absolute; 29 | top: 50%; 30 | left: 50%; 31 | transform: translate(-50%, -50%); 32 | fill: #fff; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Tabs.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | -------------------------------------------------------------------------------- /src/styles/components/themes.scss: -------------------------------------------------------------------------------- 1 | .b-themes { 2 | background: rgba(#fff, 0.05); 3 | color: rgba(#fff, 0.65); 4 | position: relative; 5 | display: inline-block; 6 | border: 1px solid rgba(#fff, 0.25); 7 | height: 25px; 8 | border-radius: 26px; 9 | padding: 2px 13px; 10 | padding-right: 23px; 11 | 12 | &:after { 13 | content: ''; 14 | position: absolute; 15 | top: 10px; 16 | right: 8px; 17 | border-style: solid; 18 | border-width: 5px 4px; 19 | border-color: transparent; 20 | border-top-color: rgba(#fff, 0.35); 21 | display: block; 22 | } 23 | 24 | select { 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | width: 100%; 29 | height: 100%; 30 | opacity: 0; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/components/tabs.scss: -------------------------------------------------------------------------------- 1 | .b-tabs { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | display: inline-flex; 6 | transform: translateX(4px); 7 | } 8 | 9 | .b-tabs__item { 10 | transition: opacity 200ms; 11 | margin: 0 10px; 12 | opacity: 0.2; 13 | cursor: pointer; 14 | position: relative; 15 | 16 | &:after { 17 | transition: opacity 200ms, transform 200ms; 18 | content: ''; 19 | position: absolute; 20 | top: 100%; 21 | left: 0; 22 | width: 100%; 23 | height: 1px; 24 | background: #fff; 25 | transform: translateY(2px); 26 | opacity: 0; 27 | } 28 | 29 | &:hover { 30 | &:after { 31 | transform: translateY(0px); 32 | opacity: 1; 33 | } 34 | } 35 | } 36 | 37 | .b-tabs__item--active { 38 | opacity: 1; 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/themes/darcula.scss: -------------------------------------------------------------------------------- 1 | body.theme-darcula { 2 | background: #393939; 3 | color: #c9d1d1; 4 | 5 | .b-process__value { 6 | fill: #facf7f; 7 | } 8 | 9 | .b-application--break .b-process__value { 10 | fill: #b3ca74; 11 | } 12 | 13 | .b-target__item { 14 | fill: rgba(#c9d1d1, 0.2); 15 | } 16 | 17 | .b-target__item--finished { 18 | fill: #7fadc5; 19 | } 20 | 21 | .b-target__item--extra { 22 | fill: lighten(#7fadc5, 20%); 23 | } 24 | 25 | .b-target__item--skipped { 26 | fill: rgba(#7fadc5, 0.5); 27 | } 28 | 29 | .b-heatmap__level { 30 | background: #b3ca74; 31 | } 32 | 33 | .b-clock__pauses { 34 | color: #facf7f; 35 | } 36 | 37 | .vue-slider-process { 38 | background: #7fadc5; 39 | } 40 | 41 | .vue-js-switch.toggled .v-switch-core { 42 | background: #7fadc5; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/themes/one-dark.scss: -------------------------------------------------------------------------------- 1 | body.theme-one-dark { 2 | background: #343a43; 3 | color: #c9d1d1; 4 | 5 | .b-process__value { 6 | fill: #eb8388; 7 | } 8 | 9 | .b-application--break .b-process__value { 10 | fill: #a5cc8e; 11 | } 12 | 13 | .b-target__item { 14 | fill: rgba(#c9d1d1, 0.2); 15 | } 16 | 17 | .b-target__item--finished { 18 | fill: #6ebcf0; 19 | } 20 | 21 | .b-target__item--extra { 22 | fill: lighten(#6ebcf0, 20%); 23 | } 24 | 25 | .b-target__item--skipped { 26 | fill: rgba(#6ebcf0, 0.5); 27 | } 28 | 29 | .b-heatmap__level { 30 | background: #99cc8e; 31 | } 32 | 33 | .b-clock__pauses { 34 | color: #ddab7c; 35 | } 36 | 37 | .vue-slider-process { 38 | background: #6ebcf0; 39 | } 40 | 41 | .vue-js-switch.toggled .v-switch-core { 42 | background: #6ebcf0; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/components/about.scss: -------------------------------------------------------------------------------- 1 | .b-about { 2 | text-align: center; 3 | padding-top: 60px; 4 | } 5 | 6 | .b-about__image { 7 | margin-bottom: 15px; 8 | } 9 | 10 | .b-about__title { 11 | margin-bottom: 10px; 12 | font-size: 1.2em; 13 | } 14 | 15 | .b-about__version { 16 | opacity: 0.4; 17 | color: inherit; 18 | text-decoration: none; 19 | 20 | &:hover { 21 | text-decoration: underline; 22 | } 23 | } 24 | 25 | .b-about__desc { 26 | color: rgba(#fff, 0.6); 27 | line-height: 1.35; 28 | margin-bottom: 15px; 29 | 30 | a { 31 | color: #39b6eb; 32 | text-decoration: none; 33 | 34 | &:hover { 35 | text-decoration: underline; 36 | } 37 | } 38 | } 39 | 40 | .b-about__github { 41 | transition: opacity 200ms; 42 | fill: #fff; 43 | opacity: 0.5; 44 | width: 30px; 45 | 46 | &:hover { 47 | opacity: 1; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/themes/monokai.scss: -------------------------------------------------------------------------------- 1 | body.theme-monokai { 2 | background: #34352e; 3 | color: #c9d1d1; 4 | 5 | .b-process__value { 6 | fill: #ff4484; 7 | } 8 | 9 | .b-application--break .b-process__value { 10 | fill: #b0e54b; 11 | } 12 | 13 | .b-target__item { 14 | fill: rgba(#c9d1d1, 0.2); 15 | } 16 | 17 | .b-target__item--finished { 18 | fill: #6edff2; 19 | } 20 | 21 | .b-target__item--extra { 22 | fill: lighten(#6edff2, 20%); 23 | } 24 | 25 | .b-target__item--skipped { 26 | fill: rgba(#6edff2, 0.5); 27 | } 28 | 29 | .b-heatmap__level { 30 | background: #b0e54b; 31 | } 32 | 33 | .b-clock__pauses { 34 | color: #ffad34; 35 | } 36 | 37 | .vue-slider-process { 38 | background: #6edff2; 39 | } 40 | 41 | .vue-js-switch.toggled .v-switch-core { 42 | background: darken(#6edff2, 22%); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/components/timer.scss: -------------------------------------------------------------------------------- 1 | .b-timer { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | border-left: 25px solid transparent; 8 | border-right: 25px solid transparent; 9 | border-top: 37px solid transparent; 10 | 11 | .b-process { 12 | position: absolute; 13 | top: 50%; 14 | left: 50%; 15 | width: 100%; 16 | height: 100%; 17 | padding: 2%; 18 | transform: translateX(-50%) translateY(-50%); 19 | } 20 | 21 | .b-target { 22 | position: absolute; 23 | top: 50%; 24 | left: 50%; 25 | width: 100%; 26 | height: 100%; 27 | transform: translateX(-50%) translateY(-50%); 28 | } 29 | } 30 | 31 | @include device-until(mobile) { 32 | .b-timer { 33 | border-left: 15px solid transparent; 34 | border-right: 15px solid transparent; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/styles/themes/solarized-dark.scss: -------------------------------------------------------------------------------- 1 | body.theme-solarized-dark { 2 | background: #002c36; 3 | color: #c9d1d1; 4 | 5 | .b-process__value { 6 | fill: #cb4b16; 7 | } 8 | 9 | .b-application--break .b-process__value { 10 | fill: darken(#84991a, 3%); 11 | } 12 | 13 | .b-target__item { 14 | fill: rgba(#c9d1d1, 0.2); 15 | } 16 | 17 | .b-target__item--finished { 18 | fill: #2aa198; 19 | } 20 | 21 | .b-target__item--extra { 22 | fill: lighten(#2aa198, 20%); 23 | } 24 | 25 | .b-target__item--skipped { 26 | fill: rgba(#2aa198, 0.5); 27 | } 28 | 29 | .b-heatmap__level { 30 | background: lighten(#84991a, 5%); 31 | } 32 | 33 | .b-clock__pauses { 34 | color: lighten(#cb4b16, 15%); 35 | } 36 | 37 | .vue-slider-process { 38 | background: #2aa198; 39 | } 40 | 41 | .vue-js-switch.toggled .v-switch-core { 42 | background: #2aa198; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/notify.js: -------------------------------------------------------------------------------- 1 | const createNotification = (title, options) => { 2 | const notification = new Notification(title, options); 3 | 4 | notification.onclick = () => { 5 | window.focus(); 6 | notification.close(); 7 | }; 8 | 9 | return notification; 10 | }; 11 | 12 | let showNotification = null; 13 | 14 | try { 15 | navigator.serviceWorker.register('sw.js?v=2'); 16 | 17 | navigator.serviceWorker.ready.then((reg) => { 18 | showNotification = (title, options) => reg.showNotification(title, options); 19 | }); 20 | } catch (e) { 21 | // Nothings Todo 22 | } 23 | 24 | const notify = (title, options) => { 25 | try { 26 | if (showNotification) showNotification(title, options); 27 | else createNotification(title, options); 28 | } catch (e) { 29 | alert(title); // eslint-disable-line no-alert 30 | } 31 | }; 32 | 33 | export const sounds = new Audio('alert.mp3'); 34 | sounds.load(); 35 | 36 | export default notify; 37 | -------------------------------------------------------------------------------- /src/components/SettingsField.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ruslan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export const MONTHS = [ 2 | 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 3 | ]; 4 | 5 | export const propsLimit = (obj, limit) => { 6 | const result = {}; 7 | const keys = Object.keys(obj).slice(0, limit); 8 | keys.forEach((key) => { result[key] = obj[key]; }); 9 | return result; 10 | }; 11 | 12 | export const array = n => [...Array(n)]; 13 | 14 | export const zeroify = (number, fixed = 2) => { 15 | const zeros = '0'.repeat(fixed - 1); 16 | return (zeros + number).slice(-fixed); 17 | }; 18 | 19 | export const dayMonth = (date) => { 20 | date = new Date(date); // eslint-disable-line no-param-reassign 21 | 22 | const month = MONTHS[date.getMonth()]; 23 | const day = date.getDate(); 24 | 25 | return `${day} ${month}`; 26 | }; 27 | 28 | export const today = () => dayMonth(new Date()); 29 | export const seconds = n => n * 1000; 30 | export const minutes = n => n * seconds(60); 31 | export const hours = n => n * minutes(60); 32 | export const days = n => n * hours(24); 33 | 34 | export const reachGoal = (...args) => { 35 | if (window.yaCounter) { 36 | window.yaCounter.reachGoal(...args); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import '~normalize.css'; 2 | @import './device'; 3 | @import url('https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300'); 4 | 5 | @import './themes/solarized-dark'; 6 | @import './themes/solarized-light'; 7 | @import './themes/one-dark'; 8 | @import './themes/one-light'; 9 | @import './themes/monokai'; 10 | @import './themes/darcula'; 11 | 12 | @import './components/applicaiton'; 13 | @import './components/layout'; 14 | @import './components/process'; 15 | @import './components/target'; 16 | @import './components/clock'; 17 | @import './components/play'; 18 | @import './components/tabs'; 19 | @import './components/controls'; 20 | @import './components/pauses'; 21 | @import './components/timer'; 22 | @import './components/about'; 23 | @import './components/settings'; 24 | @import './components/heatmap'; 25 | @import './components/themes'; 26 | @import './plugins'; 27 | 28 | html { 29 | box-sizing: border-box; 30 | height: 100%; 31 | width: 100%; 32 | } 33 | 34 | * { 35 | box-sizing: inherit; 36 | 37 | &::before, 38 | &::after { 39 | box-sizing: inherit; 40 | } 41 | } 42 | 43 | body { 44 | background: #323949; 45 | color: #f5f5f5; 46 | font-family: 'Open Sans Condensed', sans-serif; 47 | font-weight: 300; 48 | font-size: 16px; 49 | width: 100%; 50 | height: 100%; 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Timer.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Pomidorus 5 | 6 |

7 | 8 |

9 | 10 | 11 | 12 |

13 | 14 |

15 | Web based time tracker inspired by Pomodoro Technique. 16 |

17 | 18 |

19 | 20 |

21 | 22 | > This pet project was made to try in practice Vue and d3 libraries 23 | 24 | ## Key Features 25 | 26 | - Daily target 27 | - Customizable work/break time 28 | - System notifications 29 | - Heatmap statistics for the last month 30 | - Minimalistic design 31 | - Themes 32 | - Sound alerts 33 | - Keyboard Shortcuts 34 | - Start/Pause/Unpause - `Space` 35 | 36 | 37 | ## License 38 | 39 | MIT © [Ruslan Tatyshev](http://github.com/tatyshev) 40 | -------------------------------------------------------------------------------- /src/styles/components/applicaiton.scss: -------------------------------------------------------------------------------- 1 | .b-application { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | } 8 | 9 | .b-application__middle { 10 | display: flex; 11 | flex-grow: 1; 12 | flex-direction: column; 13 | max-height: 580px; 14 | margin: 30px 0; 15 | } 16 | 17 | .b-application--break { 18 | .b-process__value { 19 | fill: #97ce28; 20 | } 21 | } 22 | 23 | .b-application__header { 24 | flex-grow: auto; 25 | display: flex; 26 | justify-content: center; 27 | } 28 | 29 | .b-application__body { 30 | flex-grow: 1; 31 | display: flex; 32 | flex-direction: column; 33 | } 34 | 35 | .b-application__sections { 36 | position: relative; 37 | flex-grow: 1; 38 | 39 | > div { 40 | position: absolute; 41 | top: 0; 42 | left: 0; 43 | height: 100%; 44 | display: flex; 45 | align-items: stretch; 46 | 47 | > div { 48 | display: flex; 49 | } 50 | } 51 | } 52 | 53 | .b-application__section { 54 | width: 100%; 55 | display: flex; 56 | } 57 | 58 | .b-application__wrapper { 59 | position: relative; 60 | max-width: 600px; 61 | width: 100%; 62 | margin: 0 auto; 63 | display: flex; 64 | flex-direction: column; 65 | overflow: auto; 66 | } 67 | 68 | .b-application__wrapper--timer { 69 | .b-timer { 70 | flex-grow: 1; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { zeroify, minutes, seconds, propsLimit } from './utils'; 2 | 3 | describe('utils', () => { 4 | describe('.propsLimit', () => { 5 | it('should pick only limited prop list', () => { 6 | const result = propsLimit({ one: 1, two: 2, three: 3 }, 2); 7 | expect(result).toEqual({ one: 1, two: 2 }); 8 | }); 9 | }); 10 | 11 | describe('.zeroify', () => { 12 | describe('when number length is 1', () => { 13 | it('should add zero', () => { 14 | expect(zeroify(1)).toBe('01'); 15 | expect(zeroify(2)).toBe('02'); 16 | }); 17 | }); 18 | 19 | describe('when number length 2', () => { 20 | it('should not add zero', () => { 21 | expect(zeroify(10)).toBe('10'); 22 | expect(zeroify(20)).toBe('20'); 23 | }); 24 | }); 25 | 26 | describe('when fixed given', () => { 27 | it('should render zeros according given fixed', () => { 28 | expect(zeroify(1, 4)).toBe('0001'); 29 | expect(zeroify(10, 4)).toBe('0010'); 30 | expect(zeroify(222, 4)).toBe('0222'); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('.minutes', () => { 36 | it('should return milliseconds', () => { 37 | expect(minutes(1)).toBe(60000); 38 | expect(minutes(2)).toBe(120000); 39 | }); 40 | }); 41 | 42 | describe('.seconds', () => { 43 | it('should return milliseconds', () => { 44 | expect(seconds(1)).toBe(1000); 45 | expect(seconds(2)).toBe(2000); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/Themes.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 69 | -------------------------------------------------------------------------------- /src/styles/components/controls.scss: -------------------------------------------------------------------------------- 1 | .b-controls { 2 | display: flex; 3 | justify-content: center; 4 | padding-top: 50px; 5 | } 6 | 7 | .b-controls__action { 8 | transition: opacity 200ms; 9 | text-decoration: none; 10 | color: #fff; 11 | display: inline-block; 12 | margin: 0 35px; 13 | opacity: 0.25; 14 | position: relative; 15 | cursor: pointer; 16 | 17 | &:after { 18 | transition: opacity 200ms, transform 200ms; 19 | content: ''; 20 | position: absolute; 21 | top: 100%; 22 | left: 0; 23 | width: 100%; 24 | height: 1px; 25 | background: #fff; 26 | transform: translateY(2px); 27 | opacity: 0; 28 | } 29 | 30 | &:hover { 31 | opacity: 1; 32 | 33 | &:after { 34 | transform: translateY(0px); 35 | opacity: 1; 36 | } 37 | } 38 | } 39 | 40 | .b-controls__button { 41 | transition: background 200ms, color 200ms; 42 | margin: 0; 43 | padding: 0; 44 | color: inherit; 45 | font-family: inherit; 46 | background: transparent; 47 | cursor: pointer; 48 | padding: 6px 5px 5px 5px; 49 | border: 1px solid rgba(#fff, 0.25); 50 | min-width: 90px; 51 | text-align: center; 52 | border-radius: 90px; 53 | -webkit-tap-highlight-color: transparent; 54 | 55 | &:active { 56 | background: rgba(#000, 0.09) !important; 57 | } 58 | 59 | &:focus { 60 | outline: none; 61 | } 62 | 63 | &:hover { 64 | background: rgba(#fff, 0.09); 65 | } 66 | 67 | &:first-child { 68 | border-radius: 20px 0 0 20px; 69 | } 70 | 71 | &:last-child { 72 | border-right-width: 1px; 73 | border-radius: 0 20px 20px 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "author": "Ruslan Tatyshev ", 4 | "version": "1.0.8", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "poi dev", 8 | "build": "NODE_ENV=production poi build", 9 | "cname": "echo pomidorus.js.org>dist/CNAME", 10 | "deploy": "npm run build && npm run cname && gh-pages -d dist", 11 | "test": "poi test", 12 | "debug": "poi test --watch", 13 | "lint": "eslint --ext .js,.vue src" 14 | }, 15 | "devDependencies": { 16 | "babel-eslint": "^8.2.1", 17 | "eslint": "^4.17.0", 18 | "eslint-config-airbnb-base": "^12.1.0", 19 | "eslint-plugin-html": "^4.0.2", 20 | "eslint-plugin-import": "^2.8.0", 21 | "gh-pages": "^1.1.0", 22 | "given2": "^2.1.1", 23 | "karma-chrome-launcher": "^2.2.0", 24 | "karma-jasmine": "^1.1.1", 25 | "karma-jasmine-html-reporter": "^0.2.2", 26 | "karma-spec-reporter": "^0.0.32", 27 | "node-sass": "^4.7.2", 28 | "poi": "^9.6.13", 29 | "poi-preset-karma": "^9.2.4", 30 | "sass-loader": "^6.0.6", 31 | "vue-template-compiler": "^2.5.10" 32 | }, 33 | "dependencies": { 34 | "d3-interpolate": "^1.1.6", 35 | "d3-scale": "^2.0.0", 36 | "d3-selection": "^1.3.0", 37 | "d3-shape": "^1.2.0", 38 | "d3-transition": "^1.1.1", 39 | "deepmerge": "^2.0.1", 40 | "events": "^2.0.0", 41 | "humanize-duration": "^3.12.1", 42 | "normalize.css": "^7.0.0", 43 | "visibilityjs": "^1.2.6", 44 | "vue": "^2.5.10", 45 | "vue-carousel": "^0.6.5", 46 | "vue-js-toggle-button": "^1.2.2", 47 | "vue-slider-component": "^2.5.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/components/settings.scss: -------------------------------------------------------------------------------- 1 | .b-settings { 2 | padding: 30px 20px 0; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | .b-settings__field { 11 | display: flex; 12 | margin-bottom: 12px; 13 | align-items: center; 14 | } 15 | 16 | .b-settings__label { 17 | color: rgba(#fff, 0.5); 18 | min-width: 100px; 19 | text-align: left; 20 | } 21 | 22 | .b-settings__control { 23 | flex-grow: 1; 24 | padding: 7px 10px; 25 | } 26 | 27 | .b-settings__warning { 28 | color: #ef8b6b; 29 | display: inline-block; 30 | margin-left: 10px; 31 | font-size: 0.9em; 32 | position: relative; 33 | top: -1px; 34 | } 35 | 36 | .b-settings__buttons { 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | padding-top: 30px; 41 | } 42 | 43 | .b-settings__button { 44 | transition: background 200ms, color 200ms; 45 | margin: 0; 46 | color: rgba(#fff, 0.7); 47 | font-family: inherit; 48 | background: transparent; 49 | cursor: pointer; 50 | padding: 5px 17px; 51 | border: 1px solid rgba(#fff, 0.25); 52 | text-align: center; 53 | border-radius: 90px; 54 | margin-right: -1px; 55 | margin: 0 7px; 56 | 57 | &:active { 58 | background: rgba(#000, 0.09) !important; 59 | } 60 | 61 | &:focus { 62 | outline: none; 63 | } 64 | 65 | &:hover { 66 | background: rgba(#fff, 0.09); 67 | color: #fff; 68 | } 69 | } 70 | 71 | @include device-until(mobile) { 72 | .b-settings { 73 | padding: 25 10px; 74 | } 75 | 76 | .b-settings__button { 77 | padding: 5px 15px; 78 | } 79 | 80 | .b-settings__control { 81 | padding: 5px; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/About.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 33 | -------------------------------------------------------------------------------- /src/styles/device.scss: -------------------------------------------------------------------------------- 1 | $devices: ( 2 | mobile: 412px, 3 | desktop: 1200px, 4 | wide: 1800px, 5 | ); 6 | 7 | @mixin device-between ($from: null, $until: null) { 8 | $query: "screen"; 9 | 10 | @if type-of($from) == 'string' { 11 | @if map-has-key($devices, $from) { 12 | $from: map-get($devices, $from); 13 | } 14 | 15 | @else { 16 | @error "Can not find device '#{$from}'"; 17 | } 18 | } 19 | 20 | @if type-of($until) == 'string' { 21 | @if map-has-key($devices, $until) { 22 | $until: map-get($devices, $until); 23 | } 24 | 25 | @else { 26 | @error "Can not find device '#{$until}'"; 27 | } 28 | } 29 | 30 | @if ($from) { 31 | $query: "#{$query} and (min-width: #{$from + 1})"; 32 | } 33 | 34 | @if ($until) { 35 | $query: "#{$query} and (max-width: #{$until - 1})"; 36 | } 37 | 38 | @media #{$query} { @content; } 39 | } 40 | 41 | @mixin device-from($device) { 42 | @include device-between($from: $device) { @content; } 43 | } 44 | 45 | @mixin device-until($device) { 46 | @include device-between($until: $device) { @content; } 47 | } 48 | 49 | @mixin device-only($device) { 50 | @if $device == mobile { 51 | @include device-until(mobile) { @content; } 52 | } 53 | 54 | @else { 55 | @if $device == tablet { 56 | @include device-between(mobile, desktop) { @content; } 57 | } 58 | 59 | @else { 60 | @if $device == desktop { 61 | @include device-between(desktop, wide) { @content; } 62 | } 63 | 64 | @else { 65 | @if $device == wide { 66 | @include device-from(wide) { @content; } 67 | } 68 | 69 | @else { 70 | @error "Can't build media query for \"#{$device}\" device"; 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/styles/plugins.scss: -------------------------------------------------------------------------------- 1 | .vue-slider-component { 2 | padding-left: 0 !important; 3 | padding-right: 0 !important; 4 | 5 | &:hover { 6 | .vue-slider-tooltip { 7 | visibility: visible !important; 8 | opacity: 1 !important; 9 | transform: scale(1) !important; 10 | } 11 | } 12 | 13 | .vue-slider { 14 | background: rgba(#fff, 0.1) !important; 15 | } 16 | 17 | .vue-slider-process { 18 | background: #39b6eb; 19 | } 20 | 21 | .vue-slider-dot { 22 | &.vue-slider-always { 23 | .vue-slider-tooltip { 24 | transform: scale(1); 25 | opacity: 1; 26 | } 27 | } 28 | } 29 | 30 | .vue-slider-tooltip { 31 | visibility: hidden !important; 32 | transition: transform 200ms, opacity 200ms, visibility 200ms; 33 | border: 0 !important; 34 | padding: 3px 4px; 35 | width: auto; 36 | line-height: 1; 37 | border-radius: 2px; 38 | min-width: auto; 39 | text-align: center; 40 | background: rgba(#000, 0.4) !important; 41 | font-size: 0.82em; 42 | transform: scale(0.8) !important; 43 | opacity: 0 !important; 44 | 45 | &:before { 46 | border-top-color: transparent !important; 47 | } 48 | } 49 | } 50 | 51 | .vue-slider-component--active { 52 | .vue-slider-tooltip { 53 | visibility: visible !important; 54 | opacity: 1 !important; 55 | transform: scale(1) !important; 56 | } 57 | } 58 | 59 | .vue-js-switch { 60 | transform: translateY(-2px); 61 | overflow: visible !important; 62 | -webkit-tap-highlight-color: transparent; 63 | 64 | .v-switch-core { 65 | background: rgba(#fff, 0.05); 66 | box-shadow: inset 0 0 0 1px rgba(#fff, 0.15); 67 | } 68 | 69 | &.toggled .v-switch-core { 70 | background: #39b6eb; 71 | box-shadow: inset 0 0 0 1px rgba(#fff, 0); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/styles/components/layout.scss: -------------------------------------------------------------------------------- 1 | .b-layout { 2 | position: relative; 3 | text-align: center; 4 | width: 400px; 5 | height: 500px; 6 | display: flex; 7 | flex-direction: column; 8 | z-index: 1; 9 | } 10 | 11 | .b-layout__top { 12 | padding-bottom: 25px; 13 | } 14 | 15 | .b-layout__body { 16 | position: relative; 17 | flex: 1 auto; 18 | } 19 | 20 | .b-layout__body--timer { 21 | .b-layout__tab--timer { 22 | visibility: visible; 23 | opacity: 1; 24 | } 25 | 26 | .b-layout__tab--stats { 27 | transform: translateX(50%); 28 | } 29 | 30 | .b-layout__tab--settings { 31 | transform: translateX(100%); 32 | } 33 | } 34 | 35 | .b-layout__body--stats { 36 | .b-layout__tab--timer { 37 | transform: translateX(-50%); 38 | } 39 | 40 | .b-layout__tab--stats { 41 | transform: translateX(0); 42 | visibility: visible; 43 | opacity: 1; 44 | } 45 | 46 | .b-layout__tab--settings { 47 | transform: translateX(50%); 48 | } 49 | } 50 | 51 | .b-layout__body--settings { 52 | .b-layout__tab--timer { 53 | transform: translateX(-100%); 54 | } 55 | 56 | .b-layout__tab--stats { 57 | transform: translateX(-50%); 58 | } 59 | 60 | .b-layout__tab--settings { 61 | transform: translateX(0); 62 | visibility: visible; 63 | opacity: 1; 64 | } 65 | } 66 | 67 | .b-layout__tab { 68 | transition: transform 400ms, opacity 200ms, visibility 200ms; 69 | transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1); 70 | opacity: 0; 71 | visibility: hidden; 72 | position: absolute; 73 | top: 0; 74 | left: 0; 75 | width: 100%; 76 | height: 100%; 77 | display: flex; 78 | justify-content: center; 79 | align-items: center; 80 | flex-direction: column; 81 | } 82 | 83 | .b-layout__tab--timer { 84 | } 85 | 86 | .b-layout__tab--stats { 87 | } 88 | 89 | .b-layout__tab--settings { 90 | } 91 | 92 | .b-layout__bottom { 93 | padding-top: 35px; 94 | } 95 | -------------------------------------------------------------------------------- /src/styles/components/heatmap.scss: -------------------------------------------------------------------------------- 1 | $columns: 10; 2 | $size: 10px; 3 | 4 | .b-heatmap { 5 | cursor: default !important; 6 | transform: translateY(30px); 7 | display: flex; 8 | flex-wrap: wrap; 9 | width: ($size * $columns) + ($columns * 6px); 10 | z-index: 1; 11 | } 12 | 13 | .b-heatmap__box { 14 | width: $size; 15 | height: $size; 16 | margin: 3px; 17 | background: rgba(#fff, 0.1); 18 | border-radius: 50%; 19 | position: relative; 20 | 21 | &:hover { 22 | .b-heatmap__title { 23 | transform: translateY(-100%) translateX(-50%) scale(1); 24 | visibility: visible; 25 | opacity: 1; 26 | } 27 | }; 28 | } 29 | 30 | .b-heatmap__level { 31 | background: #97ce28; 32 | width: 100%; 33 | height: 100%; 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | border-radius: 50%; 38 | } 39 | 40 | .b-heatmap__title { 41 | transition: transform 100ms, opacity 100ms, visibility 100ms; 42 | visibility: hidden; 43 | opacity: 0; 44 | background: rgba(#000, 0.3); 45 | position: absolute; 46 | top: 0; 47 | left: 50%; 48 | margin-top: -5px; 49 | transform: translateY(-100%) translateX(-50%) scale(0.8); 50 | padding: 3px 5px; 51 | font-size: 0.82em; 52 | border-radius: 3px; 53 | text-align: center; 54 | min-width: 20px; 55 | white-space: nowrap; 56 | pointer-events: none; 57 | } 58 | 59 | .b-heatmap__pomodoros { 60 | > b { 61 | position: relative; 62 | } 63 | } 64 | 65 | .b-heatmap__day { 66 | opacity: 0.7; 67 | } 68 | 69 | .b-heatmap__pomodoros { 70 | } 71 | 72 | .b-heatmap__time { 73 | } 74 | 75 | @include device-until(mobile) { 76 | $columns: 8; 77 | $size: 8px; 78 | 79 | .b-heatmap { 80 | width: ($size * $columns) + ($columns * 6px); 81 | transform: translateY(20px); 82 | } 83 | 84 | .b-heatmap__box { 85 | width: $size; 86 | height: $size; 87 | 88 | &:nth-child(n+25) { 89 | display: none; 90 | } 91 | } 92 | 93 | .b-heatmap__title { 94 | background: rgba(#000, 0.8); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/Clock.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 83 | -------------------------------------------------------------------------------- /src/components/Controls.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 93 | -------------------------------------------------------------------------------- /src/lib/pomodoro.js: -------------------------------------------------------------------------------- 1 | import merge from 'deepmerge'; 2 | 3 | export default class Pomodoro { 4 | static get state() { 5 | const createdAt = Date.now(); 6 | 7 | return { 8 | createdAt, 9 | duration: 0, 10 | skipped: false, 11 | type: null, 12 | pauses: [], 13 | }; 14 | } 15 | 16 | constructor(state = {}) { 17 | this.state = merge(Pomodoro.state, state); 18 | this.time = Date.now(); 19 | this.tick(); 20 | } 21 | 22 | tick() { 23 | if (!this.finished) { 24 | this.time = Date.now(); 25 | } 26 | } 27 | 28 | pause() { 29 | const { pauses } = this.state; 30 | const last = pauses[pauses.length - 1]; 31 | 32 | if (last === undefined || last.end !== null) { 33 | pauses.push({ 34 | start: this.time, 35 | end: null, 36 | }); 37 | } 38 | } 39 | 40 | unpause() { 41 | const { pauses } = this.state; 42 | const last = pauses[pauses.length - 1]; 43 | 44 | if (last !== undefined && last.end === null) { 45 | const end = this.time; 46 | 47 | if (last.start === end) { 48 | pauses.pop(); 49 | } else { 50 | last.end = end; 51 | } 52 | } 53 | } 54 | 55 | get pauses() { 56 | return this.state.pauses.reduce((result, pause) => { 57 | const start = new Date(pause.start); 58 | const end = pause.end !== null ? pause.end : this.time; 59 | return result + (end - start); 60 | }, 0); 61 | } 62 | 63 | get paused() { 64 | const { pauses } = this.state; 65 | const last = pauses[pauses.length - 1]; 66 | return last !== undefined && last.end === null; 67 | } 68 | 69 | get createdAt() { 70 | return this.state.createdAt; 71 | } 72 | 73 | get type() { 74 | return this.state.type; 75 | } 76 | 77 | get duration() { 78 | return this.state.duration; 79 | } 80 | 81 | get skipped() { 82 | return this.state.skipped; 83 | } 84 | 85 | get interval() { 86 | if (this.skipped) return this.duration + 1; 87 | return this.time - (this.createdAt + this.pauses); 88 | } 89 | 90 | get elapsed() { 91 | const value = this.duration - this.interval; 92 | return value > 0 ? value : 0; 93 | } 94 | 95 | get finished() { 96 | return this.skipped || this.elapsed <= 0; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/favicon.js: -------------------------------------------------------------------------------- 1 | const SIZE = 16; 2 | 3 | const DEFAULTS = { 4 | max: 100, 5 | value: 0, 6 | }; 7 | 8 | const Canvas = (size) => { 9 | const canvas = document.createElement('canvas'); 10 | const ctx = canvas.getContext('2d'); 11 | const ratio = window.devicePixelRatio || 1; 12 | 13 | canvas.width = size * ratio; 14 | canvas.height = size * ratio; 15 | ctx.scale(ratio, ratio); 16 | 17 | return canvas; 18 | }; 19 | 20 | const Icon = () => { 21 | const icon = document.createElement('link'); 22 | 23 | icon.rel = 'icon'; 24 | icon.type = 'image/png'; 25 | 26 | return icon; 27 | }; 28 | 29 | export default class Favicon { 30 | constructor(options = {}) { 31 | this.options = Object.assign({}, DEFAULTS, options); 32 | this.icon = Icon(); 33 | this.size = SIZE; 34 | this.canvas = Canvas(this.size); 35 | this.ctx = this.canvas.getContext('2d'); 36 | } 37 | 38 | attach() { 39 | const icons = document.querySelectorAll('link[rel=icon]'); 40 | icons.forEach((icon) => { icon.setAttribute('rel', 'prev-icon'); }); 41 | document.head.append(this.icon); 42 | } 43 | 44 | draw(options = {}) { 45 | const { 46 | max, 47 | value, 48 | fill, 49 | } = Object.assign({}, this.options, options); 50 | 51 | const { 52 | ctx, icon, canvas, size, 53 | } = this; 54 | 55 | ctx.clearRect(0, 0, size, size); 56 | 57 | this.drawPie({ fill: '#292f3d', padding: 0.5 }); 58 | this.drawPie({ max, value, fill }); 59 | 60 | icon.href = canvas.toDataURL(); 61 | } 62 | 63 | drawPie(options) { 64 | const { 65 | max = 100, 66 | value = 100, 67 | width = 1, 68 | fill = 'transparent', 69 | padding = 1, 70 | } = options; 71 | 72 | const { ctx, size } = this; 73 | 74 | const center = size / 2; 75 | const progress = (360 / max) * value; 76 | const startAngle = (Math.PI / 180) * 270; 77 | const endAngle = (Math.PI / 180) * (270 + progress); 78 | 79 | ctx.beginPath(); 80 | ctx.moveTo(center, center); 81 | ctx.arc(center, center, center - padding, startAngle, endAngle, false); 82 | ctx.lineTo(center, center); 83 | ctx.strokeStyle = fill; 84 | ctx.fillStyle = fill; 85 | ctx.lineWidth = width; 86 | ctx.closePath(); 87 | ctx.stroke(); 88 | ctx.fill(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/styles/components/clock.scss: -------------------------------------------------------------------------------- 1 | .b-clock { 2 | position: relative; 3 | display: inline-block; 4 | transform: translateX(-2px); 5 | margin-bottom: 20px; 6 | z-index: 1; 7 | } 8 | 9 | .b-clock--paused { 10 | .b-clock__pauses { 11 | opacity: 0.8; 12 | } 13 | 14 | .b-clock__minutes { 15 | opacity: 0.3; 16 | } 17 | 18 | .b-clock__seconds { 19 | opacity: 0.2; 20 | } 21 | } 22 | 23 | .b-clock__minutes { 24 | transition: opacity 300ms; 25 | font-size: 4em; 26 | line-height: 1; 27 | } 28 | 29 | .b-clock__seconds { 30 | transition: opacity 300ms; 31 | font-size: 1.2em; 32 | position: absolute; 33 | top: 5px; 34 | right: -23px; 35 | line-height: 1; 36 | opacity: 0.6; 37 | } 38 | 39 | .b-clock__pauses { 40 | transition: opacity 300ms, transform 300ms, bottom 300ms; 41 | font-size: 1.2em; 42 | color: #ff9451; 43 | position: absolute; 44 | opacity: 0.2; 45 | white-space: nowrap; 46 | bottom: 5px; 47 | left: 100%; 48 | transform: translateX(9px); 49 | } 50 | 51 | .b-clock__pauses--hidden { 52 | opacity: 0; 53 | } 54 | 55 | .b-clock__pauses--small { 56 | transform: translateX(3px) translateY(1px) scale(0.75); 57 | bottom: 5px; 58 | } 59 | 60 | @include device-until(mobile) { 61 | .b-clock { 62 | margin-top: -16px; 63 | display: flex; 64 | margin-bottom: 0; 65 | } 66 | 67 | .b-clock--paused { 68 | .b-clock__pauses { 69 | transform: translateX(-50%) translateY(-50%) scale(1); 70 | } 71 | 72 | .b-clock__minutes { 73 | transform: scale(0.5); 74 | opacity: 0; 75 | } 76 | 77 | .b-clock__seconds { 78 | transform: scale(0.5); 79 | opacity: 0; 80 | } 81 | } 82 | 83 | .b-clock__minutes { 84 | transition: opacity 300ms, transform 300ms; 85 | font-size: 2em; 86 | } 87 | 88 | .b-clock__seconds { 89 | transition: opacity 300ms, transform 300ms; 90 | font-size: 2em; 91 | position: static; 92 | top: auto; 93 | right: auto; 94 | opacity: 1; 95 | 96 | &::before { 97 | content: ':'; 98 | display: inline-block; 99 | margin: 0 2px; 100 | transform: translateY(-2px); 101 | } 102 | } 103 | 104 | .b-clock__pauses { 105 | font-size: 2em; 106 | bottom: auto; 107 | top: 50%; 108 | left: 50%; 109 | right: auto; 110 | transform: translateX(-50%) translateY(-50%) scale(0.5); 111 | line-height: 1; 112 | display: inline-block; 113 | opacity: 0; 114 | } 115 | 116 | .b-clock__pauses--small { 117 | transform: translateX(-50%) translateY(-50%) scale(0.5); 118 | bottom: auto; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/components/Heatmap.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 101 | -------------------------------------------------------------------------------- /src/components/Process.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 105 | 106 | -------------------------------------------------------------------------------- /index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Pomidorus 27 | 28 | 29 | 30 |
31 | 32 | 33 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/styles/themes/one-light.scss: -------------------------------------------------------------------------------- 1 | body.theme-one-light { 2 | background: #fbfbfb; 3 | color: #2c394d; 4 | 5 | .b-tabs__item { 6 | opacity: 0.4; 7 | } 8 | 9 | .b-tabs__item--active { 10 | opacity: 1; 11 | } 12 | 13 | .b-process__value { 14 | fill: lighten(#eb8388, 5%); 15 | } 16 | 17 | .b-application--break .b-process__value { 18 | fill: #a5cc8e; 19 | } 20 | 21 | .b-target__item { 22 | fill: rgba(#2c394d, 0.2); 23 | } 24 | 25 | .b-target__item--finished { 26 | fill: lighten(#6ebcf0, 4%); 27 | } 28 | 29 | .b-target__item--extra { 30 | fill: lighten(#6ebcf0, 10%); 31 | } 32 | 33 | .b-target__item--skipped { 34 | fill: rgba(#6ebcf0, 0.5); 35 | } 36 | 37 | .b-heatmap__box { 38 | background: rgba(#2c394d, 0.1); 39 | } 40 | 41 | .b-heatmap__level { 42 | background: #99cc8e; 43 | } 44 | 45 | .b-clock__pauses { 46 | color: #eb8388; 47 | } 48 | 49 | .b-controls__button { 50 | border-color: rgba(#2c394d, 0.6); 51 | 52 | &:hover { 53 | background-color: rgba(#2c394d, 0.05); 54 | } 55 | 56 | &:active { 57 | background-color: rgba(#2c394d, 0.2); 58 | } 59 | } 60 | 61 | .b-controls__action { 62 | color: #2c394d; 63 | opacity: 0.6; 64 | 65 | &:hover { 66 | opacity: 1; 67 | } 68 | } 69 | 70 | .b-settings__label { 71 | color: #2c394d; 72 | } 73 | 74 | .b-settings__button { 75 | color: #2c394d; 76 | border-color: rgba(#2c394d, 0.6); 77 | 78 | &:hover { 79 | background-color: rgba(#2c394d, 0.05); 80 | } 81 | 82 | &:active { 83 | background-color: rgba(#2c394d, 0.2); 84 | } 85 | } 86 | 87 | .b-about__desc { 88 | color: #2c394d; 89 | } 90 | 91 | .b-themes { 92 | background: transparent; 93 | color: #2c394d; 94 | border-color: rgba(#2c394d, 0.6); 95 | 96 | &::after { 97 | border-top-color: #2c394d; 98 | } 99 | } 100 | 101 | .b-heatmap__title { 102 | background: rgba(#000, 0.6); 103 | color: #fff; 104 | } 105 | 106 | .b-about__github { 107 | fill: #2c394d; 108 | } 109 | 110 | .vue-slider { 111 | background: rgba(#2c394d, 0.2) !important; 112 | } 113 | 114 | .vue-slider-tooltip { 115 | background: rgba(#000, 0.7) !important; 116 | } 117 | 118 | .vue-slider-process { 119 | background: #6ebcf0; 120 | } 121 | 122 | .vue-js-switch { 123 | transform: translateY(-2px); 124 | overflow: visible !important; 125 | -webkit-tap-highlight-color: transparent; 126 | 127 | .v-switch-core { 128 | background: transparent; 129 | box-shadow: inset 0 0 0 1px rgba(#2c394d, 0.6); 130 | } 131 | 132 | .v-switch-button { 133 | background: rgba(#2c394d, 0.7) !important; 134 | } 135 | 136 | &.toggled { 137 | .v-switch-core { 138 | background: #6ebcf0; 139 | box-shadow: inset 0 0 0 1px rgba(#fff, 0); 140 | } 141 | 142 | .v-switch-button { 143 | background: #fff !important; 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/styles/themes/solarized-light.scss: -------------------------------------------------------------------------------- 1 | body.theme-solarized-light { 2 | background: #fdf6e3; 3 | color: #657b83; 4 | 5 | .b-tabs__item { 6 | opacity: 0.4; 7 | } 8 | 9 | .b-tabs__item--active { 10 | opacity: 1; 11 | } 12 | 13 | .b-process__value { 14 | fill: lighten(#ce4d17, 5%); 15 | } 16 | 17 | .b-application--break .b-process__value { 18 | fill: #bf9937; 19 | } 20 | 21 | .b-target__item { 22 | fill: rgba(#657b83, 0.2); 23 | } 24 | 25 | .b-target__item--finished { 26 | fill: lighten(#2ba29e, 4%); 27 | } 28 | 29 | .b-target__item--extra { 30 | fill: lighten(#2ba29e, 10%); 31 | } 32 | 33 | .b-target__item--skipped { 34 | fill: rgba(#2ba29e, 0.5); 35 | } 36 | 37 | .b-heatmap__box { 38 | background: rgba(#657b83, 0.15); 39 | } 40 | 41 | .b-heatmap__level { 42 | background: lighten(#84991a, 7%); 43 | } 44 | 45 | .b-clock__pauses { 46 | color: #cb4b16; 47 | } 48 | 49 | .b-controls__button { 50 | border-color: rgba(#657b83, 0.6); 51 | 52 | &:hover { 53 | background-color: rgba(#657b83, 0.05); 54 | } 55 | 56 | &:active { 57 | background-color: rgba(#657b83, 0.2); 58 | } 59 | } 60 | 61 | .b-controls__action { 62 | color: #657b83; 63 | opacity: 0.6; 64 | 65 | &:hover { 66 | opacity: 1; 67 | } 68 | } 69 | 70 | .b-settings__label { 71 | color: #657b83; 72 | } 73 | 74 | .b-settings__button { 75 | color: #657b83; 76 | border-color: rgba(#657b83, 0.6); 77 | 78 | &:hover { 79 | background-color: rgba(#657b83, 0.05); 80 | } 81 | 82 | &:active { 83 | background-color: rgba(#657b83, 0.2); 84 | } 85 | } 86 | 87 | .b-about__desc { 88 | color: #657b83; 89 | } 90 | 91 | .b-themes { 92 | background: transparent; 93 | color: #657b83; 94 | border-color: rgba(#657b83, 0.6); 95 | 96 | &::after { 97 | border-top-color: #657b83; 98 | } 99 | } 100 | 101 | .b-heatmap__title { 102 | background: rgba(#000, 0.6); 103 | color: #fff; 104 | } 105 | 106 | .b-about__github { 107 | fill: #657b83; 108 | } 109 | 110 | .vue-slider { 111 | background: rgba(#657b83, 0.2) !important; 112 | } 113 | 114 | .vue-slider-tooltip { 115 | background: rgba(#000, 0.7) !important; 116 | } 117 | 118 | .vue-slider-process { 119 | background: #2ba29e; 120 | } 121 | 122 | .vue-js-switch { 123 | transform: translateY(-2px); 124 | overflow: visible !important; 125 | -webkit-tap-highlight-color: transparent; 126 | 127 | .v-switch-core { 128 | background: transparent; 129 | box-shadow: inset 0 0 0 1px rgba(#657b83, 0.6); 130 | } 131 | 132 | .v-switch-button { 133 | background: rgba(#657b83, 0.7) !important; 134 | } 135 | 136 | &.toggled { 137 | .v-switch-core { 138 | background: #2ba29e; 139 | box-shadow: inset 0 0 0 1px rgba(#fff, 0); 140 | } 141 | 142 | .v-switch-button { 143 | background: #fff !important; 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ruslan.tatyshev@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 18 | 26 | 34 | 43 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/components/Target.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 139 | 140 | -------------------------------------------------------------------------------- /src/components/Application.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 46 | 47 | 173 | -------------------------------------------------------------------------------- /src/lib/pomodoro.spec.js: -------------------------------------------------------------------------------- 1 | import given from 'given2'; 2 | import Pomodoro from './pomodoro'; 3 | 4 | given('input', () => ({})); 5 | given('pomodoro', () => new Pomodoro(given.input)); 6 | given('state', () => given.pomodoro.state); 7 | given('pauses', () => given.state.pauses); 8 | 9 | describe('Pomodoro', () => { 10 | describe('.constructor()', () => { 11 | it('should set default state', () => { 12 | spyOn(Date, 'now').and.returnValue(123); 13 | expect(given.pomodoro.state).toEqual(Pomodoro.state); 14 | }); 15 | 16 | it('should set initial state', () => { 17 | given('createdAt', () => Date.now()); 18 | given('input', () => ({ createdAt: given.createdAt, type: 'example' })); 19 | 20 | expect(given.pomodoro.createdAt).toBe(given.createdAt); 21 | expect(given.pomodoro.type).toBe('example'); 22 | }); 23 | }); 24 | 25 | describe('.pause()', () => { 26 | describe('when called first time', () => { 27 | it('should add pause with { end: null }', () => { 28 | given.pomodoro.time = 123; 29 | 30 | expect(given.pauses).toEqual([]); 31 | given.pomodoro.pause(); 32 | expect(given.pauses).toEqual([{ start: given.pomodoro.time, end: null }]); 33 | }); 34 | }); 35 | 36 | describe('when called on existing pause', () => { 37 | given('input', () => ({ pauses: [{ start: 123, end: null }] })); 38 | 39 | it('should not add extra pause', () => { 40 | given.pomodoro.pause(); 41 | expect(given.pauses.length).toBe(1); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('.unpause()', () => { 47 | describe('when pauses not exist', () => { 48 | it('should not do anything', () => { 49 | expect(() => given.pomodoro.unpause()).not.toThrow(); 50 | expect(given.pauses.length).toBe(0); 51 | }); 52 | }); 53 | 54 | describe('when pauses exists', () => { 55 | given('input', () => ({ pauses: [{ start: 123, end: null }] })); 56 | given('pause', () => given.pomodoro.state.pauses[0]); 57 | 58 | it('should set "end" property on last pause', () => { 59 | given.pomodoro.time = 345; 60 | given.pomodoro.unpause(); 61 | 62 | expect(given.pause.end).toBe(345); 63 | }); 64 | }); 65 | 66 | describe('when unpaused at same time', () => { 67 | it('should remove pause', () => { 68 | given.pomodoro.pause(); 69 | expect(given.pauses.length).toBe(1); 70 | given.pomodoro.unpause(); 71 | expect(given.pauses.length).toBe(0); 72 | }); 73 | }); 74 | }); 75 | 76 | describe('.pauses', () => { 77 | describe('when pauses not exist', () => { 78 | it('should return zero', () => { 79 | expect(given.pomodoro.pauses).toBe(0); 80 | }); 81 | }); 82 | 83 | describe('when pauses exist', () => { 84 | given('input', () => ({ pauses: given.pauses })); 85 | 86 | describe('when one pause', () => { 87 | given('pauses', () => ([{ start: 100, end: 200 }])); 88 | 89 | it('should return pauses time', () => { 90 | expect(given.pomodoro.pauses).toBe(100); 91 | }); 92 | }); 93 | 94 | describe('when multiple pause', () => { 95 | given('pauses', () => ([ 96 | { start: 100, end: 200 }, 97 | { start: 400, end: 500 }, 98 | ])); 99 | 100 | it('should return pauses time', () => { 101 | expect(given.pomodoro.pauses).toBe(200); 102 | }); 103 | }); 104 | }); 105 | 106 | describe('when pause is active', () => { 107 | given('input', () => ({ pauses: given.pauses })); 108 | given('pauses', () => ([{ start: 100, end: null }])); 109 | 110 | it('should return time between "start" and "now"', () => { 111 | given.pomodoro.time = 200; 112 | expect(given.pomodoro.pauses).toBe(100); 113 | }); 114 | }); 115 | }); 116 | 117 | describe('.interval', () => { 118 | given('input', () => ({ createdAt: 10 })); 119 | 120 | it('should return interval between "now" and "createdAt"', () => { 121 | given.pomodoro.time = 20; 122 | expect(given.pomodoro.interval).toBe(10); 123 | 124 | given.pomodoro.time = 30; 125 | expect(given.pomodoro.interval).toBe(20); 126 | 127 | given.pomodoro.time = 40; 128 | expect(given.pomodoro.interval).toBe(30); 129 | }); 130 | 131 | it('should not increase when paused', () => { 132 | given.pomodoro.time = 20; 133 | 134 | expect(given.pomodoro.interval).toBe(10); 135 | 136 | given.pomodoro.pause(); 137 | given.pomodoro.time = 30; 138 | 139 | expect(given.pomodoro.interval).toBe(10); 140 | }); 141 | }); 142 | 143 | describe('.elapsed', () => { 144 | given('input', () => ({ createdAt: 10, duration: 10 })); 145 | 146 | it('should return "elapsed" time from createdAt', () => { 147 | given.pomodoro.time = 15; 148 | expect(given.pomodoro.elapsed).toBe(5); 149 | 150 | given.pomodoro.time = 20; 151 | expect(given.pomodoro.elapsed).toBe(0); 152 | }); 153 | }); 154 | 155 | describe('.finished', () => { 156 | given('input', () => ({ duration: 10 })); 157 | 158 | describe('when time not finished', () => { 159 | it('should return "false"', () => { 160 | expect(given.pomodoro.finished).toBe(false); 161 | }); 162 | }); 163 | 164 | describe('when time finished', () => { 165 | it('should return "true"', () => { 166 | spyOnProperty(given.pomodoro, 'interval', 'get').and.returnValue(10); 167 | expect(given.pomodoro.finished).toBe(true); 168 | }); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/components/Settings.vue: -------------------------------------------------------------------------------- 1 | 121 | 122 | 215 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import merge from 'deepmerge'; 2 | import Events from 'events'; 3 | import { minutes, today, propsLimit } from '@/lib/utils'; 4 | import Pomodoro from '@/lib/pomodoro'; 5 | import notify, { sounds } from '@/lib/notify'; 6 | 7 | export const DEFAULT_TYPE = 'DEFAULT'; 8 | export const SHORT_TYPE = 'SHORT'; 9 | export const LONG_TYPE = 'LONG'; 10 | 11 | export const DEFAULT_ALERT = 'It\'s time to work'; 12 | export const SHORT_ALERT = 'It\'s time to break'; 13 | export const LONG_ALERT = 'It\'s time to long break'; 14 | 15 | export const STATS_LIMIT = 100; 16 | 17 | export default class Focus { 18 | static get state() { 19 | return { 20 | items: [], 21 | options: { 22 | theme: '', 23 | sounds: false, 24 | auto: false, 25 | notifications: false, 26 | target: 10, 27 | longAfter: 4, 28 | durations: { 29 | [DEFAULT_TYPE]: minutes(25), 30 | [SHORT_TYPE]: minutes(5), 31 | [LONG_TYPE]: minutes(15), 32 | }, 33 | }, 34 | }; 35 | } 36 | 37 | static load() { 38 | const state = JSON.parse(localStorage.getItem('state')) || {}; 39 | return new this(state); 40 | } 41 | 42 | constructor(state = {}) { 43 | const events = new Events(); 44 | 45 | this.state = merge(Focus.state, state); 46 | this.pending = null; 47 | this.touched = false; 48 | 49 | this.on = events.on; 50 | this.emit = events.emit; 51 | 52 | if (!this.isEmpty) { 53 | this.state.items = this.items.map(item => new Pomodoro(item)); 54 | } 55 | } 56 | 57 | start() { 58 | setInterval(this.tick.bind(this), 1000); 59 | this.touched = this.isActive; 60 | } 61 | 62 | tick() { 63 | this.items.forEach(item => item.tick()); 64 | 65 | if (this.isActive) { 66 | this.emit('tick'); 67 | return; 68 | } 69 | 70 | if (this.isFinished && this.touched && this.pending == null) { 71 | if (!this.current.skipped) { 72 | this.emit('finish', this.current); 73 | this.notify(); 74 | } 75 | 76 | this.pending = this.current; 77 | if (this.options.auto) this.play(); 78 | } 79 | } 80 | 81 | play() { 82 | if (this.isActive === true) return; 83 | 84 | let type; 85 | let duration; 86 | 87 | if (this.isEmpty || this.isShort || this.isLong) { 88 | type = DEFAULT_TYPE; 89 | duration = this.durations[DEFAULT_TYPE]; 90 | } 91 | 92 | if (this.isWork) { 93 | type = this.isTimeToLong ? LONG_TYPE : SHORT_TYPE; 94 | duration = this.durations[type]; 95 | } 96 | 97 | this.touched = true; 98 | this.pending = null; 99 | 100 | const item = new Pomodoro({ type, duration }); 101 | 102 | this.state.items = [...this.state.items, item]; 103 | } 104 | 105 | pause() { 106 | if (this.current) { 107 | this.current.pause(); 108 | } 109 | } 110 | 111 | unpause() { 112 | if (this.current) { 113 | this.current.unpause(); 114 | } 115 | } 116 | 117 | stop() { 118 | if (this.isActive) { 119 | this.items.pop(); 120 | } 121 | } 122 | 123 | reset() { 124 | this.state.items = []; 125 | } 126 | 127 | skip() { 128 | if (this.isActive) { 129 | this.current.state.skipped = true; 130 | } 131 | } 132 | 133 | toJson() { 134 | const state = { ...this.state }; 135 | 136 | if (state.items.length !== 0) { 137 | state.items = state.items.map(item => ({ ...item.state })); 138 | } 139 | 140 | return state; 141 | } 142 | 143 | statistics() { 144 | return { 145 | completed: this.completed.length, 146 | target: this.target, 147 | time: this.time, 148 | }; 149 | } 150 | 151 | save() { 152 | const t = today(); 153 | const state = this.toJson(); 154 | let statistics = JSON.parse(localStorage.getItem('statistics')); 155 | 156 | if (statistics && !statistics[t]) { 157 | this.emit('daily'); 158 | this.reset(); 159 | this.play(); 160 | } else { 161 | statistics = { ...statistics }; 162 | } 163 | 164 | statistics[t] = propsLimit(this.statistics(), STATS_LIMIT); 165 | 166 | localStorage.setItem('state', JSON.stringify(state)); 167 | localStorage.setItem('statistics', JSON.stringify(statistics)); 168 | 169 | this.emit('update'); 170 | } 171 | 172 | notify() { 173 | const { type } = this.current; 174 | const icon = 'android-chrome-192x192.png'; 175 | let title = DEFAULT_ALERT; 176 | let vibrate = 200; 177 | 178 | if (type === DEFAULT_TYPE) { 179 | title = this.isTimeToLong ? LONG_ALERT : SHORT_ALERT; 180 | vibrate = 700; 181 | } 182 | 183 | if (this.options.sounds) sounds.play(); 184 | 185 | if (this.options.notifications) { 186 | // eslint-disable-next-line consistent-return 187 | notify(title, { icon, vibrate }); 188 | } 189 | } 190 | 191 | get items() { 192 | return this.state.items; 193 | } 194 | 195 | get options() { 196 | return this.state.options; 197 | } 198 | 199 | get target() { 200 | return this.options.target; 201 | } 202 | 203 | get completed() { 204 | return this.items.filter(item => item.type === DEFAULT_TYPE && item.finished); 205 | } 206 | 207 | get time() { 208 | return this.completed.reduce((time, p) => time + p.duration, 0); 209 | } 210 | 211 | get durations() { 212 | return this.options.durations; 213 | } 214 | 215 | get longAfter() { 216 | return this.options.longAfter; 217 | } 218 | 219 | get isTimeToLong() { 220 | return (this.completed.length % this.longAfter) === 0; 221 | } 222 | 223 | get elapsed() { 224 | if (!this.current) return 0; 225 | return this.current.elapsed; 226 | } 227 | 228 | get pauses() { 229 | if (!this.current) return 0; 230 | return this.current.pauses; 231 | } 232 | 233 | get interval() { 234 | if (!this.current) { 235 | return 0; 236 | } 237 | 238 | return this.current.interval; 239 | } 240 | 241 | get duration() { 242 | if (!this.current) return 0; 243 | return this.current.duration; 244 | } 245 | 246 | get current() { 247 | return this.items[this.items.length - 1]; 248 | } 249 | 250 | get isWork() { 251 | return this.current && this.current.type === DEFAULT_TYPE; 252 | } 253 | 254 | get isShort() { 255 | return this.current && this.current.type === SHORT_TYPE; 256 | } 257 | 258 | get isLong() { 259 | return this.current && this.current.type === LONG_TYPE; 260 | } 261 | 262 | get isEmpty() { 263 | return this.items.length === 0; 264 | } 265 | 266 | get isActive() { 267 | return this.current && !this.current.finished; 268 | } 269 | 270 | get isFinished() { 271 | return this.current && this.current.finished; 272 | } 273 | 274 | get isPaused() { 275 | return this.current !== undefined && this.current.paused; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/lib/index.spec.js: -------------------------------------------------------------------------------- 1 | import given from 'given2'; 2 | import Focus, { DEFAULT_TYPE, SHORT_TYPE, LONG_TYPE } from './index'; 3 | import Pomodoro from './pomodoro'; 4 | 5 | describe('Focus', () => { 6 | given('input', () => ({})); 7 | given('focus', () => new Focus(given.input)); 8 | 9 | describe('.constructor()', () => { 10 | it('should set default initial state', () => { 11 | expect(given.focus.state).toEqual(Focus.state); 12 | }); 13 | 14 | it('should set initial state', () => { 15 | const custom = { 16 | items: [], 17 | options: { 18 | theme: '', 19 | sounds: false, 20 | auto: false, 21 | notifications: false, 22 | target: 1, 23 | longAfter: 2, 24 | durations: { DEFAULT: 3, SHORT: 4, LONG: 5 }, 25 | }, 26 | }; 27 | 28 | given('input', () => custom); 29 | expect(given.focus.state).toEqual(custom); 30 | }); 31 | 32 | it('should wrap all items with Pomodoro', () => { 33 | given('input', () => ({ items: [{}, {}] })); 34 | const isPomodoro = item => item instanceof Pomodoro; 35 | expect(given.focus.items.map(isPomodoro)).toEqual([true, true]); 36 | }); 37 | }); 38 | 39 | describe('.play()', () => { 40 | describe('when items is empty', () => { 41 | it('should add first pomodoro', () => { 42 | expect(given.focus.items.length).toBe(0); 43 | given.focus.play(); 44 | expect(given.focus.items.length).toBe(1); 45 | }); 46 | }); 47 | 48 | describe('when items is not empty', () => { 49 | given('input', () => ({ items: given.items })); 50 | 51 | describe('when current is work', () => { 52 | given('items', () => [{ type: DEFAULT_TYPE }]); 53 | 54 | it('should add "work" pomodoro', () => { 55 | given.focus.play(); 56 | 57 | expect(given.focus.items.length).toBe(2); 58 | expect(given.focus.current.type).toBe(SHORT_TYPE); 59 | }); 60 | }); 61 | 62 | describe('when current is break', () => { 63 | given('items', () => [ 64 | { type: DEFAULT_TYPE }, 65 | { type: SHORT_TYPE }, 66 | ]); 67 | 68 | it('should add "work" pomodoro', () => { 69 | given.focus.play(); 70 | 71 | expect(given.focus.items.length).toBe(3); 72 | expect(given.focus.items[given.focus.items.length - 1].type).toBe(DEFAULT_TYPE); 73 | }); 74 | }); 75 | 76 | describe('when time to long break', () => { 77 | given('input', () => ({ 78 | items: [ 79 | { type: DEFAULT_TYPE, duration: 0 }, 80 | { type: SHORT_TYPE, duration: 0 }, 81 | { type: DEFAULT_TYPE, duration: 0 }, 82 | ], 83 | options: { 84 | longAfter: 2, 85 | }, 86 | })); 87 | 88 | it('should add long break', () => { 89 | given.focus.play(); 90 | expect(given.focus.current.type).toBe(LONG_TYPE); 91 | }); 92 | }); 93 | 94 | describe('when current is active', () => { 95 | given('items', () => [{ duration: 5 }]); 96 | 97 | it('should not add new items', () => { 98 | expect(given.focus.items.length).toBe(1); 99 | given.focus.play(); 100 | expect(given.focus.items.length).toBe(1); 101 | }); 102 | }); 103 | }); 104 | }); 105 | 106 | describe('.start()', () => { 107 | it('should call "tick" every second', () => { 108 | const tick = () => {}; 109 | 110 | spyOn(window, 'setInterval'); 111 | spyOn(given.focus.tick, 'bind').and.returnValue(tick); 112 | 113 | given.focus.start(); 114 | 115 | expect(window.setInterval).toHaveBeenCalledWith(tick, 1000); 116 | }); 117 | }); 118 | 119 | describe('.tick()', () => { 120 | it('should call "tick" on each item', () => { 121 | given('input', () => ({ items: [{}, {}] })); 122 | 123 | const first = given.focus.items[0]; 124 | const second = given.focus.items[1]; 125 | 126 | spyOn(first, 'tick'); 127 | spyOn(second, 'tick'); 128 | 129 | given.focus.tick(); 130 | 131 | expect(first.tick).toHaveBeenCalled(); 132 | expect(second.tick).toHaveBeenCalled(); 133 | }); 134 | }); 135 | 136 | describe('.pause()', () => { 137 | it('should call pause on current item', () => { 138 | given('input', () => ({ items: [{}] })); 139 | spyOn(given.focus.current, 'pause'); 140 | 141 | given.focus.pause(); 142 | expect(given.focus.current.pause).toHaveBeenCalled(); 143 | }); 144 | }); 145 | 146 | describe('.unpause()', () => { 147 | it('should call pause on current item', () => { 148 | given('input', () => ({ items: [{}] })); 149 | spyOn(given.focus.current, 'unpause'); 150 | 151 | given.focus.unpause(); 152 | expect(given.focus.current.unpause).toHaveBeenCalled(); 153 | }); 154 | }); 155 | 156 | describe('.stop()', () => { 157 | describe('when current is active', () => { 158 | given('input', () => ({ items: [{}, { duration: 1 }] })); 159 | 160 | it('should remove last element', () => { 161 | expect(given.focus.items.length).toBe(2); 162 | given.focus.stop(); 163 | expect(given.focus.items.length).toBe(1); 164 | }); 165 | }); 166 | 167 | describe('when current is not active', () => { 168 | given('input', () => ({ items: [{}] })); 169 | 170 | it('should not do anything', () => { 171 | expect(given.focus.items.length).toBe(1); 172 | given.focus.stop(); 173 | expect(given.focus.items.length).toBe(1); 174 | }); 175 | }); 176 | }); 177 | 178 | describe('.toJson()', () => { 179 | given('input', () => ({ 180 | items: [ 181 | { 182 | createdAt: 1, type: 'one', duration: 1, pauses: [], skipped: false, 183 | }, 184 | { 185 | createdAt: 2, type: 'two', duration: 2, pauses: [], skipped: false, 186 | }, 187 | ], 188 | options: { 189 | theme: 'foo', 190 | sounds: false, 191 | auto: false, 192 | notifications: false, 193 | target: 5, 194 | longAfter: 3, 195 | durations: { 196 | DEFAULT: 1, 197 | SHORT: 2, 198 | LONG: 3, 199 | }, 200 | }, 201 | })); 202 | 203 | it('should return state as json', () => { 204 | expect(given.focus.toJson()).toEqual(given.input); 205 | }); 206 | }); 207 | 208 | describe('.isTimeToLong', () => { 209 | given('input', () => ({ longAfter: 4 })); 210 | given('len', () => 0); 211 | given('completed', () => ({ length: given.len })); 212 | 213 | beforeEach(() => { 214 | spyOnProperty(given.focus, 'completed', 'get').and.callFake(() => given.completed); 215 | }); 216 | 217 | describe('when completed is equal', () => { 218 | given('len', () => 4); 219 | 220 | it('should return "true"', () => { 221 | expect(given.focus.isTimeToLong).toBe(true); 222 | }); 223 | }); 224 | 225 | describe('when completed is enough', () => { 226 | given('len', () => 12); 227 | 228 | it('should return "true"', () => { 229 | expect(given.focus.isTimeToLong).toBe(true); 230 | }); 231 | }); 232 | 233 | describe('when completed is not enough', () => { 234 | given('len', () => 3); 235 | 236 | it('should return "false"', () => { 237 | expect(given.focus.isTimeToLong).toBe(false); 238 | }); 239 | }); 240 | 241 | describe('when completed is not time', () => { 242 | given('len', () => 5); 243 | 244 | it('should return "false"', () => { 245 | expect(given.focus.isTimeToLong).toBe(false); 246 | }); 247 | }); 248 | }); 249 | 250 | describe('.time', () => { 251 | given('input', () => ({ 252 | items: [ 253 | { createdAt: 10, duration: 10, type: DEFAULT_TYPE }, 254 | { createdAt: 20, duration: 10, type: DEFAULT_TYPE }, 255 | ], 256 | })); 257 | 258 | it('should return total work time', () => { 259 | const [first, second] = given.focus.items; 260 | 261 | first.time = 20; 262 | second.time = 40; 263 | 264 | expect(given.focus.time).toBe(20); 265 | }); 266 | }); 267 | }); 268 | --------------------------------------------------------------------------------