├── packages ├── core │ ├── index.js │ └── package.json ├── electron │ ├── preload.js │ ├── assets │ │ ├── icon.ico │ │ ├── icon.png │ │ ├── 128x128.png │ │ ├── 256x256.png │ │ ├── icon.icns │ │ └── 1024x1024.png │ ├── icons │ │ ├── idle.ico │ │ ├── idle.png │ │ ├── pausing.ico │ │ ├── pausing.png │ │ ├── state │ │ │ ├── four.ico │ │ │ ├── four.png │ │ │ ├── one.ico │ │ │ ├── one.png │ │ │ ├── two.ico │ │ │ ├── two.png │ │ │ ├── zero.ico │ │ │ ├── zero.png │ │ │ ├── three.ico │ │ │ └── three.png │ │ └── index.js │ ├── tests │ │ └── App.test.js │ ├── utils │ │ ├── getLatestVersion.js │ │ ├── notifyLatestVersion.js │ │ └── toCSV.js │ ├── package.json │ ├── store.js │ └── app.js └── app │ ├── public │ ├── favicon.ico │ ├── assets │ │ ├── welcome │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ ├── 4.png │ │ │ ├── 5.gif │ │ │ ├── 6.png │ │ │ └── cat.svg │ │ └── audio │ │ │ ├── notification.wav │ │ │ └── notification-long.wav │ └── index.html │ ├── src │ ├── tests │ │ ├── __mocks__ │ │ │ └── ipcRenderer.js │ │ └── Welcome.test.js │ ├── assets │ │ ├── fonts │ │ │ ├── Material-Icons-Round.ttf │ │ │ └── MajorMonoDisplay-Regular.ttf │ │ └── index.css │ ├── utils │ │ └── isElectron.js │ ├── components │ │ ├── Welcome │ │ │ ├── Progress.jsx │ │ │ ├── index.css │ │ │ └── index.jsx │ │ ├── Footer │ │ │ ├── heatmap.css │ │ │ ├── BarChart.jsx │ │ │ ├── HeatmapChart.jsx │ │ │ ├── EditTimer.jsx │ │ │ ├── Statistics.jsx │ │ │ └── Goals.jsx │ │ ├── Header │ │ │ └── index.jsx │ │ ├── Counter.jsx │ │ └── Controls.jsx │ ├── index.js │ ├── backend │ │ ├── index.js │ │ ├── IpcMain.js │ │ └── IpcRenderer.js │ └── App.jsx │ └── package.json ├── .prettierignore ├── .github ├── cover.fig ├── cover.png ├── cycles.gif ├── goals.gif ├── icons.fig ├── streak.gif ├── change_time.gif ├── statistics.gif ├── workTillNextHour.gif └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── prettier.config.js ├── appveyor.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── package.json ├── TESTCASES.md └── README.md /packages/core/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | dist -------------------------------------------------------------------------------- /packages/electron/preload.js: -------------------------------------------------------------------------------- 1 | window.ipcRenderer = require('electron').ipcRenderer 2 | -------------------------------------------------------------------------------- /.github/cover.fig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/cover.fig -------------------------------------------------------------------------------- /.github/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/cover.png -------------------------------------------------------------------------------- /.github/cycles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/cycles.gif -------------------------------------------------------------------------------- /.github/goals.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/goals.gif -------------------------------------------------------------------------------- /.github/icons.fig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/icons.fig -------------------------------------------------------------------------------- /.github/streak.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/streak.gif -------------------------------------------------------------------------------- /.github/change_time.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/change_time.gif -------------------------------------------------------------------------------- /.github/statistics.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/statistics.gif -------------------------------------------------------------------------------- /.github/workTillNextHour.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/.github/workTillNextHour.gif -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/electron/assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/assets/icon.ico -------------------------------------------------------------------------------- /packages/electron/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/assets/icon.png -------------------------------------------------------------------------------- /packages/electron/icons/idle.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/idle.ico -------------------------------------------------------------------------------- /packages/electron/icons/idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/idle.png -------------------------------------------------------------------------------- /packages/electron/assets/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/assets/128x128.png -------------------------------------------------------------------------------- /packages/electron/assets/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/assets/256x256.png -------------------------------------------------------------------------------- /packages/electron/assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/assets/icon.icns -------------------------------------------------------------------------------- /packages/electron/icons/pausing.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/pausing.ico -------------------------------------------------------------------------------- /packages/electron/icons/pausing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/pausing.png -------------------------------------------------------------------------------- /packages/electron/assets/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/assets/1024x1024.png -------------------------------------------------------------------------------- /packages/electron/icons/state/four.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/four.ico -------------------------------------------------------------------------------- /packages/electron/icons/state/four.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/four.png -------------------------------------------------------------------------------- /packages/electron/icons/state/one.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/one.ico -------------------------------------------------------------------------------- /packages/electron/icons/state/one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/one.png -------------------------------------------------------------------------------- /packages/electron/icons/state/two.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/two.ico -------------------------------------------------------------------------------- /packages/electron/icons/state/two.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/two.png -------------------------------------------------------------------------------- /packages/electron/icons/state/zero.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/zero.ico -------------------------------------------------------------------------------- /packages/electron/icons/state/zero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/zero.png -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/welcome/1.png -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/welcome/2.png -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/welcome/3.png -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/welcome/4.png -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/welcome/5.gif -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/welcome/6.png -------------------------------------------------------------------------------- /packages/electron/icons/state/three.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/three.ico -------------------------------------------------------------------------------- /packages/electron/icons/state/three.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/electron/icons/state/three.png -------------------------------------------------------------------------------- /packages/app/src/tests/__mocks__/ipcRenderer.js: -------------------------------------------------------------------------------- 1 | window.ipcRenderer = { 2 | on: jest.fn(), 3 | send: jest.fn(), 4 | once: jest.fn() 5 | } 6 | -------------------------------------------------------------------------------- /packages/app/public/assets/audio/notification.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/audio/notification.wav -------------------------------------------------------------------------------- /packages/app/public/assets/audio/notification-long.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/public/assets/audio/notification-long.wav -------------------------------------------------------------------------------- /packages/app/src/assets/fonts/Material-Icons-Round.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/src/assets/fonts/Material-Icons-Round.ttf -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | semi: false, 4 | singleQuote: true, 5 | trailingComma: 'none', 6 | jsxBracketSameLine: true 7 | } -------------------------------------------------------------------------------- /packages/app/src/assets/fonts/MajorMonoDisplay-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeziahMoselle/tempus/HEAD/packages/app/src/assets/fonts/MajorMonoDisplay-Regular.ttf -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tempus/core", 3 | "description": "Functions", 4 | "version": "1.7.0", 5 | "private": true, 6 | "main": "index.js" 7 | } 8 | -------------------------------------------------------------------------------- /packages/app/src/utils/isElectron.js: -------------------------------------------------------------------------------- 1 | function isElectron() { 2 | return navigator.userAgent.toLowerCase().indexOf(' electron/') > -1 3 | } 4 | 5 | module.exports = isElectron() 6 | -------------------------------------------------------------------------------- /packages/app/src/components/Welcome/Progress.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function Progress({ steps, currentStep }) { 4 | return ( 5 |
6 | {currentStep} / {steps} 7 |
8 | ) 9 | } 10 | 11 | export default Progress 12 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Visual Studio 2017 2 | 3 | platform: 4 | - x64 5 | 6 | cache: 7 | - node_modules 8 | - '%USERPROFILE%\.electron' 9 | 10 | install: 11 | - ps: Install-Product node 10 x64 12 | - yarn 13 | 14 | build_script: 15 | - yarn release 16 | 17 | test: off -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature:' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Solution 11 | The feature you want, describe where, how ? 12 | 13 | ## Alternatives 14 | Alternative if it's not possible 15 | -------------------------------------------------------------------------------- /packages/app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tempus 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | build 13 | dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* -------------------------------------------------------------------------------- /packages/app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App.jsx' 4 | import isElectron from './utils/isElectron' 5 | import { ipcRenderer } from './backend' 6 | 7 | if (!isElectron) { 8 | window.ipcRenderer = ipcRenderer 9 | } 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ) 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | - os: osx 4 | osx_image: xcode9.4 5 | language: node_js 6 | node_js: "12" 7 | 8 | - os: linux 9 | services: docker 10 | language: node_js 11 | node_js: "12" 12 | 13 | cache: 14 | directories: 15 | - node_modules 16 | - $HOME/.cache/electron 17 | - $HOME/.cache/electron-builder 18 | 19 | script: 20 | - yarn release 21 | 22 | branches: 23 | only: 24 | - master -------------------------------------------------------------------------------- /packages/app/src/backend/index.js: -------------------------------------------------------------------------------- 1 | import IpcRenderer from './IpcRenderer' 2 | import IpcMain from './IpcMain' 3 | 4 | const ipcMain = new IpcMain() 5 | const ipcRenderer = new IpcRenderer(ipcMain) 6 | 7 | ipcMain.on('handshake', event => { 8 | event.emit('handshake', { 9 | work: 60 * 25, 10 | pause: 60 * 5, 11 | sessionStreak: 5, 12 | numberOfCycle: 0, 13 | isDraggable: false, 14 | workTillDelayedMinutes: 0 15 | }) 16 | }) 17 | 18 | export { ipcMain, ipcRenderer } 19 | -------------------------------------------------------------------------------- /packages/electron/tests/App.test.js: -------------------------------------------------------------------------------- 1 | const Application = require('spectron').Application 2 | const electron = require('electron') 3 | const path = require('path') 4 | 5 | describe('Application', () => { 6 | const app = new Application({ 7 | path: electron, 8 | args: [path.join(__dirname, '..', '..', 'public', 'app.js')] 9 | }) 10 | 11 | it('should open a window', async () => { 12 | await app.start() 13 | const isVisible = await app.browserWindow.isVisible() 14 | isVisible.toBe(true) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | Steps to reproduce the behavior: 15 | 1. 16 | 17 | ## Expected behavior 18 | A clear and concise description of what you expected to happen. 19 | 20 | ## Screenshots 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | ## Platform 24 | MacOS ? Windows ? 25 | -------------------------------------------------------------------------------- /packages/electron/utils/getLatestVersion.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | const log = require('electron-log') 3 | const { version: currentVersion } = require('../package.json') 4 | 5 | const githubReleaseUrl = 6 | 'https://api.github.com/repos/KeziahMoselle/tempus/releases/latest' 7 | 8 | async function getLatestVersion() { 9 | try { 10 | const { body } = await got(githubReleaseUrl) 11 | const { name: latestVersion } = JSON.parse(body) 12 | 13 | return { 14 | currentVersion, 15 | latestVersion 16 | } 17 | } catch (error) { 18 | log.warn('Unable to check for latest versions.', error) 19 | } 20 | } 21 | 22 | module.exports = getLatestVersion 23 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tempus/app", 3 | "version": "1.7.0", 4 | "private": true, 5 | "homepage": "./", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "react-scripts start", 9 | "start:electron": "cross-env BROWSER=none react-scripts start", 10 | "build": "react-scripts build", 11 | "test": "react-scripts test" 12 | }, 13 | "dependencies": { 14 | "@tempus/core": "^1.7.0", 15 | "chart.js": "^2.7.3", 16 | "react-calendar-heatmap": "^1.8.0", 17 | "react-tooltip": "^3.9.2" 18 | }, 19 | "devDependencies": { 20 | "@testing-library/react": "^9.3.0", 21 | "react": "^16.12.0", 22 | "react-dom": "^16.12.0", 23 | "react-scripts": "^3.3.0" 24 | }, 25 | "browserslist": [ 26 | "last 2 Chrome versions" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /packages/electron/icons/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { nativeImage } = require('electron') 3 | 4 | let extension 5 | process.platform === 'win32' 6 | ? (extension = 'ico') // .ico on Win32 7 | : (extension = 'png') // .png on Darwin 8 | 9 | function getIcon(icon) { 10 | const iconPath = path.join(__dirname, `${icon}.${extension}`) 11 | return iconPath 12 | } 13 | 14 | function getStateIcon(icon) { 15 | const stateIconPath = path.join(__dirname, 'state', `${icon}.${extension}`) 16 | return stateIconPath 17 | } 18 | 19 | module.exports = { 20 | idle: getIcon('idle'), 21 | pausing: getIcon('pausing'), 22 | counting: getStateIcon('zero'), 23 | one: getStateIcon('one'), 24 | two: getStateIcon('two'), 25 | three: getStateIcon('three'), 26 | four: getStateIcon('four') 27 | } 28 | -------------------------------------------------------------------------------- /packages/app/src/backend/IpcMain.js: -------------------------------------------------------------------------------- 1 | class IpcMain { 2 | constructor() { 3 | this.channels = {} 4 | } 5 | 6 | on(channel, listener) { 7 | if (typeof this.channels[channel] !== 'object') { 8 | this.channels[channel] = [] 9 | } 10 | this.channels[channel].push(listener) 11 | return () => this.removeListener(channel, listener) 12 | } 13 | 14 | /** 15 | * Call functions from the IpcMain channels 16 | * 17 | * @param {*} channel 18 | * @param {*} data 19 | */ 20 | emit(sender, channel, data) { 21 | if (!this.channels[channel]) { 22 | return console.warn(`IpcMain: No listener on '${channel}'`) 23 | } 24 | 25 | this.channels[channel].forEach(listener => 26 | listener.call(this, sender, data) 27 | ) 28 | } 29 | } 30 | 31 | module.exports = IpcMain 32 | -------------------------------------------------------------------------------- /packages/electron/utils/notifyLatestVersion.js: -------------------------------------------------------------------------------- 1 | const { Notification, shell } = require('electron') 2 | 3 | const getLatestVersion = require('./getLatestVersion') 4 | 5 | async function latestVersionAvailable() { 6 | const { latestVersion, currentVersion } = await getLatestVersion() 7 | 8 | if (latestVersion !== currentVersion) { 9 | const notification = new Notification({ 10 | title: 'Tempus', 11 | body: 'Click to download the new version on the Website !' 12 | }) 13 | 14 | notification.show() 15 | 16 | notification.on('click', () => 17 | shell.openExternal( 18 | `https://tempus.keziahmoselle.fr/?from=${currentVersion}` 19 | ) 20 | ) 21 | } 22 | 23 | return { 24 | currentVersion, 25 | latestVersion 26 | } 27 | } 28 | 29 | module.exports = latestVersionAvailable 30 | -------------------------------------------------------------------------------- /packages/app/src/components/Footer/heatmap.css: -------------------------------------------------------------------------------- 1 | /* DEFAULT STYLING */ 2 | 3 | .react-calendar-heatmap text { 4 | font-size: 10px; 5 | fill: #aaa; 6 | } 7 | 8 | .react-calendar-heatmap .react-calendar-heatmap-small-text { 9 | font-size: 5px; 10 | } 11 | 12 | .react-calendar-heatmap .react-calendar-heatmap-weekday-label { 13 | transform: translateX(-6px); 14 | } 15 | 16 | .react-calendar-heatmap rect:hover { 17 | stroke: #555; 18 | stroke-width: 1px; 19 | } 20 | 21 | .react-calendar-heatmap .today { 22 | stroke: rgba(0, 0, 0, 0.3); 23 | stroke-width: 1px; 24 | } 25 | 26 | /* COLOR SCALE */ 27 | 28 | .react-calendar-heatmap .color-0 { 29 | fill: #eeeeee; 30 | } 31 | 32 | .react-calendar-heatmap .color-1 { 33 | fill: #dcefff; 34 | } 35 | 36 | .react-calendar-heatmap .color-2 { 37 | fill: #acd5f2; 38 | } 39 | 40 | .react-calendar-heatmap .color-3 { 41 | fill: #7fa8d1; 42 | } 43 | 44 | .react-calendar-heatmap .color-max { 45 | fill: #254e77; 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nemesis 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 | -------------------------------------------------------------------------------- /packages/app/src/tests/Welcome.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cleanup, render, fireEvent } from '@testing-library/react' 3 | import './__mocks__/ipcRenderer' 4 | import App from '../App' 5 | 6 | describe('Welcome guide', () => { 7 | afterEach(cleanup) 8 | 9 | it('shows the welcome screen', () => { 10 | const { getByText } = render() 11 | expect(getByText('or Skip')).toBeTruthy() 12 | }) 13 | 14 | it('shows the steps of the welcome screen', () => { 15 | const { getByText, getByAltText } = render() 16 | 17 | fireEvent.click(getByText('Discover')) 18 | expect(getByAltText('preview 1')).toBeTruthy() 19 | 20 | const nextBtn = getByText('Next') 21 | 22 | fireEvent.click(nextBtn) 23 | expect(getByAltText('preview 2')).toBeTruthy() 24 | 25 | fireEvent.click(nextBtn) 26 | expect(getByAltText('preview 3')).toBeTruthy() 27 | 28 | fireEvent.click(nextBtn) 29 | expect(getByAltText('preview 4')).toBeTruthy() 30 | 31 | fireEvent.click(nextBtn) 32 | expect(getByAltText('preview 5')).toBeTruthy() 33 | 34 | fireEvent.click(nextBtn) 35 | expect(getByAltText('preview 6')).toBeTruthy() 36 | 37 | fireEvent.click(nextBtn) 38 | expect(getByAltText(`Thank's`)).toBeTruthy() 39 | }) 40 | 41 | it('hides the welcome screen on skip', () => { 42 | const { getByText } = render() 43 | fireEvent.click(getByText('or Skip')) 44 | expect(getByText('play_arrow')).toBeTruthy() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /packages/app/src/components/Header/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import isElectron from '../../utils/isElectron' 3 | 4 | function Header({ allowDrag, sessionStreak, winRestore, winCompact, quit }) { 5 | return ( 6 |
7 |
0 ? 'in-a-row' : '' 10 | }`}> 11 |

12 | 13 | 🔥 14 | 15 | {sessionStreak} 16 |

17 |
18 | 19 |
20 | window.ipcRenderer.send('win-settings')} 22 | className="material-icons hidden-on-compacted"> 23 | settings 24 | 25 | {isElectron && ( 26 | window.ipcRenderer.send('win-minimize')} 28 | className="material-icons"> 29 | remove 30 | 31 | )} 32 | 33 | call_made 34 | 35 | 36 | call_received 37 | 38 | {isElectron && ( 39 | 40 | close 41 | 42 | )} 43 |
44 |
45 | ) 46 | } 47 | 48 | export default Header 49 | -------------------------------------------------------------------------------- /packages/app/src/backend/IpcRenderer.js: -------------------------------------------------------------------------------- 1 | class IpcRenderer { 2 | constructor(IpcMain) { 3 | this.channels = {} 4 | this.IpcMain = IpcMain 5 | } 6 | 7 | on(channel, listener) { 8 | if (typeof this.channels[channel] !== 'object') { 9 | this.channels[channel] = [] 10 | } 11 | this.channels[channel].push(listener) 12 | return () => this.removeListener(channel, listener) 13 | } 14 | 15 | removeListener(channel, listener) { 16 | if (typeof this.channels[channel] === 'object') { 17 | const idx = this.channels[channel].indexOf(listener) 18 | if (idx > -1) { 19 | this.channels[channel].splice(idx, 1) 20 | } 21 | } 22 | } 23 | 24 | once(channel, listener) { 25 | const remove = this.on(channel, (...args) => { 26 | remove() 27 | listener.apply(this, args) 28 | }) 29 | } 30 | 31 | send(channel, data) { 32 | this.IpcMain.emit(this, channel, data) 33 | } 34 | 35 | /** 36 | * Call functions from the IpcRenderer channels 37 | * 38 | * @param {*} channel 39 | * @param {*} data 40 | */ 41 | async emit(channel, data) { 42 | await this.waitEventLoop() 43 | 44 | if (!this.channels[channel]) { 45 | return console.warn(`IpcRenderer: No listener on '${channel}'`) 46 | } 47 | 48 | this.channels[channel].forEach(listener => listener.call(this, data)) 49 | } 50 | 51 | removeAllListeners(channel) { 52 | console.log('Remove all listeners ', channel) 53 | } 54 | 55 | waitEventLoop() { 56 | return Promise.resolve() 57 | } 58 | } 59 | 60 | module.exports = IpcRenderer 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tempus", 3 | "description": " A simple yet featureful pomodoro in the tray/menubar", 4 | "version": "1.7.0", 5 | "author": "Keziah Moselle (https://github.com/KeziahMoselle)", 6 | "private": true, 7 | "homepage": "./", 8 | "license": "MIT", 9 | "scripts": { 10 | "web": "yarn workspace @tempus/app start", 11 | "build:web": "yarn workspace @tempus/app build", 12 | "test:app": "yarn workspace @tempus/app test", 13 | "electron": "yarn workspace @tempus/electron start", 14 | "build:electron": "yarn workspace @tempus/electron build", 15 | "test:electron": "yarn workspace @tempus/electron test", 16 | "release": "yarn workspace @tempus/electron release", 17 | "format": "prettier packages/**/*.{js,jsx,json} --write" 18 | }, 19 | "workspaces": { 20 | "packages": [ 21 | "packages/*" 22 | ] 23 | }, 24 | "devDependencies": { 25 | "concurrently": "^5.0.1", 26 | "cross-env": "^5.2.0", 27 | "husky": "^3.1.0", 28 | "lint-staged": "^9.5.0", 29 | "prettier": "^1.19.1", 30 | "wait-on": "^3.3.0" 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged" 35 | } 36 | }, 37 | "lint-staged": { 38 | "packages/**/*.{js,jsx,json}": [ 39 | "prettier --write", 40 | "git add" 41 | ] 42 | }, 43 | "eslintConfig": { 44 | "extends": "react-app" 45 | }, 46 | "keywords": [ 47 | "pomodoro", 48 | "tray", 49 | "electron" 50 | ], 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/keziahmoselle/tempus.git" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/app/src/components/Footer/BarChart.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import Chart from 'chart.js' 3 | 4 | export default () => { 5 | const [isLoaded, setIsLoaded] = useState(false) 6 | 7 | useEffect(() => { 8 | window.ipcRenderer.send('getBarChartData') 9 | window.ipcRenderer.once('getBarChartData', (event, payload) => { 10 | setIsLoaded(true) 11 | new Chart('bar-chart', { 12 | type: 'bar', 13 | data: { 14 | datasets: [ 15 | { 16 | label: 'Minutes of work', 17 | data: payload, 18 | backgroundColor: '#A1D6FF', 19 | hoverBackgroundColor: '#87CBFF', 20 | borderColor: '#6EC0FF', 21 | borderWidth: 2 22 | } 23 | ] 24 | }, 25 | options: { 26 | legend: { display: false }, 27 | title: { 28 | display: true, 29 | text: 'Minutes of work in the last 7 days' 30 | }, 31 | scales: { 32 | xAxes: [ 33 | { 34 | type: 'time', 35 | time: { 36 | unit: 'day' 37 | } 38 | } 39 | ], 40 | yAxes: [ 41 | { 42 | ticks: { 43 | beginAtZero: true 44 | } 45 | } 46 | ] 47 | } 48 | } 49 | }) 50 | }) 51 | }, []) 52 | 53 | return ( 54 | <> 55 | {isLoaded && } 56 | 57 | {!isLoaded &&
Loading ...
} 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /packages/electron/utils/toCSV.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises 2 | const path = require('path') 3 | const { app, dialog, shell } = require('electron') 4 | const log = require('electron-log') 5 | const { data } = require('../store') 6 | 7 | const downloadPath = path.join(app.getPath('downloads'), 'tempus.csv') 8 | 9 | async function exportAsCsv() { 10 | const csvData = toCSV(data.get('data'), ['day', 'value', 'streak']) 11 | 12 | try { 13 | // Write the file 14 | await fs.writeFile(downloadPath, csvData) 15 | // Show the dialog information box 16 | const action = dialog.showMessageBox({ 17 | type: 'info', 18 | message: `Exported at ${downloadPath}`, 19 | buttons: ['Open Directory', 'Ok'] 20 | }) 21 | // 'Open Directory' Opens the 'Downloads' dir 22 | if (action === 0) { 23 | shell.openExternal(app.getPath('downloads')) 24 | } 25 | } catch (error) { 26 | // Show an error box 27 | const action = dialog.showMessageBox({ 28 | type: 'error', 29 | message: `${error}`, 30 | buttons: ['Open an issue', 'Ok'] 31 | }) 32 | // 'Open an issue' redirects to a new Github issue 33 | if (action === 0) { 34 | const url = new URL( 35 | `https://github.com/KeziahMoselle/tempus/issues/new?body=${error}` 36 | ).toString() 37 | // Open the link 38 | shell.openExternal(url) 39 | } 40 | log.warn(error) 41 | } 42 | } 43 | 44 | function toCSV(arr, columns) { 45 | return [ 46 | columns.join(','), 47 | ...arr.map(obj => 48 | columns.reduce( 49 | (acc, key) => 50 | `${acc}${!acc.length ? '' : ','}"${!obj[key] ? '0' : obj[key]}"`, 51 | '' 52 | ) 53 | ) 54 | ].join('\n') 55 | } 56 | 57 | module.exports = exportAsCsv 58 | -------------------------------------------------------------------------------- /packages/app/src/components/Footer/HeatmapChart.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import CalendarHeatmap from 'react-calendar-heatmap' 3 | import ReactTooltip from 'react-tooltip' 4 | 5 | export default () => { 6 | const [data, setData] = useState(null) 7 | 8 | useEffect(() => { 9 | window.ipcRenderer.send('getHeatmapChartData') 10 | window.ipcRenderer.once('getHeatmapChartData', (event, payload) => { 11 | setData(payload) 12 | }) 13 | }, []) 14 | 15 | const startDate = new Date() 16 | startDate.setMonth(startDate.getMonth() - 1, 1) 17 | const endDate = new Date() 18 | endDate.setMonth(endDate.getMonth() + 3, 1) 19 | 20 | return ( 21 | <> 22 | {data && ( 23 | <> 24 | { 30 | if (!value) return 'color-0' 31 | let classes = '' 32 | if (value.streak <= 3) classes = `color-${value.streak}` 33 | if (value.streak >= 4) classes = 'color-max' 34 | 35 | const today = new Date() 36 | .toISOString() 37 | .split('T')[0] 38 | .toString() 39 | if (value.date.toString() === today) classes += ' today' 40 | return classes 41 | }} 42 | tooltipDataAttrs={value => { 43 | if (!value.date) return { 'data-tip': 'No streak' } 44 | return { 45 | 'data-tip': `${new Date(value.date).toDateString()} : ${ 46 | value.value 47 | } min (Streak: ${value.streak})` 48 | } 49 | }} 50 | /> 51 | 52 | 53 | )} 54 | 55 | {!data &&
Loading...
} 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /packages/electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tempus/electron", 3 | "description": " A simple yet featureful pomodoro in the tray/menubar", 4 | "version": "1.7.0", 5 | "author": "Keziah Moselle (https://github.com/KeziahMoselle)", 6 | "private": true, 7 | "main": "app.js", 8 | "homepage": "./", 9 | "license": "MIT", 10 | "scripts": { 11 | "start": "concurrently \"yarn workspace @tempus/app start:electron\" \"wait-on http://localhost:3000 && electron app.js\"", 12 | "prebuild": "yarn workspace @tempus/app build && copyfiles -u 2 \"../app/build/**/*\" ./", 13 | "build": "electron-builder build", 14 | "release": "npm run prebuild && electron-builder build --publish always", 15 | "test": "jest" 16 | }, 17 | "dependencies": { 18 | "auto-launch": "^5.0.5", 19 | "electron-is-dev": "^1.2.0", 20 | "electron-log": "^4.2.4", 21 | "electron-positioner": "^4.1.0", 22 | "electron-store": "^6.0.0", 23 | "electron-updater": "^4.3.5", 24 | "got": "^11.7.0" 25 | }, 26 | "devDependencies": { 27 | "copyfiles": "^2.3.0", 28 | "electron": "10.1.2", 29 | "electron-builder": "^22.8.1", 30 | "electron-devtools-installer": "^3.1.1", 31 | "spectron": "^12.0.0" 32 | }, 33 | "build": { 34 | "linux": { 35 | "target": [ 36 | { 37 | "target": "deb", 38 | "arch": [ 39 | "x64" 40 | ] 41 | } 42 | ], 43 | "maintainer": "Keziah Moselle", 44 | "icon": "./assets/" 45 | }, 46 | "appId": "com.electron.tempus", 47 | "productName": "Tempus", 48 | "extends": null, 49 | "publish": [ 50 | { 51 | "provider": "github", 52 | "owner": "KeziahMoselle", 53 | "repo": "tempus" 54 | } 55 | ], 56 | "directories": { 57 | "buildResources": "./assets" 58 | }, 59 | "nsis": { 60 | "oneClick": false, 61 | "allowToChangeInstallationDirectory": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/app/src/components/Welcome/index.css: -------------------------------------------------------------------------------- 1 | .welcome .container { 2 | padding: 0 20px; 3 | } 4 | 5 | .welcome a { 6 | font-size: 18px; 7 | color: grey; 8 | padding: 0; 9 | } 10 | 11 | .welcome .sub-action { 12 | padding: 12px 32px; 13 | border-radius: 17px; 14 | background-color: white; 15 | color: black; 16 | text-decoration: none; 17 | margin: 0; 18 | transition: transform 0.2s, color 0.2s; 19 | } 20 | 21 | .welcome .sub-action:hover { 22 | padding: 12px 32px !important; 23 | transform: translateY(-4px); 24 | background-color: #2f88ff; 25 | color: white !important; 26 | } 27 | 28 | .welcome .sub-action.action { 29 | margin-top: 64px; 30 | padding: 8px 16px; 31 | background-color: #2f88ff; 32 | color: white; 33 | } 34 | 35 | .welcome .sub-action.action:hover { 36 | color: white; 37 | } 38 | 39 | .welcome img { 40 | border-radius: 6px; 41 | margin-top: 22px; 42 | width: 80%; 43 | } 44 | 45 | .welcome h6 { 46 | font-weight: 300; 47 | padding: 0 16px; 48 | text-align: center; 49 | } 50 | 51 | .welcome p { 52 | font-size: 17px !important; 53 | text-align: center; 54 | } 55 | 56 | .welcome .sub-action:hover { 57 | padding: 12px; 58 | color: black; 59 | } 60 | 61 | .welcome footer { 62 | height: 38px; 63 | display: flex; 64 | justify-content: space-between; 65 | align-items: center; 66 | } 67 | 68 | .welcome footer .progress { 69 | background-color: white; 70 | color: black; 71 | padding: 6px 16px; 72 | border-radius: 18px; 73 | margin-bottom: 8px; 74 | font-size: 16px; 75 | } 76 | 77 | .welcome footer button { 78 | background-color: black; 79 | color: white; 80 | } 81 | 82 | .welcome footer button:first-child { 83 | border-top-right-radius: 6px; 84 | } 85 | 86 | .welcome footer button:last-child { 87 | background-color: #2f88ff; 88 | color: white; 89 | border-top-left-radius: 6px; 90 | } 91 | 92 | .welcome footer button:hover { 93 | background-color: white !important; 94 | color: black !important; 95 | } 96 | 97 | .welcome p { 98 | font-size: 20px; 99 | } 100 | -------------------------------------------------------------------------------- /packages/app/src/components/Footer/EditTimer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default ({ 4 | setWork, 5 | setPause, 6 | resetTime, 7 | total, 8 | totalPause, 9 | setNumberOfCycle, 10 | numberOfCycle, 11 | setIsTimerChanged, 12 | workTillDelayedMinutes, 13 | setWorkTillDelayedMinutes 14 | }) => ( 15 |
16 |
17 |

Preferences

18 | 19 |
20 | 21 |
22 |

Time

23 | 24 |
25 | 26 |
27 | { 29 | setWork(event.target.value) 30 | setIsTimerChanged(true) 31 | }} 32 | id="work" 33 | type="number" 34 | min="1" 35 | value={total / 60} 36 | /> 37 | 38 |
min
39 |
40 |
41 | 42 |
43 | 44 |
45 | { 47 | setPause(event.target.value) 48 | setIsTimerChanged(true) 49 | }} 50 | id="pause" 51 | type="number" 52 | min="1" 53 | value={totalPause / 60} 54 | /> 55 | 56 |
min
57 |
58 |
59 |
60 | 61 |
62 |

