├── 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 |
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 && {
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 | Reset
19 |
20 |
21 |
22 |
Time
23 |
24 |
25 |
Work time
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 |
Break time
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 |
Repeat
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 |
Add a delay
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 | setChartType('bar')}
76 | className={chartType === 'bar' ? 'selected' : ''}>
77 | Week
78 |
79 |
80 | setChartType('heatmap')}
82 | className={chartType === 'heatmap' ? 'selected' : ''}>
83 | Months
84 |
85 |
86 | setChartType('Goals')}
88 | className={chartType === 'Goals' ? 'selected' : ''}>
89 | Goals
90 |
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 |
59 | {isShow ? '⨯' : '+'}
60 |
61 |
62 |
66 |
67 | Work
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 | per
78 | setType(event.target.value)}
81 | value={type}>
82 | day
83 | week
84 | month
85 | year
86 |
87 |
88 |
89 |
90 | 🗸
91 |
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 | removeGoal(goal.type, goal.value)}>
110 | x
111 |
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 | switchComponent('Statistics')}
81 | className={`hidden-on-compacted ${
82 | component === 'Statistics' ? 'active' : ''
83 | }`}>
84 |
85 | {component !== 'Statistics' ? 'bar_chart' : 'close'}
86 |
87 |
88 |
89 | (!state ? saveAndStart() : stop(true))}
91 | className={`overlap ${state}`}>
92 | {!state ? 'play_arrow' : 'stop'}
93 |
94 |
95 | switchComponent('EditTimer')}
97 | className={`hidden-on-compacted ${
98 | component === 'EditTimer' ? 'active' : ''
99 | }`}>
100 |
101 | {component !== 'EditTimer' ? 'timer' : 'close'}
102 |
103 |
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 |
45 |
,
46 |
47 |
48 |
Want to work until 2 PM ?
49 |
50 | Note: It will revert your settings after the timer.
51 |
52 |
53 |
,
54 |
55 |
56 |
57 | Want to work at least 1 hour a day ? You can create goals for that.
58 |
59 |
60 |
,
61 |
62 |
63 |
64 |
65 | 🔥
66 | {' '}
67 | It counts how many times you finished a pomodoro.
68 |
69 |
70 |
,
71 |
72 |
73 |
It gives you insights about your productivity.
74 |
75 |
,
76 |
77 |
78 |
If set, it will automatically stop the pomodoro after `x` times.
79 |
80 |
,
81 |
82 |
83 |
88 | Be productive{' '}
89 |
90 | ❤️
91 |
92 |
93 |
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 | Prev
129 |
130 |
131 |
132 |
133 | {step < maxStep ? 'Next' : 'Start'}
134 |
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 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
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 |
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 |
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 |
74 |
75 |
76 |
77 |
78 | 🔥 Streak
79 |
80 | It counts how many times you finished a pomodoro.
81 |
82 |
83 |
84 |
85 |
86 |
87 | 📊 Statistics
88 |
89 | It gives you insights about your productivity.
90 |
91 |
92 |
93 |
94 |
95 |
96 | 🔁 Cycles
97 |
98 | If set, it will automatically stop the pomodoro after `x` times.
99 |
100 |
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 |
--------------------------------------------------------------------------------