Cycle

63 | 64 |
65 | 66 |
67 | { 69 | setNumberOfCycle(event.target.value) 70 | setIsTimerChanged(true) 71 | }} 72 | id="cycle" 73 | type="number" 74 | min="0" 75 | value={numberOfCycle} 76 | /> 77 | 78 |
cycles
79 |
80 |
81 |
82 | 83 |
84 |

Work till

85 | 86 |
87 | 88 |
89 | { 91 | setWorkTillDelayedMinutes(event.target.value) 92 | setIsTimerChanged(true) 93 | }} 94 | id="cycle" 95 | type="number" 96 | min="0" 97 | value={workTillDelayedMinutes} 98 | /> 99 | 100 |
min
101 |
102 |
103 |
104 |
105 | ) 106 | -------------------------------------------------------------------------------- /packages/app/src/components/Footer/Statistics.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | import BarChart from './BarChart' 4 | import HeatmapChart from './HeatmapChart' 5 | import Goals from './Goals' 6 | 7 | import './heatmap.css' 8 | 9 | export default ({ sessionStreak }) => { 10 | const [data, setData] = useState({ 11 | totalHoursOfWork: null, 12 | totalStreak: null, 13 | todayStreak: null, 14 | todayMinutes: null 15 | }) 16 | const [chartType, setChartType] = useState('bar') 17 | 18 | /** 19 | * Get data from the store 20 | */ 21 | 22 | useEffect(() => { 23 | window.ipcRenderer.send('getData') 24 | window.ipcRenderer.once('getData', (event, payload) => { 25 | setData({ 26 | totalHoursOfWork: payload.totalHoursOfWork, 27 | totalStreak: payload.totalStreak, 28 | todayStreak: payload.todayStreak, 29 | todayMinutes: payload.todayMinutes 30 | }) 31 | }) 32 | }, []) 33 | 34 | return ( 35 |
36 |
37 |
38 |

Today

39 |

40 | 41 | 🔥 42 | 43 | {data.todayStreak} 44 |

45 | 46 |

47 | 48 | ⏱️ 49 | 50 | {data.todayMinutes}m 51 |

52 |
53 | 54 |
55 |

Total

56 |

57 | 58 | 🔥 59 | 60 | {data.totalStreak} 61 |

62 | 63 |

64 | 65 | ⏱️ 66 | 67 | {data.totalHoursOfWork}h 68 |

69 |
70 |
71 | 72 |
73 |
74 | 79 | 80 | 85 | 86 | 91 |
92 | 93 | {chartType === 'bar' && } 94 | 95 | {chartType === 'heatmap' && } 96 | 97 | {chartType === 'Goals' && } 98 |
99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /packages/app/src/components/Counter.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react' 2 | 3 | export default ({ 4 | state, 5 | total, 6 | count, 7 | totalPause, 8 | countPause, 9 | windowStyle 10 | }) => { 11 | const [format, setFormat] = useState('percentage') 12 | 13 | useEffect(() => { 14 | window.ipcRenderer.send('getCounterData') 15 | 16 | window.ipcRenderer.once('getCounterData', (event, data) => { 17 | setFormat(data) 18 | }) 19 | }, []) 20 | 21 | let percentage 22 | let seconds 23 | if (state === 'counting') { 24 | seconds = count 25 | percentage = toPercentage(count, total) 26 | } else if (state === 'pausing') { 27 | seconds = countPause 28 | percentage = toPercentage(countPause, totalPause) 29 | } else { 30 | percentage = 0 31 | } 32 | 33 | function setPercentage() { 34 | setFormat('percentage') 35 | window.ipcRenderer.send('updateConfig', { 36 | format: 'percentage' 37 | }) 38 | } 39 | 40 | function setNumeric() { 41 | setFormat('numeric') 42 | window.ipcRenderer.send('updateConfig', { 43 | format: 'numeric' 44 | }) 45 | } 46 | 47 | function calculateSwapIconSpacing() { 48 | let secondaryTextWidth = 0 49 | if ( 50 | typeof document.getElementsByClassName('counter-display-secondary')[0] !== 51 | 'undefined' 52 | ) { 53 | secondaryTextWidth = document.getElementsByClassName( 54 | 'counter-display-secondary' 55 | )[0].clientWidth 56 | } 57 | return window.innerWidth / 2 + secondaryTextWidth / 2 + 40 58 | } 59 | 60 | const borderWidth = (percentage, style) => { 61 | if (style === 'compacted') { 62 | return percentage * 1.8 + 28 // 208px to fill (1.8 + 0.28) 63 | } 64 | return percentage * 2.78 + 56 // 334px to fill (2.78 + 0.56) 65 | } 66 | 67 | return ( 68 | 69 |
74 |
76 | format === 'percentage' ? setNumeric() : setPercentage() 77 | }> 78 |
82 | {percentage}% 83 |
84 |
88 | {`${ 89 | typeof seconds === 'undefined' 90 | ? `${Math.floor(total / 60)}m` 91 | : `${formatValue(seconds, total)}` 92 | }`} 93 |
94 |
100 | swap_vert 101 |
102 |
103 |
104 | ) 105 | } 106 | 107 | const toPercentage = (seconds, total) => 108 | parseInt(Math.round(((seconds / total) * 100).toFixed(0)), 10) 109 | const formatValue = (seconds, total) => { 110 | return seconds > 60 111 | ? Math.floor(seconds / 60).toFixed(0) + 112 | ':' + 113 | (seconds % 60).toString().padStart(2, '0') + 114 | 'm' 115 | : seconds + 's' 116 | } 117 | -------------------------------------------------------------------------------- /TESTCASES.md: -------------------------------------------------------------------------------- 1 | # Test Cases 2 | 3 | ### Welcome Guide 4 | 5 | Upon first starting the app, there should be a welcome guide that introduces the user to the application 6 | 7 | Scroll through the 7 steps and ensure that it exits to the main menu 8 | 9 | ### Minimize/Maximize 10 | 11 | At the top right, select the downward directing arrow 12 | 13 | Confirm the UI changes to exclude the settings cog, preferences, session count and analytics. 14 | 15 | Select the now upward facing arrow 16 | 17 | Confirm the UI changes to include the settings cog, preferences, session count and analytics. 18 | 19 | ### Settings 20 | 21 | At the top right, select the cog icon 22 | 23 | A drop down menu should appear with the options: Enable notifications, Enable launch at login, autohide window on start, auto show window on finish and enable drag window 24 | 25 | ##### Enable drag window (currently failing) 26 | 27 | Ensure you cannot drag the window around 28 | 29 | Select the option "Enable drag window" from the settinds drop down 30 | 31 | Ensure that the window is now movable 32 | 33 | ### Pomodoro Preferences 34 | 35 | At the bottom right, select the stopwatch icon 36 | 37 | Confirm that a menu pops up. 38 | 39 | Change the Work time option to 1 min (for next test) 40 | Change the Break time option to 1 min (for next test) 41 | 42 | Ensure that the Repeat option is set to 0 and that the user can increase the number of cycles by 2. 43 | 44 | Ensure that the user cannot decrease the number of cycles below 0 45 | 46 | Ensure that the "Add a delay" option is set to 0 and that the user can increase the number of cycles by 2. 47 | 48 | Ensure that the user cannot decrease the number of "Add a delay" below 0 49 | 50 | ### Start 51 | 52 | At the bottom, select the icon that looks like a play button 53 | 54 | Confirm the Pomodoro clock begins 55 | 56 | After one minute, ensure that a bell dings and the break timer starts 57 | 58 | After one minute, the break should end and the UI should return to normal 59 | 60 | ### Analytics 61 | 62 | At the bottom left, select the icon that looks like a bar graph 63 | 64 | Confirm a new menu pops up 65 | 66 | Find the box that is titled Total and ensure the number beside the flame icon is the same as the one at the very top left of the application 67 | 68 | Ensure there are three options to choose from: Week, Months and Goals 69 | 70 | When Week is selected, there should be a bar graph that displays the number of minutes worked. It should be greater than 1 after running the "Start" test 71 | 72 | When Months is selected, there should be a checkered graph that displays the days that the pomodoro clock was started. It should be highlighted on the current day. 73 | 74 | When Goals is selected, the user should be given the option to add a Goals 75 | 76 | ##### Add a Goal 77 | 78 | Select the plus icon on the left 79 | 80 | The user should be able to decrease the amount of work to .1, but no lower 81 | 82 | The user should be able to select the unit of time: day, week, month or year 83 | 84 | Select the blue button on the far right 85 | 86 | A new goal should appear 87 | 88 | Exit the menu 89 | 90 | ### Session Count 91 | 92 | At the top left, ensure there is a flame icon with a number beside it 93 | 94 | Start the pomodoro clock once again 95 | 96 | Ensure the number increases by 1 97 | 98 | ### Work-Till Functionality 99 | 100 | Directly above the start button, ensure there is an option to work until a certain time 101 | 102 | Push that button and ensure a pomodoro session starts 103 | 104 | Stop the session by selecting the start button again, which should now be a stop button 105 | -------------------------------------------------------------------------------- /packages/app/src/components/Footer/Goals.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | function Goals() { 4 | const [goals, setGoals] = useState([]) 5 | 6 | const [hour, setHour] = useState(1) 7 | const [type, setType] = useState('day') 8 | 9 | const [isShow, setIsShow] = useState(false) 10 | 11 | useEffect(() => { 12 | window.ipcRenderer.send('getGoalsData') 13 | 14 | window.ipcRenderer.on('getGoalsData', (event, payload) => { 15 | setGoals(payload.reverse()) 16 | }) 17 | 18 | window.ipcRenderer.on('refreshGoals', () => { 19 | window.ipcRenderer.send('getGoalsData') 20 | }) 21 | 22 | return () => { 23 | window.ipcRenderer.removeAllListeners('getGoalsData') 24 | window.ipcRenderer.removeAllListeners('refreshGoals') 25 | } 26 | }, []) 27 | 28 | function addGoal() { 29 | if (hour <= 0) return 30 | 31 | const isCreated = goals.findIndex(goal => { 32 | return goal.type === type && goal.value === hour * 60 33 | }) 34 | if (isCreated !== -1) return 35 | 36 | window.ipcRenderer.send('addGoal', { 37 | value: hour * 60, 38 | type 39 | }) 40 | 41 | // Hide input 42 | setIsShow(false) 43 | } 44 | 45 | function removeGoal(type, value) { 46 | window.ipcRenderer.send('removeGoal', { 47 | type, 48 | value 49 | }) 50 | } 51 | 52 | function toggleAddGoalCard() { 53 | isShow ? setIsShow(false) : setIsShow(true) 54 | } 55 | 56 | return ( 57 |
58 | 61 | 62 |
66 |
67 | 68 | setHour(event.target.value)} 70 | value={hour} 71 | type="number" 72 | placeholder="1" 73 | min="0.1" 74 | step="0.1"> 75 | hr 76 | 77 | 78 | 87 |
88 | 89 | 92 |
93 | 94 | {goals.length > 0 && 95 | goals.map((goal, index) => ( 96 |
101 | 102 | Work {goal.value / 60} hr per {goal.type} 103 | 104 | 105 | {Math.round((goal.currentValue / goal.value) * 100)}% 106 | 107 | 112 |
113 | ))} 114 | 115 | {goals.length === 0 && !isShow && ( 116 |
117 |

You do not have goals yet !

118 |

Click the button on the left to create your first goal

119 |
120 | )} 121 |
122 | ) 123 | } 124 | 125 | export default Goals 126 | -------------------------------------------------------------------------------- /packages/app/src/components/Controls.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import EditTimer from './Footer/EditTimer' 3 | import Statistics from './Footer/Statistics' 4 | 5 | export default ({ 6 | state, 7 | total, 8 | totalPause, 9 | start, 10 | stop, 11 | setWork, 12 | setPause, 13 | resetTime, 14 | sessionStreak, 15 | setNumberOfCycle, 16 | numberOfCycle, 17 | loadedConfig, 18 | setWorkTillDelayedMinutes, 19 | workTillDelayedMinutes 20 | }) => { 21 | // Is the footer full height or not ? (classname) 22 | const [isExtended, setIsExtended] = useState('') 23 | // Which component to display 24 | const [component, setComponent] = useState(null) 25 | // If a timer state has been changed 26 | const [isTimerChanged, setIsTimerChanged] = useState(false) 27 | 28 | function switchComponent(name) { 29 | setIsExtended('extended') 30 | if (name !== component) { 31 | setComponent(name) 32 | } else { 33 | setComponent(null) 34 | setIsExtended('') 35 | } 36 | } 37 | 38 | function saveAndStart() { 39 | window.ipcRenderer.send('updateConfig', { 40 | work: total, 41 | pause: totalPause, 42 | numberOfCycle: numberOfCycle 43 | }) 44 | setIsTimerChanged(false) 45 | 46 | start() 47 | } 48 | 49 | /** 50 | * Save the new values to the config store 51 | * Only when : 52 | * - The user leave the EditTimer component 53 | * - The config is loaded (local state is equal to the config store) 54 | * - The user has changed values in the EditTimer component 55 | */ 56 | useEffect(() => { 57 | if (component !== 'EditTimer' && loadedConfig && isTimerChanged) { 58 | window.ipcRenderer.send('updateConfig', { 59 | work: total, 60 | pause: totalPause, 61 | numberOfCycle: numberOfCycle, 62 | workTillDelayedMinutes: workTillDelayedMinutes 63 | }) 64 | setIsTimerChanged(false) 65 | } 66 | }, [ 67 | component, 68 | isTimerChanged, 69 | loadedConfig, 70 | numberOfCycle, 71 | total, 72 | totalPause, 73 | workTillDelayedMinutes 74 | ]) 75 | 76 | return ( 77 |
78 |
79 | 88 | 89 | 94 | 95 | 104 |
105 | 106 |
107 | {component === 'EditTimer' && ( 108 | 120 | )} 121 | 122 | {component === 'Statistics' && ( 123 | 124 | )} 125 |
126 |
127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /packages/app/src/components/Welcome/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Progress from './Progress' 3 | import './index.css' 4 | 5 | function Welcome({ finishedWelcome, quit }) { 6 | const [step, setStep] = useState(0) 7 | const maxStep = 7 8 | 9 | function decrementStep() { 10 | if (step > 0) { 11 | setStep(step - 1) 12 | } 13 | } 14 | 15 | function incrementStep() { 16 | if (step < maxStep) { 17 | setStep(step + 1) 18 | } 19 | } 20 | 21 | const stepView = [ 22 |
23 |

24 | Welcome{' '} 25 | 26 | 👋 27 | 28 |

29 |

Want to discover the great features ?

30 | 40 |
, 41 | 42 |
43 |
Want to work 27 min ? You can.
44 | preview 1 45 |
, 46 | 47 |
48 |
Want to work until 2 PM ?
49 |

50 | Note: It will revert your settings after the timer. 51 |

52 | preview 2 53 |
, 54 | 55 |
56 |
57 | Want to work at least 1 hour a day ? You can create goals for that. 58 |
59 | preview 3 60 |
, 61 | 62 |
63 |
64 | 65 | 🔥 66 | {' '} 67 | It counts how many times you finished a pomodoro. 68 |
69 | preview 4 70 |
, 71 | 72 |
73 |
It gives you insights about your productivity.
74 | preview 5 75 |
, 76 | 77 |
78 |
If set, it will automatically stop the pomodoro after `x` times.
79 | preview 6 80 |
, 81 | 82 |
83 |

88 | Be productive{' '} 89 | 90 | ❤️ 91 | 92 |

93 | Thank's 101 |
102 | ] 103 | 104 | return ( 105 |
106 |
107 |
108 | 109 | 🍝 110 | 111 |
112 | 113 |
114 | window.ipcRenderer.send('win-minimize')} 116 | className="material-icons"> 117 | remove 118 | 119 | 120 | close 121 | 122 |
123 |
124 | 125 |
{stepView[step]}
126 | 127 |
128 | 129 | 130 | 131 | 132 | 135 |
136 |
137 | ) 138 | } 139 | 140 | export default Welcome 141 | -------------------------------------------------------------------------------- /packages/electron/store.js: -------------------------------------------------------------------------------- 1 | const Store = require('electron-store') 2 | 3 | const [ISODate] = new Date().toISOString().split('T') // "yyyy-mm-dd" 4 | 5 | // Store configuration 6 | const config = new Store({ 7 | defaults: { 8 | work: 1500, // 25 minutes in sec 9 | pause: 300, // 5 minutes in sec 10 | numberOfCycle: 0, // Disable cycle by default 11 | format: 'percentage', // The counter format 12 | workTillDelayedMinutes: 0, // Delay for 'Work till' 13 | lastTimeUpdated: { 14 | ISODate: null, // The current date in ISO Format 15 | index: null // The current index of the data to mutate 16 | }, 17 | autoLaunch: false, // Launch the app on OS start 18 | autoHide: false, // Hide the window when the user click on the start btn 19 | autoShowOnFinish: false, // Show the window when the pomodoro is finished 20 | showNotifications: true, 21 | allowDrag: false, // Is the tray window draggable ? 22 | goals: [] 23 | } 24 | }) 25 | 26 | /** 27 | * 28 | * Store streak and informations 29 | * 30 | * Data structure : 31 | * { 32 | * "day": "yyyy-mm-dd", Record's date 33 | * "value": 0, Work time 34 | * "streak": 0 Streak 35 | * } 36 | * 37 | */ 38 | const data = new Store({ 39 | name: 'data', 40 | defaults: { 41 | data: [] 42 | } 43 | }) 44 | 45 | const localData = data.get('data') 46 | 47 | // Create a new key at startup 48 | if (localData.length === 0) { 49 | // If there is no key yet 50 | setNewKey(localData) 51 | } else if (localData[localData.length - 1].day !== ISODate) { 52 | // If the last key is not the same -> avoid duplicata 53 | setNewKey(localData) 54 | } 55 | 56 | if (localData.length >= 2) { 57 | // Fill potential empty dates 58 | fillEmptyDates(localData) 59 | } 60 | 61 | /** 62 | * Set a new key at startup 63 | * to mutate this object later 64 | * Also set the `lastTimeUpdated` config key 65 | * With the current date and the index to mutate 66 | * 67 | * @param {object} newData Local data 68 | */ 69 | function setNewKey(newData) { 70 | // Push the new item 71 | const index = newData.push({ 72 | day: ISODate, 73 | value: 0, 74 | streak: 0 75 | }) 76 | 77 | // Save it 78 | data.set('data', newData) 79 | 80 | // Edit config 81 | config.set('lastTimeUpdated', { 82 | ISODate, 83 | index: index - 1 84 | }) 85 | } 86 | 87 | /** 88 | * Mutate the object to save the user activity 89 | * 90 | * @param {number} timePassed Work time passed to add 91 | */ 92 | function updateData(timePassed) { 93 | const newData = data.get('data') 94 | const index = newData.length - 1 95 | 96 | // Mutate the object 97 | newData[index] = { 98 | day: ISODate, 99 | value: newData[index].value + timePassed, 100 | streak: newData[index].streak + 1 101 | } 102 | 103 | // Save it 104 | data.set('data', newData) 105 | } 106 | 107 | /** 108 | * It will fill empty objects between two dates 109 | * It fixes partial chart data 110 | * 111 | * @param {object} entries 112 | */ 113 | function fillEmptyDates(entries) { 114 | // Check for potential empty dates 115 | const lastEntry = entries[entries.length - 2].day 116 | // Yesterday because today already exists with `setNewKey()` 117 | const date = new Date() 118 | date.setDate(date.getDate() - 1) 119 | const [yesterday] = date.toISOString().split('T') 120 | 121 | // Cancel if no empty dates 122 | if (yesterday === lastEntry) return 123 | 124 | const firstDate = entries[0].day 125 | const lastDate = entries[entries.length - 1].day 126 | 127 | const dates = [ 128 | ...Array( 129 | Date.parse(lastDate) / 86400000 - Date.parse(firstDate) / 86400000 + 1 130 | ).keys() 131 | ].map( 132 | k => 133 | new Date(86400000 * k + Date.parse(firstDate)).toISOString().split('T')[0] 134 | ) 135 | 136 | const result = [] 137 | 138 | for (let i = 0, j = 0; i < dates.length; i++) { 139 | let hasSameKey = false 140 | if (dates[i] === entries[j].day) hasSameKey = true 141 | 142 | result[i] = { 143 | day: dates[i], 144 | value: hasSameKey ? entries[j].value : 0, 145 | streak: hasSameKey ? entries[j].streak : 0 146 | } 147 | 148 | if (hasSameKey) j++ 149 | } 150 | 151 | data.set('data', result) 152 | 153 | // Update the last index 154 | const newIndex = result.length - 1 155 | config.set('lastTimeUpdated.index', newIndex) 156 | } 157 | 158 | module.exports = { 159 | config, 160 | data, 161 | updateData 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | Tempus 7 |

8 | 9 |

10 | A simple yet featureful pomodoro in the tray/menubar 11 |

12 | 13 |

14 | 15 | Windows Release Badge 16 | 17 | 18 | 19 | MacOS Release Badge 20 | 21 | 22 | 23 | Version 24 | 25 | 26 | 27 | Last commit 28 | 29 | 30 | 31 | GitHub Downloads 32 | 33 | 34 |

35 | 36 |

37 | Tempus cover 38 |

39 | 40 |

41 | Download Last GitHub version 42 |

43 | 44 | ## Features 45 | 46 | Click on the arrows to get more informations about these features 47 | 48 |
49 | ⏱️ Change work time and pause time 50 | 51 |

Want to work 27 min ? You can.

52 |

53 | preview 54 |

55 |
56 | 57 |
58 | ⏲️ Automagically set the timer till the next hour 59 | 60 |

Want to work until 8 PM ? You can set the timer automagically for you. 61 |
Note: It will revert your settings after the timer. 62 |

63 |

64 | preview 65 |

66 |
67 | 68 |
69 | 🚩 Goals 70 | 71 |

Want to work at least 1 hour a day ? You can create a goal for that.

72 |

73 | preview 74 |

75 |
76 | 77 |
78 | 🔥 Streak 79 | 80 |

It counts how many times you finished a pomodoro.

81 |

82 | preview 83 |

84 |
85 | 86 |
87 | 📊 Statistics 88 | 89 |

It gives you insights about your productivity.

90 |

91 | preview 92 |

93 |
94 | 95 |
96 | 🔁 Cycles 97 | 98 |

If set, it will automatically stop the pomodoro after `x` times.

99 |

100 | preview 101 |

102 |
103 | 104 | ## Want to contribute ? 105 | 106 | ### Prerequisites 107 | * Have [Node.js](https://nodejs.org/en/) installed (> 8) 108 | * Have [Yarn](https://yarnpkg.com/en/) installed (> 1.4.2) 109 | 110 | ### Steps 111 | 112 | 1. Clone the repository 113 | ```sh 114 | $ git clone https://github.com/KeziahMoselle/tempus.git 115 | ``` 116 | 2. Create a new branch (i.e: feat-new-feature) 117 | 118 | 3. Install dependencies : 119 | ```sh 120 | $ cd tempus && yarn 121 | ``` 122 | 123 | 4. Run the app 124 | 125 | In a browser : 126 | ```sh 127 | $ yarn web 128 | ``` 129 | 130 | In Electron : 131 | ```sh 132 | $ yarn electron 133 | ``` 134 | 135 | ### Build 136 | 137 | To build the app (without Electron) you will need to run : 138 | 139 | The `/build` directory is in `packages/app/build` 140 | 141 | ```sh 142 | $ yarn build:app 143 | ``` 144 | 145 | To build the app with Electron you will need to run : 146 | 147 | The `/build` directory is in `packages/electron/dist` 148 | 149 | ```sh 150 | $ yarn build:electron 151 | ``` 152 | 153 | ### Tests 154 | 155 | Run tests for the application only : 156 | ```sh 157 | $ yarn test:app 158 | ``` 159 | 160 | Run tests for the Electron app : 161 | ```sh 162 | $ yarn test:electron 163 | ``` 164 | 165 | See [Test Cases (need to implement)](./TESTCASES.md) 166 | 167 | ### Project tree 168 | 169 | ``` 170 | |-- packages 171 | |-- app The React app 172 | | |-- build The build of the React app 173 | | |-- public .html and assets go here 174 | | |-- src React components and assets for the components 175 | |-- core Modules being shared between the browser and Node 176 | |-- electron 177 | |-- assets Assets like tray icons 178 | |-- dist The build of the electron app 179 | |-- tests Tests 180 | |-- utils Utility functions 181 | ``` 182 | 183 | ## Built With 184 | 185 | * [Electron](https://electronjs.org/) - framework for creating native applications with web technologies 186 | * [React](https://reactjs.org) - A JavaScript library for building user interfaces 187 | 188 | ## Contributors 189 | 190 | Thank you ❤️ 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | ## License 201 | 202 | This project is licensed under the [MIT License](LICENSE). 203 | -------------------------------------------------------------------------------- /packages/app/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import './assets/index.css' 3 | import Header from './components/Header' 4 | import Counter from './components/Counter' 5 | import Controls from './components/Controls' 6 | import Welcome from './components/Welcome/index' 7 | 8 | class App extends Component { 9 | state = { 10 | state: '', // Can be '', 'counting' or 'pausing' 11 | total: 1500, // Total of seconds for the counting interval 12 | count: 0, // Count the seconds to `total` 13 | totalPause: 300, // Total of seconds for the pausing interval 14 | countPause: 0, // Count the seconds for `totalPause` 15 | numberOfCycle: 0, // Repeat the counter `x` times (0 mean infinity) 16 | countCycle: 0, // Count the number of repetition 17 | sessionStreak: 0, // Count the streak this session 18 | loadedConfig: false, // Is the config has been fetched 19 | nextHour: null, 20 | workTillDelayedMinutes: 0, // Delayed minutes for 'Work till' (Allow working until 1.) 21 | shouldResetValues: { 22 | // Used for `workTillNearestHour()` 23 | shouldReset: false, // Should we revert the old values ? 24 | oldTotal: null, // Old value for total seconds 25 | oldCycle: null // Old value for cycle 26 | }, 27 | finishedWelcome: false, 28 | allowDrag: false, 29 | windowStyle: 'restored' // Can be 'compacted' or 'restored' 30 | } 31 | 32 | componentDidMount() { 33 | if (localStorage.getItem('finishedWelcome')) { 34 | this.setState({ 35 | finishedWelcome: true 36 | }) 37 | } 38 | 39 | // Listeners from the Tray menu 40 | window.ipcRenderer.on('start', () => { 41 | if (this.state.state === '') { 42 | this.start() 43 | } else { 44 | this.stop() 45 | } 46 | }) 47 | 48 | window.ipcRenderer.on('stop', () => { 49 | if (this.state.state) { 50 | this.stop() 51 | } 52 | }) 53 | // Send `handshake` event to receive new value from the store 54 | window.ipcRenderer.send('handshake') 55 | // Receive new values from the store 56 | window.ipcRenderer.once('handshake', (event, data) => { 57 | this.setState({ 58 | total: data.work, 59 | totalPause: data.pause, 60 | sessionStreak: data.sessionStreak, 61 | numberOfCycle: data.numberOfCycle, 62 | loadedConfig: true, 63 | allowDrag: data.isDraggable, 64 | workTillDelayedMinutes: data.workTillDelayedMinutes 65 | }) 66 | 67 | // Interval to update `this.state.nextHour` 68 | this.updateNextHour(data.workTillDelayedMinutes) 69 | }) 70 | } 71 | 72 | /** 73 | * Launch the counter 74 | * Creates the `countInterval` variable 75 | * Send `counting` event to the main process 76 | */ 77 | start = displayWorkNotification => { 78 | if (this.state.state === 'counting') return // If already counting return 79 | this.countInterval = setInterval(this.increment, 1000) 80 | this.setState({ 81 | state: 'counting' 82 | }) 83 | 84 | // Update icon at 25% 85 | this.timeout25 = setTimeout(() => { 86 | window.ipcRenderer.send('updateTrayIcon', 'one') 87 | }, this.state.total * 0.25 * 1000) 88 | 89 | // Update icon at 50% 90 | this.timeout50 = setTimeout(() => { 91 | window.ipcRenderer.send('updateTrayIcon', 'two') 92 | }, this.state.total * 0.5 * 1000) 93 | 94 | // Update icon at 75% 95 | this.timeout75 = setTimeout(() => { 96 | window.ipcRenderer.send('updateTrayIcon', 'three') 97 | }, this.state.total * 0.75 * 1000) 98 | 99 | // Update icon at 90% 100 | this.timeout90 = setTimeout(() => { 101 | window.ipcRenderer.send('updateTrayIcon', 'four') 102 | }, this.state.total * 0.9 * 1000) 103 | 104 | window.ipcRenderer.send('counting', displayWorkNotification) 105 | } 106 | 107 | /** 108 | * Triggered every 1s when `state.state` = counting 109 | * Increment the `state.count` 110 | * Create the `pauseInterval` when `state.count` > `state.total` 111 | */ 112 | increment = () => { 113 | if (this.state.count >= this.state.total) { 114 | // The work interval is finished 115 | this.stop() 116 | this.setState(prevState => ({ 117 | state: 'pausing', 118 | sessionStreak: prevState.sessionStreak + 1, 119 | countCycle: prevState.countCycle + 1 120 | })) 121 | 122 | /* Cycles */ 123 | // The maximum number of cycle has been reached 124 | if ( 125 | this.state.numberOfCycle > 0 && 126 | this.state.countCycle >= this.state.numberOfCycle 127 | ) { 128 | this.setState({ 129 | countCycle: 0 130 | }) 131 | 132 | // Get the old values back 133 | if (this.state.shouldResetValues.shouldReset) { 134 | this.revertValues() 135 | } 136 | 137 | // Display the notification 'You finished the pomodoro' 138 | window.ipcRenderer.send('finished') 139 | window.ipcRenderer.send('updateData', this.state.total / 60) 140 | 141 | new Audio('./assets/audio/notification-long.wav').play() 142 | 143 | return this.stop() 144 | } 145 | 146 | new Audio('./assets/audio/notification.wav').play() 147 | 148 | /* Streak */ 149 | window.ipcRenderer.send('updateData', this.state.total / 60) 150 | /* Set pause state */ 151 | window.ipcRenderer.send('pausing') 152 | /* Begin to count pause */ 153 | return (this.pauseInterval = setInterval(this.incrementPause, 1000)) 154 | } 155 | 156 | // Continue to increment the count variable 157 | this.setState(prevState => ({ 158 | count: prevState.count + 1 159 | })) 160 | } 161 | 162 | /** 163 | * Triggered every 1s when `state.isPause` = true 164 | * Increment the `state.countPause` 165 | * Switch to the `countInterval` 166 | */ 167 | incrementPause = () => { 168 | // Max value for `state.countPause` 169 | if (this.state.countPause >= this.state.totalPause) { 170 | new Audio('./assets/audio/notification.wav').play() 171 | this.stop() 172 | return this.start(true) // True to display the work notification 173 | } 174 | this.setState(prevState => ({ 175 | countPause: prevState.countPause + 1 176 | })) 177 | } 178 | 179 | /** 180 | * Work till the nearest hour 181 | */ 182 | workTillNearestHour = () => { 183 | const date = new Date() 184 | const minutes = date.getMinutes() // i.e 32 (min) 185 | const seconds = date.getSeconds() // i.e 16 (sec) 186 | const secondsOfWork = 187 | (60 - minutes + this.state.workTillDelayedMinutes) * 60 - seconds 188 | 189 | // Get old values to restore them later 190 | const { total, numberOfCycle } = this.state 191 | 192 | this.setState({ 193 | total: secondsOfWork, 194 | numberOfCycle: 1, 195 | shouldResetValues: { 196 | shouldReset: true, 197 | oldTotal: total, 198 | oldCycle: numberOfCycle 199 | } 200 | }) 201 | 202 | // Start the counter, but it will automatically stop after `secondsOfWork` seconds 203 | this.start() 204 | } 205 | 206 | updateNextHour = value => { 207 | const date = new Date() 208 | 209 | date.setHours(date.getHours() + 1) 210 | 211 | let minutesValue 212 | if (value === undefined) { 213 | minutesValue = this.state.workTillDelayedMinutes 214 | } else { 215 | minutesValue = value 216 | } 217 | date.setMinutes(minutesValue) 218 | 219 | const nextHour = date.toLocaleString('en-US', { 220 | hour: 'numeric', 221 | minute: '2-digit', 222 | hour12: true 223 | }) 224 | 225 | this.setState({ 226 | nextHour: nextHour 227 | }) 228 | 229 | const [, minutes] = new Date().toLocaleTimeString().split(':') // i.e 32 230 | 231 | // Run again the next hour to update the UI 232 | this.updateNextHourInterval = setInterval( 233 | this.updateNextHour, 234 | 1000 * 60 * (60 - minutes) 235 | ) 236 | } 237 | 238 | /** 239 | * Will revert old values after `workTillNearestHour` 240 | */ 241 | revertValues = () => { 242 | const { oldTotal, oldCycle } = this.state.shouldResetValues 243 | // Restore old values 244 | this.setState({ 245 | total: oldTotal, 246 | numberOfCycle: oldCycle, 247 | shouldResetValues: { 248 | shouldReset: false 249 | } 250 | }) 251 | } 252 | 253 | /** 254 | * Clear all intervals 255 | * Clear the state 256 | * Send `idle` event to the main process 257 | */ 258 | stop = isManual => { 259 | clearInterval(this.countInterval) 260 | clearInterval(this.pauseInterval) 261 | 262 | clearTimeout(this.timeout25) 263 | clearTimeout(this.timeout50) 264 | clearTimeout(this.timeout75) 265 | clearTimeout(this.timeout90) 266 | 267 | this.setState({ 268 | state: '', 269 | count: 0, 270 | countPause: 0 271 | }) 272 | 273 | if (isManual && this.state.shouldResetValues.shouldReset) { 274 | this.revertValues() 275 | 276 | // Display the notification 'You finished the pomodoro' 277 | // If it's manual -> don't show 278 | window.ipcRenderer.send('finished', isManual) 279 | } 280 | 281 | if (isManual) { 282 | // Cancel the pausing timeout 'You must work during...' 283 | window.ipcRenderer.send('pausing', isManual) 284 | } 285 | 286 | window.ipcRenderer.send('idle') 287 | } 288 | 289 | resetTime = () => { 290 | this.setState({ 291 | total: 1500, 292 | totalPause: 300, 293 | numberOfCycle: 0 294 | }) 295 | window.ipcRenderer.send('updateConfig', { 296 | work: 1500, 297 | pause: 300, 298 | numberOfCycle: 0 299 | }) 300 | } 301 | 302 | /** 303 | * Set a new value for work time 304 | */ 305 | setWork = minutes => { 306 | const seconds = parseInt(minutes) * 60 307 | if (!seconds) return 308 | this.setState({ 309 | total: seconds 310 | }) 311 | } 312 | 313 | /** 314 | * Set a new value for pause time 315 | */ 316 | setPause = minutes => { 317 | const seconds = parseInt(minutes) * 60 318 | if (!seconds) return 319 | this.setState({ 320 | totalPause: seconds 321 | }) 322 | } 323 | 324 | /** 325 | * Set a new value for numberOfCycle 326 | */ 327 | setNumberOfCycle = newValue => { 328 | if (newValue < 0) return 329 | this.setState({ 330 | numberOfCycle: parseInt(newValue, 10) 331 | }) 332 | } 333 | 334 | /** 335 | * Set a new value for workTillDelayedMinutes 336 | */ 337 | setWorkTillDelayedMinutes = newValue => { 338 | if (newValue < 0) return 339 | if (newValue > 59) return 340 | 341 | clearInterval(this.updateNextHourInterval) 342 | 343 | this.setState({ 344 | workTillDelayedMinutes: parseInt(newValue, 10) 345 | }) 346 | 347 | this.updateNextHour(parseInt(newValue, 10)) 348 | } 349 | 350 | /** 351 | * Set the style of the window 352 | */ 353 | winCompact = () => { 354 | window.ipcRenderer.send('win-compact') 355 | this.setState({ 356 | windowStyle: 'compacted' 357 | }) 358 | } 359 | 360 | winRestore = () => { 361 | window.ipcRenderer.send('win-restore') 362 | this.setState({ 363 | windowStyle: 'restored' 364 | }) 365 | } 366 | 367 | /** 368 | * Show a confirmation dialog before quit the app 369 | */ 370 | quit = () => { 371 | window.ipcRenderer.send('win-close') 372 | } 373 | 374 | finishedWelcome = () => { 375 | this.setState({ 376 | finishedWelcome: true 377 | }) 378 | localStorage.setItem('finishedWelcome', true) 379 | } 380 | 381 | render() { 382 | if (this.state.finishedWelcome) { 383 | return ( 384 |
385 |
392 | 393 | 401 | 402 |
406 | Or work till {this.state.nextHour}. 407 |
408 | 409 | 426 |
427 | ) 428 | } else { 429 | return 430 | } 431 | } 432 | } 433 | 434 | export default App 435 | -------------------------------------------------------------------------------- /packages/app/src/assets/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Major Mono Display'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local('Major Mono Display'), 6 | url(./fonts/MajorMonoDisplay-Regular.ttf) format('truetype'); 7 | } 8 | 9 | @font-face { 10 | font-family: 'Material Icons'; 11 | font-style: normal; 12 | font-weight: 400; 13 | src: url(./fonts/Material-Icons-Round.ttf) format('truetype'); 14 | } 15 | 16 | .material-icons { 17 | font-family: 'Material Icons'; 18 | font-weight: normal; 19 | font-style: normal; 20 | font-size: 24px; 21 | display: inline-block; 22 | line-height: 1; 23 | text-transform: none; 24 | letter-spacing: normal; 25 | word-wrap: normal; 26 | white-space: nowrap; 27 | direction: ltr; 28 | -webkit-font-smoothing: antialiased; 29 | text-rendering: optimizeLegibility; 30 | -moz-osx-font-smoothing: grayscale; 31 | font-feature-settings: 'liga'; 32 | } 33 | 34 | .material-icons.md-18 { 35 | font-size: 18px; 36 | } 37 | .material-icons.md-24 { 38 | font-size: 24px; 39 | } 40 | .material-icons.md-36 { 41 | font-size: 36px; 42 | } 43 | .material-icons.md-48 { 44 | font-size: 48px; 45 | } 46 | 47 | html, 48 | body, 49 | #root, 50 | .container { 51 | height: 100%; 52 | } 53 | 54 | body { 55 | margin: 0; 56 | padding: 0; 57 | background-color: black; 58 | color: white; 59 | font-size: 2em; 60 | font-family: sans-serif; 61 | user-select: none; 62 | overflow: hidden; 63 | } 64 | 65 | .container { 66 | display: flex; 67 | flex-direction: column; 68 | justify-content: center; 69 | align-items: center; 70 | } 71 | 72 | /** 73 | * 74 | * TYPOGRAPHY 75 | * 76 | */ 77 | 78 | .mono { 79 | font-family: 'Major Mono Display'; 80 | } 81 | 82 | h1, 83 | h2, 84 | h3, 85 | h4, 86 | h5, 87 | h6, 88 | p { 89 | margin: 0; 90 | padding: 0; 91 | } 92 | 93 | .etched { 94 | text-shadow: 0 2px rgba(255, 255, 255, 0.8); 95 | font-size: 1.3rem; 96 | font-weight: bold; 97 | color: #b8bec5; 98 | } 99 | 100 | /** 101 | * 102 | * FLEX 103 | * 104 | */ 105 | 106 | .flex { 107 | display: flex; 108 | } 109 | 110 | .space-between { 111 | justify-content: space-between; 112 | } 113 | 114 | .center { 115 | display: flex; 116 | justify-content: center; 117 | } 118 | 119 | .column { 120 | flex-direction: column; 121 | } 122 | 123 | .valign { 124 | align-items: center; 125 | } 126 | 127 | /** 128 | * 129 | * TITLEBAR 130 | * 131 | */ 132 | 133 | .titlebar { 134 | z-index: 2; 135 | position: absolute; 136 | top: 0; 137 | right: 0; 138 | width: 100%; 139 | display: flex; 140 | justify-content: space-between; 141 | align-items: center; 142 | } 143 | 144 | .titlebar .streak { 145 | padding: 6px 14px; 146 | font-weight: 600; 147 | font-size: 0.6em; 148 | background-color: white; 149 | color: black; 150 | border-bottom-right-radius: 6px; 151 | transition: background-color 0.2s, color 0.2s; 152 | } 153 | 154 | .titlebar .streak.in-a-row { 155 | background-color: #ff694f; 156 | color: white; 157 | } 158 | 159 | .titlebar .streak span { 160 | margin-right: 5px; 161 | } 162 | 163 | .titlebar .controls { 164 | -webkit-app-region: no-drag; 165 | background-color: transparent; 166 | border-bottom-left-radius: 4px; 167 | } 168 | 169 | .titlebar.is-draggable { 170 | -webkit-app-region: drag; 171 | } 172 | 173 | .titlebar i { 174 | text-align: center; 175 | width: 46px; 176 | padding: 6px; 177 | cursor: pointer; 178 | transition: background-color 0.2s, border 0.2s; 179 | } 180 | 181 | .titlebar i:hover { 182 | background-color: rgba(255, 255, 255, 0.2); 183 | } 184 | 185 | .titlebar i.danger:hover { 186 | background-color: #ff5f56; 187 | } 188 | 189 | /** 190 | * 191 | * COUNTER 192 | * 193 | */ 194 | 195 | .counter { 196 | position: absolute; 197 | bottom: -107px; 198 | height: 0; 199 | width: 0; 200 | border: 0 solid #2f88ff; 201 | border-radius: 50%; 202 | margin-bottom: 56px; 203 | transition: border 0.2s ease; 204 | z-index: 1; 205 | } 206 | 207 | .counter.counting { 208 | border-color: #2f88ff; 209 | } 210 | 211 | .counter.pausing { 212 | border-color: #14b07b; 213 | } 214 | 215 | .counter-display { 216 | z-index: 3; 217 | position: absolute; 218 | left: 50%; 219 | transform: translate(-50%, -50%); 220 | font-family: 'Major Mono Display'; 221 | margin: 0; 222 | cursor: pointer; 223 | transition: top 0.5s, font-size 0.5s, color 0.5s, left 0.5s; 224 | } 225 | 226 | .counter-display-main { 227 | top: 50%; 228 | font-size: 1.8em; 229 | } 230 | 231 | .counter-display-secondary, 232 | .counter-display-swap { 233 | top: 60%; 234 | font-size: 1em; 235 | color: rgba(255, 255, 255, 0.4); 236 | } 237 | 238 | .counter-display-swap:hover { 239 | color: rgba(255, 255, 255, 0.7); 240 | } 241 | 242 | .counter.counting ~ .sub-action { 243 | z-index: 1; 244 | } 245 | 246 | .sub-action { 247 | color: rgba(255, 255, 255, 0.3); 248 | font-size: 16px; 249 | cursor: pointer; 250 | transition: padding 0.2s, border-radius 0.2s, background-color 0.2s, 251 | color 0.2s, opacity 0.2s; 252 | } 253 | 254 | .sub-action:hover { 255 | color: rgba(255, 255, 255, 0.7); 256 | padding: 8px 12px; 257 | border-radius: 17px; 258 | background-color: rgba(255, 255, 255, 0.2); 259 | } 260 | 261 | .sub-action.active { 262 | color: rgba(255, 255, 255, 0.7); 263 | border-radius: 17px; 264 | } 265 | 266 | .sub-action.counting, 267 | .sub-action.pausing { 268 | opacity: 0; 269 | } 270 | 271 | /** 272 | * 273 | * FOOTER 274 | * 275 | */ 276 | 277 | footer { 278 | z-index: 4; 279 | height: 56px; 280 | width: 100%; 281 | position: absolute; 282 | bottom: 0; 283 | transition: height 0.2s; 284 | } 285 | 286 | footer.extended { 287 | height: 90%; 288 | } 289 | 290 | /* FOOTER HEADER */ 291 | 292 | .footer-header { 293 | position: relative; 294 | display: flex; 295 | justify-content: space-around; 296 | height: 56px; 297 | width: 100%; 298 | background-color: white; 299 | border-top-left-radius: 16px; 300 | border-top-right-radius: 16px; 301 | transition: height 0.2s; 302 | } 303 | 304 | .footer-header button { 305 | height: 56px; 306 | width: 56px; 307 | } 308 | 309 | /* FOOTER CONTENT */ 310 | 311 | .footer-content { 312 | height: 90%; 313 | width: 100%; 314 | color: black; 315 | background-color: rgba(255, 255, 255, 0.98); 316 | box-shadow: inset 0px 2px 2px 0px rgba(0, 0, 0, 0.1); 317 | } 318 | 319 | .footer-content button { 320 | border-radius: 6px; 321 | } 322 | 323 | /* EDIT TIMER */ 324 | 325 | .timer-container { 326 | padding: 26px; 327 | } 328 | 329 | .timer-container header { 330 | display: flex; 331 | align-items: center; 332 | justify-content: space-between; 333 | margin-bottom: 30px; 334 | } 335 | 336 | .timer-container h3 { 337 | font-size: 0.8em; 338 | } 339 | 340 | .timer-container .field { 341 | display: flex; 342 | justify-content: space-between; 343 | align-items: center; 344 | margin: 0 16px 6px; 345 | } 346 | 347 | .timer-container label { 348 | font-size: 0.6em; 349 | font-family: sans-serif; 350 | color: rgb(51, 51, 51); 351 | } 352 | 353 | .timer-container input[type='number'] { 354 | height: 36px; 355 | width: 50px; 356 | padding-left: 10px; 357 | font-size: 0.7em; 358 | border-top-left-radius: 6px; 359 | border-bottom-left-radius: 6px; 360 | border: 1px solid rgba(0, 0, 0, 0.2); 361 | } 362 | 363 | .timer-container .has-suffix { 364 | display: flex; 365 | justify-content: flex-end; 366 | align-items: center; 367 | } 368 | 369 | .timer-container input[type='number'] + .suffix { 370 | height: 38px; 371 | width: 100%; 372 | padding: 0 6px; 373 | font-size: 0.7em; 374 | text-align: center; 375 | line-height: 38px; 376 | background-color: #f3f3f3; 377 | border-top-right-radius: 6px; 378 | border-bottom-right-radius: 6px; 379 | border: 1px solid rgba(0, 0, 0, 0.2); 380 | border-left: none; 381 | } 382 | 383 | .timer-container input:focus { 384 | outline: none; 385 | } 386 | 387 | .timer-container .card { 388 | margin-bottom: 26px; 389 | } 390 | 391 | /** 392 | * 393 | * STATISTICS 394 | * 395 | */ 396 | 397 | .statistics-container { 398 | display: flex; 399 | flex-direction: column; 400 | padding: 26px; 401 | } 402 | 403 | .statistics-container .cards { 404 | display: flex; 405 | justify-content: space-evenly; 406 | width: 100%; 407 | margin-bottom: 18px; 408 | } 409 | 410 | .statistics-container .cards .card { 411 | display: flex; 412 | flex-direction: column; 413 | width: 40%; 414 | } 415 | 416 | .chart-container { 417 | height: 100%; 418 | width: 100%; 419 | } 420 | 421 | .chart-container .center { 422 | margin-bottom: 18px; 423 | } 424 | 425 | .chart-container .center button { 426 | margin: 0 6px; 427 | } 428 | 429 | /** 430 | * 431 | * GOALS 432 | * 433 | */ 434 | 435 | .goals { 436 | height: 220px; 437 | overflow-y: overlay; 438 | overflow-x: hidden; 439 | padding: 0 20px; 440 | } 441 | 442 | ::-webkit-scrollbar { 443 | width: 6px; 444 | } 445 | ::-webkit-scrollbar-thumb { 446 | background: #e1e1e1; 447 | border: 0px none #ffffff; 448 | border-radius: 32px; 449 | } 450 | ::-webkit-scrollbar-track { 451 | background: transparent; 452 | border: 0px none #ffffff; 453 | border-radius: 31px; 454 | } 455 | 456 | .goals .card { 457 | padding: 12px 16px; 458 | margin-bottom: 12px; 459 | } 460 | 461 | .goals .card.success { 462 | border-left: 6px solid #14b07b; 463 | } 464 | 465 | .goals .card.in-progress { 466 | border-left: 6px solid #ffbb23; 467 | } 468 | 469 | .goals .card span { 470 | font-size: 0.7em; 471 | } 472 | 473 | .goals .card span.success { 474 | color: #14b07b; 475 | } 476 | 477 | .goals .card span.in-progress { 478 | color: #ffbb23; 479 | } 480 | 481 | .goals .card:not(.goals-add) button.circle { 482 | position: absolute; 483 | height: 16px; 484 | width: 16px; 485 | right: 0; 486 | top: 0; 487 | transition: opacity 0.2s, background-color 0.2s, color 0.2s; 488 | opacity: 0; 489 | background: transparent; 490 | } 491 | 492 | .goals .card.goals-add button.circle { 493 | position: absolute; 494 | right: -10px; 495 | top: 10px; 496 | } 497 | 498 | .goals .card.goals-add button.circle:hover { 499 | background-color: white; 500 | border: 1px solid black; 501 | } 502 | 503 | .goals .card:not(.goals-add):hover button.circle { 504 | opacity: 1; 505 | } 506 | 507 | .goals .card:not(.goals-add) button.circle:hover { 508 | color: #ff5f56; 509 | } 510 | 511 | .goals .circle.add { 512 | position: absolute; 513 | border: 2px solid black; 514 | background-color: white; 515 | height: 32px; 516 | width: 32px; 517 | color: black; 518 | left: 6px; 519 | transition: filter 0.2s; 520 | margin-top: 12px; 521 | } 522 | 523 | .goals .circle.add:hover { 524 | filter: brightness(70%); 525 | } 526 | 527 | /* GOALS ADD INPUT */ 528 | 529 | .goals-add { 530 | border-left: 6px solid #2f88ff !important; 531 | height: 26px; 532 | } 533 | 534 | .goals-add label { 535 | font-size: 0.65em; 536 | margin-right: 6px; 537 | } 538 | 539 | .goals-add label ~ span { 540 | font-size: 0.65em; 541 | margin: 0 6px; 542 | } 543 | 544 | .goals-add input[type='number'] { 545 | width: 50px; 546 | text-align: center; 547 | } 548 | 549 | .goals-add input, 550 | select { 551 | font-size: 0.6em; 552 | border: none; 553 | border-bottom: 1px solid black; 554 | } 555 | 556 | .goals-add select { 557 | padding-bottom: 3px; 558 | } 559 | 560 | /* NO GOALS */ 561 | 562 | .no-goals { 563 | display: flex; 564 | flex-direction: column; 565 | justify-content: center; 566 | height: 130px; 567 | } 568 | 569 | .no-goals p { 570 | text-align: center; 571 | margin-bottom: 12px; 572 | } 573 | 574 | .no-goals p:not(.etched) { 575 | font-size: 1.3rem; 576 | } 577 | 578 | /** 579 | * 580 | * CARDS 581 | * 582 | */ 583 | 584 | .card { 585 | position: relative; 586 | padding: 8px; 587 | border-radius: 4px; 588 | background-color: white; 589 | border: 1px solid #d1d5da; 590 | padding-top: 18px; 591 | } 592 | 593 | .card > h3 { 594 | position: absolute; 595 | top: -13px; 596 | font-size: 0.7em; 597 | background-color: white; 598 | padding: 0 2px; 599 | margin: 0 -2px; 600 | } 601 | 602 | .card-item { 603 | padding: 8px 16px; 604 | font-size: 0.7em; 605 | border-radius: 6px; 606 | } 607 | 608 | .card-item span[role='img'] { 609 | margin-right: 8px; 610 | } 611 | 612 | /** 613 | * 614 | * BUTTONS 615 | * 616 | */ 617 | 618 | button { 619 | display: flex; 620 | align-items: center; 621 | font-size: 0.6em; 622 | border: none; 623 | background-color: white; 624 | transition: background-color 0.2s; 625 | cursor: pointer; 626 | padding: 8px 16px; 627 | } 628 | 629 | button.circle { 630 | justify-content: center; 631 | padding: 0; 632 | height: 28px; 633 | width: 28px; 634 | border-radius: 50%; 635 | background-color: rgb(238, 238, 238); 636 | } 637 | 638 | button:not(.overlap):hover { 639 | background-color: rgba(0, 0, 0, 0.1); 640 | color: black; 641 | } 642 | 643 | button[disabled] { 644 | cursor: default; 645 | background-color: transparent !important; 646 | color: black !important; 647 | } 648 | 649 | button:focus { 650 | outline: none; 651 | } 652 | 653 | .overlap { 654 | position: absolute; 655 | top: -26px; 656 | left: 50%; 657 | transform: translateX(-50%); 658 | color: black; 659 | border-radius: 50%; 660 | border-top: 1px solid rgba(255, 255, 255, 0.4); 661 | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 662 | 0 1px 5px 0 rgba(0, 0, 0, 0.2); 663 | } 664 | 665 | button.counting { 666 | background-color: #2f88ff; 667 | color: white; 668 | } 669 | 670 | button.pausing { 671 | background-color: #14b07b; 672 | color: white; 673 | } 674 | 675 | /** 676 | * 677 | * STATES 678 | * 679 | */ 680 | 681 | .active { 682 | background-color: rgba(0, 0, 0, 0.1); 683 | } 684 | 685 | .selected { 686 | border: 1px solid black; 687 | } 688 | 689 | .hide { 690 | display: none !important; 691 | } 692 | 693 | /** 694 | * 695 | * WINDOW STYLES 696 | * 697 | */ 698 | 699 | .window-compacted .hidden-on-compacted, 700 | .window-restored .hidden-on-restored { 701 | visibility: hidden; 702 | } 703 | 704 | .window-compacted .remove-on-compacted, 705 | .window-restored .remove-on-restored { 706 | display: none; 707 | } 708 | 709 | .window-compacted .titlebar { 710 | z-index: 5; 711 | } 712 | 713 | .window-compacted .counter { 714 | bottom: initial; 715 | top: calc(50% + 6px); 716 | transform: translateY(-50%); 717 | } 718 | 719 | .window-compacted .counter-display { 720 | z-index: 6; 721 | } 722 | 723 | .window-compacted .counter-display-main { 724 | left: 22%; 725 | top: 54%; 726 | font-size: 1em; 727 | } 728 | 729 | .window-compacted .counter-display-secondary { 730 | left: 78%; 731 | top: 56%; 732 | font-size: 0.8em; 733 | } 734 | 735 | .window-compacted footer { 736 | height: 0%; 737 | transition: height 0s; 738 | } 739 | 740 | .window-compacted .footer-header { 741 | background-color: transparent; 742 | } 743 | 744 | .window-compacted .footer-header .overlap { 745 | top: -72px; 746 | } 747 | -------------------------------------------------------------------------------- /packages/app/public/assets/welcome/cat.svg: -------------------------------------------------------------------------------- 1 | playful cat -------------------------------------------------------------------------------- /packages/electron/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * MODULES 4 | * 5 | */ 6 | 7 | const url = require('url') 8 | const path = require('path') 9 | 10 | const { 11 | BrowserWindow, 12 | dialog, 13 | Menu, 14 | Notification, 15 | Tray, 16 | app, 17 | ipcMain, 18 | shell, 19 | globalShortcut 20 | } = require('electron') 21 | const Positioner = require('electron-positioner') 22 | const isDev = require('electron-is-dev') 23 | const AutoLaunch = require('auto-launch') 24 | const log = require('electron-log') 25 | const getLatestVersion = require('./utils/getLatestVersion') 26 | const notifyLatestVersion = require('./utils/notifyLatestVersion') 27 | const toCSV = require('./utils/toCSV') 28 | 29 | const { config, data, updateData } = require('./store') 30 | const icons = require('./icons') 31 | 32 | let tray 33 | let trayWindow 34 | let positioner 35 | const autoLauncher = new AutoLaunch({ name: 'tempus' }) 36 | 37 | app.setAppUserModelId('com.electron.tempus') 38 | 39 | /* Create the application */ 40 | app.on('ready', () => { 41 | createTray() 42 | createWindow() 43 | checkForUpdates() 44 | registerGlobalShortcuts() 45 | }) 46 | 47 | /* Change icon on idle */ 48 | 49 | ipcMain.on('idle', () => { 50 | tray.setImage(icons.idle) 51 | }) 52 | 53 | /* Change icon on counting + notification */ 54 | 55 | ipcMain.on('counting', (event, displayWorkNotification) => { 56 | tray.setImage(icons.counting) 57 | if (config.get('autoHide')) { 58 | trayWindow.hide() 59 | } 60 | 61 | if (displayWorkNotification) { 62 | showNotification(`You must work during ${config.get('work') / 60} minutes`) 63 | } 64 | }) 65 | 66 | ipcMain.on('updateTrayIcon', (event, iconName) => { 67 | tray.setImage(icons[iconName]) 68 | }) 69 | 70 | /* Change icon on pausing + notification */ 71 | 72 | ipcMain.on('pausing', (event, isManual) => { 73 | tray.setImage(icons.pausing) 74 | const pauseTime = config.get('pause') / 60 75 | 76 | if (!isManual) { 77 | showNotification(`You have a break of ${pauseTime} minutes.`) 78 | } 79 | }) 80 | 81 | /* 82 | * When the max number of cycle has been reached, 83 | * Show a notification 84 | */ 85 | 86 | ipcMain.on('finished', (event, isManual) => { 87 | if (!isManual) { 88 | showNotification('You finished the pomodoro !') 89 | } 90 | 91 | if (config.get('autoShowOnFinish')) { 92 | trayWindow.show() 93 | } 94 | }) 95 | 96 | /* 97 | * When the React App is loaded 98 | * Update the default state of the React App with the config 99 | */ 100 | 101 | ipcMain.on('handshake', event => { 102 | const currentDayIndex = config.get('lastTimeUpdated.index') 103 | const storeData = data.get('data') 104 | 105 | // default value 106 | let todayStreak = 0 107 | 108 | // Get streak if it exists 109 | if (storeData[currentDayIndex]) { 110 | todayStreak = storeData[currentDayIndex].streak 111 | } 112 | 113 | event.sender.send('handshake', { 114 | work: config.get('work'), 115 | pause: config.get('pause'), 116 | sessionStreak: todayStreak, 117 | numberOfCycle: config.get('numberOfCycle'), 118 | isDraggable: config.get('allowDrag'), 119 | workTillDelayedMinutes: config.get('workTillDelayedMinutes') 120 | }) 121 | }) 122 | 123 | /* Set new values in the config */ 124 | 125 | ipcMain.on('updateConfig', (event, data) => { 126 | const work = config.get('work') 127 | const pause = config.get('pause') 128 | const numberOfCycle = config.get('numberOfCycle') 129 | const workTillDelayedMinutes = config.get('workTillDelayedMinutes') 130 | const format = config.get('format') 131 | 132 | if (data.work && work !== data.work) config.set('work', data.work) 133 | if (data.pause && pause !== data.pause) config.set('pause', data.pause) 134 | if (data.numberOfCycle && numberOfCycle !== data.numberOfCycle) 135 | config.set('numberOfCycle', data.numberOfCycle) 136 | if (data.workTillDelayedMinutes || data.workTillDelayedMinutes === 0) { 137 | if (workTillDelayedMinutes !== data.workTillDelayedMinutes) 138 | config.set('workTillDelayedMinutes', data.workTillDelayedMinutes) 139 | } 140 | if (data.format && format !== data.format) config.set('format', data.format) 141 | }) 142 | 143 | /** 144 | * 145 | * STREAK 146 | * 147 | */ 148 | 149 | /* Send data for charts */ 150 | 151 | ipcMain.on('getData', event => { 152 | const currentDayIndex = config.get('lastTimeUpdated.index') 153 | const storeData = data.get('data') 154 | 155 | /* Calculate the total worktime */ 156 | const minutesOfWork = storeData.reduce((accumulator, currentValue) => { 157 | return accumulator + currentValue.value 158 | }, 0) 159 | const totalHoursOfWork = (minutesOfWork / 60).toFixed(1) 160 | 161 | /* Calculate the total streak */ 162 | const totalStreak = storeData.reduce((accumulator, currentValue) => { 163 | return accumulator + currentValue.streak 164 | }, 0) 165 | 166 | /* Streak */ 167 | let todayStreak = 0 // default value 168 | let todayMinutes = 0 // default value 169 | if (storeData[currentDayIndex]) { 170 | // Get streak if it exists 171 | todayStreak = storeData[currentDayIndex].streak 172 | todayMinutes = Math.round(storeData[currentDayIndex].value) 173 | } 174 | 175 | event.sender.send('getData', { 176 | totalHoursOfWork, 177 | totalStreak, 178 | todayStreak, 179 | todayMinutes 180 | }) 181 | }) 182 | 183 | /* Data for the Bar chart */ 184 | 185 | ipcMain.on('getBarChartData', event => { 186 | const payload = data 187 | .get('data') 188 | .slice(-7) 189 | .map(object => ({ 190 | t: new Date(object.day).toLocaleDateString('en-US'), 191 | y: object.value 192 | })) 193 | event.sender.send('getBarChartData', payload) 194 | }) 195 | 196 | /* Data for the Heatmap chart */ 197 | 198 | ipcMain.on('getHeatmapChartData', event => { 199 | const payload = data.get('data').map(object => ({ 200 | date: object.day, 201 | value: object.value, 202 | streak: object.streak 203 | })) 204 | 205 | event.sender.send('getHeatmapChartData', payload) 206 | }) 207 | 208 | /* Data for counter */ 209 | 210 | ipcMain.on('getCounterData', event => { 211 | event.sender.send('getCounterData', config.get('format')) 212 | }) 213 | 214 | /* Data for Goals */ 215 | 216 | ipcMain.on('addGoal', (event, { type, value }) => { 217 | // Edit goals config 218 | const goalsConfig = config.get('goals') 219 | goalsConfig.push({ 220 | type, 221 | value 222 | }) 223 | 224 | // Save 225 | config.set('goals', goalsConfig) 226 | 227 | // Refresh the app 228 | event.sender.send('refreshGoals') 229 | }) 230 | 231 | ipcMain.on('removeGoal', (event, { type, value }) => { 232 | const goalsConfig = config.get('goals') 233 | 234 | // Find the index to remove 235 | const index = goalsConfig.findIndex(goal => { 236 | return goal.type === type && goal.value === value 237 | }) 238 | 239 | // Not found 240 | if (index === -1) return 241 | 242 | // Remove 243 | goalsConfig.splice(index, 1) 244 | 245 | // Save 246 | config.set('goals', goalsConfig) 247 | 248 | // Update the UI 249 | event.sender.send('refreshGoals') 250 | }) 251 | 252 | ipcMain.on('getGoalsData', event => { 253 | const goalsCreated = config.get('goals') // [ { type: 'day', value: 60 } ] 254 | const localData = data.get('data') 255 | const types = { 256 | day: 1, 257 | week: 7, 258 | month: 31, 259 | year: 365 260 | } 261 | 262 | let payload = [] 263 | 264 | goalsCreated.forEach(goal => { 265 | // Number of days to fetch 266 | const days = types[goal.type] 267 | // Fetch the x days 268 | const daysData = localData.slice(`-${days}`) 269 | // Return the total of minutes in x days 270 | const totalMinutes = daysData.reduce((accumulator, currentValue) => { 271 | return accumulator + currentValue.value 272 | }, 0) 273 | // Compare the value of the goal and the minutes of work 274 | 275 | // If superior -> The goal is achieved 276 | // If inferior -> The goal is not achieved 277 | let isSuccess 278 | totalMinutes >= goal.value ? (isSuccess = true) : (isSuccess = false) 279 | 280 | payload.push({ 281 | ...goal, 282 | currentValue: totalMinutes, 283 | success: isSuccess 284 | }) 285 | }) 286 | 287 | event.sender.send('getGoalsData', payload) 288 | }) 289 | 290 | /* Store the streak and time */ 291 | 292 | ipcMain.on('updateData', (event, timePassed) => updateData(timePassed)) 293 | 294 | /* Window events */ 295 | 296 | ipcMain.on('win-minimize', () => { 297 | trayWindow.hide() 298 | if (process.platform === 'darwin') { 299 | app.dock.hide() 300 | } 301 | }) 302 | 303 | ipcMain.on('win-compact', () => { 304 | trayWindow.setBounds({ height: 100 }) 305 | positioner.move(getTrayPosition(), tray.getBounds()) 306 | }) 307 | 308 | ipcMain.on('win-restore', () => { 309 | trayWindow.setBounds({ height: 550 }) 310 | positioner.move(getTrayPosition(), tray.getBounds()) 311 | }) 312 | 313 | ipcMain.on('win-close', async () => { 314 | const result = await showConfirmationBox('Do you really want to quit ?') 315 | 316 | if (result.response === 0) { 317 | app.quit() 318 | } 319 | }) 320 | 321 | /** 322 | * 323 | * FUNCTIONS 324 | * 325 | */ 326 | 327 | function createWindow() { 328 | trayWindow = new BrowserWindow({ 329 | width: 400, 330 | height: 550, 331 | resizable: false, 332 | movable: config.get('allowDrag'), 333 | fullscreenable: false, 334 | alwaysOnTop: true, 335 | icon: icons.idle, 336 | show: false, 337 | frame: false, 338 | backgroundColor: '#000000', 339 | webPreferences: { 340 | nodeIntegration: false, 341 | contextIsolation: false, 342 | preload: path.join(__dirname, 'preload.js') 343 | } 344 | }) 345 | if (isDev) { 346 | // DEVELOPMENT Load the CRA server 347 | trayWindow.loadURL('http://localhost:3000/') 348 | } else { 349 | // PRODUCTION Load the React build 350 | trayWindow.loadURL( 351 | url.format({ 352 | protocol: 'file', 353 | slashes: true, 354 | pathname: path.join(__dirname, 'build', 'index.html') 355 | }) 356 | ) 357 | } 358 | 359 | positioner = new Positioner(trayWindow) 360 | positioner.move(getTrayPosition(), tray.getBounds()) 361 | 362 | if (isDev) { 363 | const { 364 | default: installExtension, 365 | REACT_DEVELOPER_TOOLS 366 | } = require('electron-devtools-installer') 367 | 368 | app.whenReady() 369 | .then(() => { 370 | installExtension(REACT_DEVELOPER_TOOLS) 371 | .then((name) => { 372 | console.log(`Added Extension: ${name}`) 373 | trayWindow.webContents.openDevTools() 374 | }) 375 | .catch((err) => console.log('An error occurred: ', err)); 376 | }) 377 | } 378 | 379 | trayWindow.on('ready-to-show', () => trayWindow.show()) 380 | } 381 | 382 | function getTrayPosition() { 383 | if (process.platform === 'win32') { 384 | return 'trayBottomCenter' 385 | } else if (process.platform === 'darwin') { 386 | return 'trayCenter' 387 | } else { 388 | return 'trayRight' 389 | } 390 | } 391 | 392 | function createTray() { 393 | tray = new Tray(icons.idle) 394 | tray.setToolTip('Tempus, click to open') 395 | tray.on('click', () => toggleWindow()) 396 | updateContextMenu() 397 | } 398 | 399 | function updateContextMenu(options) { 400 | let versionItem 401 | 402 | if (options && options.version) { 403 | const currentVersion = options.version.currentVersion 404 | const latestVersion = options.version.latestVersion 405 | const newVersionAvailable = currentVersion !== latestVersion 406 | 407 | versionItem = { 408 | label: newVersionAvailable 409 | ? `v${currentVersion} (latest: ${latestVersion})` 410 | : `v${currentVersion} (up-to-date)`, 411 | sublabel: newVersionAvailable 412 | ? 'Click to download latest version' 413 | : undefined, 414 | enabled: newVersionAvailable ? true : false, 415 | click() { 416 | shell.openExternal( 417 | `https://tempus.keziahmoselle.fr/?from=${currentVersion}` 418 | ) 419 | } 420 | } 421 | } else { 422 | versionItem = { 423 | label: 'Fetching latest release...', 424 | enabled: false 425 | } 426 | } 427 | 428 | const settings = () => ([ 429 | { 430 | type: 'checkbox', 431 | checked: config.get('showNotifications'), 432 | label: 'Enable notifications', 433 | click(event) { 434 | config.set('showNotifications', event.checked) 435 | } 436 | }, 437 | { 438 | type: 'checkbox', 439 | checked: config.get('autoLaunch'), 440 | label: 'Enable Launch At Login', 441 | click(event) { 442 | toggleAutoLaunch(event.checked) 443 | } 444 | }, 445 | { 446 | type: 'checkbox', 447 | checked: config.get('autoHide'), 448 | label: 'Auto hide window on start', 449 | click(event) { 450 | config.set('autoHide', event.checked) 451 | } 452 | }, 453 | { 454 | type: 'checkbox', 455 | checked: config.get('autoShowOnFinish'), 456 | label: 'Auto show window on finish', 457 | click(event) { 458 | config.set('autoShowOnFinish', event.checked) 459 | } 460 | }, 461 | { 462 | type: 'checkbox', 463 | checked: config.get('allowDrag'), 464 | label: 'Enable drag window (restart)', 465 | click(event) { 466 | if ( 467 | showConfirmationBox('Do you want to restart to apply changes ?') === 0 468 | ) { 469 | config.set('allowDrag', event.checked) 470 | app.relaunch() 471 | app.quit() 472 | } 473 | } 474 | } 475 | ]) 476 | 477 | const actions = [ 478 | { 479 | label: 'Export to CSV', 480 | click() { 481 | toCSV() 482 | } 483 | }, 484 | { 485 | label: 'Delete data', 486 | click() { 487 | const action = dialog.showMessageBox({ 488 | type: 'warning', 489 | message: 490 | 'This action will delete all your statistics. Are you sure ?', 491 | buttons: ['Delete', 'Cancel'] 492 | }) 493 | 494 | if (action === 0) { 495 | data.set('data', []) 496 | dialog.showMessageBox({ 497 | type: 'info', 498 | message: 'Your data has been deleted.' 499 | }) 500 | } 501 | } 502 | } 503 | ] 504 | 505 | const menuTemplate = [ 506 | { 507 | label: 'Show/Hide...', 508 | click() { 509 | toggleWindow() 510 | }, 511 | accelerator: 'CmdOrCtrl+O' 512 | }, 513 | { type: 'separator' }, 514 | { 515 | label: '▶ Start', 516 | click() { 517 | trayWindow.webContents.send('start') 518 | } 519 | }, 520 | { 521 | label: '■ Stop', 522 | click() { 523 | trayWindow.webContents.send('stop') 524 | } 525 | }, 526 | { type: 'separator' }, 527 | { 528 | label: 'Settings', 529 | submenu: settings() 530 | }, 531 | { 532 | label: 'Actions', 533 | submenu: [...actions] 534 | }, 535 | { type: 'separator' }, 536 | versionItem, 537 | { 538 | label: 'Feedback && Support...', 539 | click() { 540 | shell.openExternal('https://github.com/KeziahMoselle/tempus/issues/new') 541 | } 542 | }, 543 | { 544 | label: 'Quit', 545 | click() { 546 | app.quit() 547 | }, 548 | accelerator: 'CmdOrCtrl+Q' 549 | } 550 | ] 551 | const contextMenu = Menu.buildFromTemplate(menuTemplate) 552 | 553 | if (process.platform === 'darwin') { 554 | const appMenu = Menu.buildFromTemplate([ 555 | { 556 | label: 'Tempus', 557 | submenu: [ 558 | menuTemplate[0], 559 | menuTemplate[1], 560 | menuTemplate[2], 561 | menuTemplate[3], 562 | menuTemplate[4], 563 | menuTemplate[8], 564 | menuTemplate[9], 565 | menuTemplate[10] 566 | ] 567 | }, 568 | { 569 | label: 'Settings', 570 | submenu: settings() 571 | }, 572 | { 573 | label: 'Actions', 574 | submenu: [...actions] 575 | } 576 | ]) 577 | Menu.setApplicationMenu(appMenu) 578 | 579 | tray.on('right-click', () => { 580 | tray.popUpContextMenu(contextMenu) 581 | }) 582 | } else { 583 | tray.setContextMenu(contextMenu) 584 | tray.on('right-click', () => tray.popUpContextMenu()) 585 | } 586 | 587 | ipcMain.on('win-settings', () => { 588 | tray.popUpContextMenu(Menu.buildFromTemplate(settings())) 589 | }) 590 | } 591 | 592 | function toggleWindow() { 593 | if (trayWindow.isVisible()) { 594 | trayWindow.hide() 595 | if (process.platform === 'darwin') { 596 | app.dock.hide() 597 | } 598 | } else { 599 | trayWindow.show() 600 | if (process.platform === 'darwin') { 601 | app.dock.show() 602 | } 603 | } 604 | } 605 | 606 | function toggleAutoLaunch(isEnabled) { 607 | isEnabled ? autoLauncher.enable() : autoLauncher.disable() 608 | config.set('autoLaunch', isEnabled) 609 | } 610 | 611 | function showNotification(body) { 612 | if (config.get('showNotifications')) { 613 | new Notification({ 614 | title: 'Tempus', 615 | icon: process.platform === 'win32' ? icons.idle : null, 616 | body: body 617 | }).show() 618 | } 619 | } 620 | 621 | async function showConfirmationBox(message) { 622 | const dialogOptions = { 623 | type: 'info', 624 | buttons: ['Confirm', 'Cancel'], 625 | message 626 | } 627 | 628 | return dialog.showMessageBox(dialogOptions) 629 | } 630 | 631 | async function checkForUpdates() { 632 | let currentVer 633 | let latestVer 634 | 635 | if (process.platform === 'darwin') { 636 | const { currentVersion, latestVersion } = await notifyLatestVersion() 637 | currentVer = currentVersion 638 | latestVer = latestVersion 639 | } else { 640 | const { autoUpdater } = require('electron-updater') 641 | const { currentVersion, latestVersion } = await getLatestVersion() 642 | currentVer = currentVersion 643 | latestVer = latestVersion 644 | autoUpdater.checkForUpdatesAndNotify() 645 | } 646 | 647 | updateContextMenu({ 648 | version: { 649 | currentVersion: currentVer, 650 | latestVersion: latestVer 651 | } 652 | }) 653 | } 654 | 655 | function registerGlobalShortcuts() { 656 | // Global Shortcut : Toggle Window 657 | const shortcutToggleWindow = globalShortcut.register('Super+Alt+Up', () => { 658 | toggleWindow() 659 | }) 660 | if (!shortcutToggleWindow) { 661 | log.warn('Unable to register: Super+Alt+Up') 662 | } 663 | 664 | // Global Shortcut : Toggle Counting/Stop 665 | const shortcutToggleState = globalShortcut.register('Super+Alt+Down', () => { 666 | trayWindow.webContents.send('start') 667 | }) 668 | if (!shortcutToggleState) { 669 | log.warn('Unable to register: Super+Alt+Down') 670 | } 671 | } 672 | --------------------------------------------------------------------------------