tuidoro 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/assets/screenshot1.png +0 -0
- package/assets/tuidoro_chime.mp3 +0 -0
- package/assets/tuidoro_toggle.mp3 +0 -0
- package/config/settings.json +13 -0
- package/package.json +46 -0
- package/src/index.ts +121 -0
- package/src/layout.ts +142 -0
- package/src/logger.ts +27 -0
- package/src/timer.ts +183 -0
- package/src/types.ts +21 -0
- package/src/utils.test.ts +59 -0
- package/src/utils.ts +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Barnabas Edubio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# TUIdoro
|
|
2
|
+
|
|
3
|
+
TUIdoro is a sleek pomodoro timer that runs in your terminal.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
### Via npx / bunx (requires bun > 1.0.0)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx tuidoro (requires 'bun' to be in PATH)
|
|
13
|
+
bunx tuidoro
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Via AUR (contains Bun runtime (~100MB))
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
yay -S aur/tuidoro
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Run locally (requires bun > 1.0.0)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun install
|
|
26
|
+
bun run build
|
|
27
|
+
./dist/tuidoro
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
The priority for reading the configuration is as follows:
|
|
33
|
+
|
|
34
|
+
1. `$TUIDORO_SETTINGS_PATH` environment variable
|
|
35
|
+
2. `~/.config/tuidoro/settings.json`
|
|
36
|
+
|
|
37
|
+
If the configuration file does not exist TUIdoro will launch with its [defaults](./config/settings.json).
|
|
38
|
+
|
|
39
|
+
## Contributing
|
|
40
|
+
|
|
41
|
+
Pull requests and issues are welcome! I do however plan on keeping this TUI as simple as possible.
|
|
42
|
+
Feel free to fork this project and extend the TUI to fit your own personal needs.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
[MIT](./LICENSE)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"workDuration": 25,
|
|
3
|
+
"shortBreakDuration": 5,
|
|
4
|
+
"longBreakDuration": 15,
|
|
5
|
+
"longBreakAfter": 4,
|
|
6
|
+
"workColor": "#cccccc",
|
|
7
|
+
"shortBreakColor": "#a7f3d0",
|
|
8
|
+
"longBreakColor": "#a78bfa",
|
|
9
|
+
"zenMode": false,
|
|
10
|
+
"sound": true,
|
|
11
|
+
"autoStartBreak": false,
|
|
12
|
+
"autoStartWork": false
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tuidoro",
|
|
3
|
+
"description": "A sleek pomodoro timer that runs in your terminal.",
|
|
4
|
+
"author": "b12o",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pomodoro",
|
|
7
|
+
"tui",
|
|
8
|
+
"terminal",
|
|
9
|
+
"timer"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/b12o/tuidoro.git"
|
|
14
|
+
},
|
|
15
|
+
"os": [
|
|
16
|
+
"linux"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"version": "0.0.1",
|
|
22
|
+
"module": "src/index.ts",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"tuidoro": "src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"assets",
|
|
30
|
+
"config"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "bun run src/index.ts",
|
|
34
|
+
"test": "bun test src",
|
|
35
|
+
"build": "bun build src/index.ts --compile --outfile dist/tuidoro"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "latest"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"typescript": "^5"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@opentui/core": "^0.1.77"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { createCliRenderer, RGBA } from "@opentui/core";
|
|
4
|
+
import { createLayout } from "./layout.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
import { Timer } from "./timer.js";
|
|
7
|
+
import type { PomodoroSettings, TimerState } from "./types.js";
|
|
8
|
+
import { loadConfig, loadDefaultConfig, playSound } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
//@ts-ignore -- this is a bun-specific file embed import that ts is not aware of
|
|
11
|
+
import toggleSound from "../assets/tuidoro_toggle.mp3" with { type: "file" };
|
|
12
|
+
|
|
13
|
+
let config: PomodoroSettings;
|
|
14
|
+
try {
|
|
15
|
+
config = loadConfig();
|
|
16
|
+
} catch {
|
|
17
|
+
config = loadDefaultConfig();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const timer = new Timer(config);
|
|
21
|
+
|
|
22
|
+
const renderer = await createCliRenderer({
|
|
23
|
+
exitOnCtrlC: true,
|
|
24
|
+
onDestroy: cleanUp,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
let zenModeEnabled = config.zenMode ?? false;
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
root,
|
|
31
|
+
captionContainer,
|
|
32
|
+
captionText,
|
|
33
|
+
timeContainer,
|
|
34
|
+
timeText,
|
|
35
|
+
pomodoriContainer,
|
|
36
|
+
pomodoriText,
|
|
37
|
+
separatorContainer,
|
|
38
|
+
optionsContainer,
|
|
39
|
+
keyLifecycle,
|
|
40
|
+
} = createLayout(renderer, {
|
|
41
|
+
timeLeft: timer.timeLeftFormatted,
|
|
42
|
+
pomodori: timer.lapsCompleted,
|
|
43
|
+
caption: timer.caption,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
renderer.root.add(root);
|
|
47
|
+
|
|
48
|
+
// initial render
|
|
49
|
+
timeText.color = RGBA.fromHex(timer.activeColor);
|
|
50
|
+
showHideElements();
|
|
51
|
+
|
|
52
|
+
// 250ms in order to respond fairly quickly to timer.ts updates without
|
|
53
|
+
// unnecessarily re-rendering UI
|
|
54
|
+
const RENDER_INTERVAL = 250;
|
|
55
|
+
const mainLoop = setInterval(() => {
|
|
56
|
+
timeText.text = timer.timeLeftFormatted;
|
|
57
|
+
timeText.color = RGBA.fromHex(timer.activeColor);
|
|
58
|
+
captionText.content = timer.caption;
|
|
59
|
+
pomodoriText.content = `Pomodori: ${timer.lapsCompleted}`;
|
|
60
|
+
switch (timer.getState()) {
|
|
61
|
+
case "IDLE":
|
|
62
|
+
keyLifecycle.content = "space start";
|
|
63
|
+
break;
|
|
64
|
+
case "RUNNING":
|
|
65
|
+
keyLifecycle.content = "space pause";
|
|
66
|
+
break;
|
|
67
|
+
case "PAUSED":
|
|
68
|
+
keyLifecycle.content = "space resume";
|
|
69
|
+
}
|
|
70
|
+
}, RENDER_INTERVAL);
|
|
71
|
+
|
|
72
|
+
const transition: Record<TimerState, () => void> = {
|
|
73
|
+
IDLE: () => timer.start(),
|
|
74
|
+
RUNNING: () => timer.stop(),
|
|
75
|
+
PAUSED: () => timer.resume(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
timeContainer.onMouseUp = () => {
|
|
79
|
+
config.sound && playSound(toggleSound);
|
|
80
|
+
transition[timer.getState()]();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
84
|
+
switch (key.name) {
|
|
85
|
+
case "space": {
|
|
86
|
+
// use block scope to prevent const hoisting shenanigans
|
|
87
|
+
config.sound && playSound(toggleSound);
|
|
88
|
+
transition[timer.getState()]();
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "r":
|
|
92
|
+
config.sound && playSound(toggleSound);
|
|
93
|
+
timer.reset();
|
|
94
|
+
break;
|
|
95
|
+
case "z":
|
|
96
|
+
toggleZenMode();
|
|
97
|
+
break;
|
|
98
|
+
case "q":
|
|
99
|
+
renderer.destroy();
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function toggleZenMode() {
|
|
105
|
+
zenModeEnabled = !zenModeEnabled;
|
|
106
|
+
showHideElements();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cleanUp() {
|
|
110
|
+
logger.info("Quitting ...");
|
|
111
|
+
if (timer.intervalId) clearInterval(timer.intervalId);
|
|
112
|
+
clearInterval(mainLoop);
|
|
113
|
+
process.exit();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function showHideElements(): void {
|
|
117
|
+
captionContainer.visible = !zenModeEnabled;
|
|
118
|
+
pomodoriContainer.visible = !zenModeEnabled;
|
|
119
|
+
separatorContainer.visible = !zenModeEnabled;
|
|
120
|
+
optionsContainer.visible = !zenModeEnabled;
|
|
121
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CliRenderer,
|
|
3
|
+
ASCIIFontRenderable,
|
|
4
|
+
Box,
|
|
5
|
+
BoxRenderable,
|
|
6
|
+
RGBA,
|
|
7
|
+
TextRenderable,
|
|
8
|
+
} from "@opentui/core";
|
|
9
|
+
|
|
10
|
+
import { type InitialData } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export function createLayout(renderer: CliRenderer, initialData: InitialData) {
|
|
13
|
+
const offWhite = "#ccc";
|
|
14
|
+
const gray = "#777";
|
|
15
|
+
const timeText = new ASCIIFontRenderable(renderer, {
|
|
16
|
+
id: "timeleft",
|
|
17
|
+
font: "block",
|
|
18
|
+
color: RGBA.fromHex(offWhite),
|
|
19
|
+
text: initialData.timeLeft,
|
|
20
|
+
justifyContent: "center",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
selectable: false,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const pomodoriText = new TextRenderable(renderer, {
|
|
26
|
+
id: "pomodori",
|
|
27
|
+
content: `Pomodori: ${initialData.pomodori}`,
|
|
28
|
+
justifyContent: "center",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
fg: RGBA.fromHex(offWhite),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const captionText = new TextRenderable(renderer, {
|
|
34
|
+
id: "caption",
|
|
35
|
+
content: initialData.caption,
|
|
36
|
+
justifyContent: "center",
|
|
37
|
+
fg: RGBA.fromHex(offWhite),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const separator = new TextRenderable(renderer, {
|
|
41
|
+
content: "_________________________________________",
|
|
42
|
+
fg: RGBA.fromHex(gray),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const keyLifecycle = new TextRenderable(renderer, {
|
|
46
|
+
id: "lifecycle",
|
|
47
|
+
content: "space start",
|
|
48
|
+
fg: RGBA.fromHex(offWhite),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const keyZen = new TextRenderable(renderer, {
|
|
52
|
+
id: "keyZen",
|
|
53
|
+
content: "z zen",
|
|
54
|
+
fg: RGBA.fromHex(offWhite),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const keyReset = new TextRenderable(renderer, {
|
|
58
|
+
id: "keyReset",
|
|
59
|
+
content: "r reset",
|
|
60
|
+
fg: RGBA.fromHex(offWhite),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const keyQuit = new TextRenderable(renderer, {
|
|
64
|
+
id: "keyQuit",
|
|
65
|
+
content: "q quit",
|
|
66
|
+
fg: RGBA.fromHex(offWhite),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const captionContainer = new BoxRenderable(renderer, {
|
|
70
|
+
id: "captionContainer",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
});
|
|
73
|
+
captionContainer.add(captionText);
|
|
74
|
+
|
|
75
|
+
const timeContainer = new BoxRenderable(renderer, {
|
|
76
|
+
id: "timeContainer",
|
|
77
|
+
alignItems: "flex-start",
|
|
78
|
+
marginTop: 1,
|
|
79
|
+
marginBottom: 1,
|
|
80
|
+
});
|
|
81
|
+
timeContainer.add(timeText);
|
|
82
|
+
|
|
83
|
+
const pomodoriContainer = new BoxRenderable(renderer, {
|
|
84
|
+
id: "pomodoriContainer",
|
|
85
|
+
alignItems: "center",
|
|
86
|
+
});
|
|
87
|
+
pomodoriContainer.add(pomodoriText);
|
|
88
|
+
|
|
89
|
+
const separatorContainer = new BoxRenderable(renderer, {
|
|
90
|
+
id: "separatorContainer",
|
|
91
|
+
alignItems: "center",
|
|
92
|
+
marginTop: 1,
|
|
93
|
+
marginBottom: 1,
|
|
94
|
+
width: 42,
|
|
95
|
+
});
|
|
96
|
+
separatorContainer.add(separator);
|
|
97
|
+
|
|
98
|
+
const optionsContainer = new BoxRenderable(renderer, {
|
|
99
|
+
id: "optionsContainer",
|
|
100
|
+
flexDirection: "row",
|
|
101
|
+
justifyContent: "space-between",
|
|
102
|
+
alignItems: "center",
|
|
103
|
+
width: 42,
|
|
104
|
+
});
|
|
105
|
+
optionsContainer.add(keyLifecycle);
|
|
106
|
+
optionsContainer.add(keyReset);
|
|
107
|
+
optionsContainer.add(keyZen);
|
|
108
|
+
optionsContainer.add(keyQuit);
|
|
109
|
+
|
|
110
|
+
const root = Box(
|
|
111
|
+
{
|
|
112
|
+
alignItems: "center",
|
|
113
|
+
justifyContent: "center",
|
|
114
|
+
flexGrow: 1,
|
|
115
|
+
},
|
|
116
|
+
Box(
|
|
117
|
+
{
|
|
118
|
+
width: 45,
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
justifyContent: "center",
|
|
121
|
+
},
|
|
122
|
+
captionContainer,
|
|
123
|
+
timeContainer,
|
|
124
|
+
pomodoriContainer,
|
|
125
|
+
separatorContainer,
|
|
126
|
+
optionsContainer,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
root,
|
|
132
|
+
captionContainer,
|
|
133
|
+
captionText,
|
|
134
|
+
timeContainer,
|
|
135
|
+
timeText,
|
|
136
|
+
pomodoriContainer,
|
|
137
|
+
pomodoriText,
|
|
138
|
+
separatorContainer,
|
|
139
|
+
optionsContainer,
|
|
140
|
+
keyLifecycle,
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { appendFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
const levels = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Level = keyof typeof levels;
|
|
11
|
+
const currentLevel: Level = (process.env.LOG_LEVEL as Level) || "info";
|
|
12
|
+
|
|
13
|
+
function logToFile(msg: string) {
|
|
14
|
+
// tuidoro is located in project root.
|
|
15
|
+
appendFileSync("tuidoro.log", `${msg}\n`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const logger = {
|
|
19
|
+
debug: (msg: string) =>
|
|
20
|
+
levels[currentLevel] <= levels.debug && logToFile(`[DEBUG] ${msg}`),
|
|
21
|
+
info: (msg: string) =>
|
|
22
|
+
levels[currentLevel] <= levels.info && logToFile(`[INFO] ${msg}`),
|
|
23
|
+
warn: (msg: string) =>
|
|
24
|
+
levels[currentLevel] <= levels.warn && logToFile(`[WARN] ${msg}`),
|
|
25
|
+
error: (msg: string) =>
|
|
26
|
+
levels[currentLevel] <= levels.error && logToFile(`[ERROR] ${msg}`),
|
|
27
|
+
};
|
package/src/timer.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
import type { PomodoroSettings, TimerState } from "./types.js";
|
|
3
|
+
import {
|
|
4
|
+
countdownString,
|
|
5
|
+
getSeconds,
|
|
6
|
+
playSound,
|
|
7
|
+
validateBreakInterval,
|
|
8
|
+
validateHex,
|
|
9
|
+
validateWorkInterval,
|
|
10
|
+
getConfigPath,
|
|
11
|
+
} from "./utils.js";
|
|
12
|
+
|
|
13
|
+
//@ts-ignore -- this is a bun-specific file embed import that ts is not aware of
|
|
14
|
+
import chime from "../assets/tuidoro_chime.mp3" with { type: "file" };
|
|
15
|
+
|
|
16
|
+
const OFF_WHITE = "#ccc";
|
|
17
|
+
const WORK_CAPTION = "Let's get to work.";
|
|
18
|
+
|
|
19
|
+
export class Timer {
|
|
20
|
+
// state
|
|
21
|
+
isStarted = false; // is 'false' at the beginning and after every pomodoro
|
|
22
|
+
isRunning = false;
|
|
23
|
+
isWork = false;
|
|
24
|
+
isShortBreak = false;
|
|
25
|
+
isLongBreak = false;
|
|
26
|
+
intervalId: NodeJS.Timeout | undefined;
|
|
27
|
+
|
|
28
|
+
// pomodoro settings
|
|
29
|
+
workDurationSeconds = 1500; // 25 minutes
|
|
30
|
+
shortBreakDurationSeconds = 300;
|
|
31
|
+
longBreakDurationSeconds = 900;
|
|
32
|
+
longBreakAfter = 4;
|
|
33
|
+
lapsCompleted = 0;
|
|
34
|
+
pauseAfterLap = true;
|
|
35
|
+
currentTimeLeft = this.workDurationSeconds;
|
|
36
|
+
enableSound = true;
|
|
37
|
+
autoStartBreak = false;
|
|
38
|
+
autoStartWork = false;
|
|
39
|
+
|
|
40
|
+
// UI
|
|
41
|
+
caption = WORK_CAPTION;
|
|
42
|
+
shortBreakCaption = "Time for a short break.";
|
|
43
|
+
longBreakCaption = "Time for a long break.";
|
|
44
|
+
timeLeftFormatted = "";
|
|
45
|
+
workColor = OFF_WHITE;
|
|
46
|
+
shortBreakColor = OFF_WHITE;
|
|
47
|
+
longBreakColor = OFF_WHITE;
|
|
48
|
+
activeColor = OFF_WHITE;
|
|
49
|
+
|
|
50
|
+
constructor(settingsData: PomodoroSettings) {
|
|
51
|
+
this.validateInput(settingsData);
|
|
52
|
+
|
|
53
|
+
this.currentTimeLeft = this.workDurationSeconds;
|
|
54
|
+
this.timeLeftFormatted = countdownString(this.workDurationSeconds);
|
|
55
|
+
this.activeColor = this.workColor;
|
|
56
|
+
// should always start in work mode
|
|
57
|
+
this.isWork = true;
|
|
58
|
+
|
|
59
|
+
logger.debug("\n\n=================================\n");
|
|
60
|
+
logger.debug(`config path: ${getConfigPath()}`);
|
|
61
|
+
|
|
62
|
+
logger.debug("Initialized timer with following settings:");
|
|
63
|
+
logger.debug(`workduration (seconds): ${this.workDurationSeconds}`);
|
|
64
|
+
logger.debug(
|
|
65
|
+
`short break duration (seconds): ${this.shortBreakDurationSeconds}`,
|
|
66
|
+
);
|
|
67
|
+
logger.debug(
|
|
68
|
+
`long break duration (seconds): ${this.longBreakDurationSeconds}`,
|
|
69
|
+
);
|
|
70
|
+
logger.debug(`long break after (pomodori): ${this.longBreakAfter}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private validateInput(settingsData: PomodoroSettings) {
|
|
74
|
+
if (validateWorkInterval(settingsData.workDuration))
|
|
75
|
+
this.workDurationSeconds = getSeconds(settingsData.workDuration);
|
|
76
|
+
|
|
77
|
+
if (validateWorkInterval(settingsData.shortBreakDuration))
|
|
78
|
+
this.shortBreakDurationSeconds = getSeconds(
|
|
79
|
+
settingsData.shortBreakDuration,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (validateWorkInterval(settingsData.longBreakDuration))
|
|
83
|
+
this.longBreakDurationSeconds = getSeconds(
|
|
84
|
+
settingsData.longBreakDuration,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (validateBreakInterval(settingsData.longBreakAfter))
|
|
88
|
+
this.longBreakAfter = settingsData.longBreakAfter;
|
|
89
|
+
|
|
90
|
+
if (validateHex(settingsData.workColor))
|
|
91
|
+
this.workColor = settingsData.workColor;
|
|
92
|
+
|
|
93
|
+
if (validateHex(settingsData.shortBreakColor))
|
|
94
|
+
this.shortBreakColor = settingsData.shortBreakColor;
|
|
95
|
+
|
|
96
|
+
if (validateHex(settingsData.longBreakColor))
|
|
97
|
+
this.longBreakColor = settingsData.longBreakColor;
|
|
98
|
+
|
|
99
|
+
this.enableSound = settingsData.sound;
|
|
100
|
+
this.autoStartBreak = settingsData.autoStartBreak;
|
|
101
|
+
this.autoStartWork = settingsData.autoStartWork;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getState(): TimerState {
|
|
105
|
+
if (!this.isStarted) return "IDLE";
|
|
106
|
+
if (this.isRunning) return "RUNNING";
|
|
107
|
+
else return "PAUSED";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
start(): void {
|
|
111
|
+
this.isStarted = true;
|
|
112
|
+
const period = this.isWork ? "new pomodoro" : "break";
|
|
113
|
+
this.resume(`Starting ${period} ...`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
stop(): void {
|
|
117
|
+
logger.info("");
|
|
118
|
+
this.isRunning = false;
|
|
119
|
+
clearInterval(this.intervalId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resume(msg: string = "Resuming ..."): void {
|
|
123
|
+
logger.info(msg);
|
|
124
|
+
this.isRunning = true;
|
|
125
|
+
clearInterval(this.intervalId);
|
|
126
|
+
this.intervalId = setInterval(() => this.countdown(), 1000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
reset() {
|
|
130
|
+
this.stop();
|
|
131
|
+
this.isStarted = false;
|
|
132
|
+
const resetDurationSeconds = this.isWork
|
|
133
|
+
? this.workDurationSeconds
|
|
134
|
+
: this.isShortBreak
|
|
135
|
+
? this.shortBreakDurationSeconds
|
|
136
|
+
: this.longBreakDurationSeconds;
|
|
137
|
+
this.currentTimeLeft = resetDurationSeconds;
|
|
138
|
+
this.timeLeftFormatted = countdownString(this.currentTimeLeft);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private countdown() {
|
|
142
|
+
this.currentTimeLeft--;
|
|
143
|
+
this.timeLeftFormatted = countdownString(this.currentTimeLeft);
|
|
144
|
+
if (this.currentTimeLeft <= 0) {
|
|
145
|
+
this.handleNextPeriod();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
handleNextPeriod() {
|
|
150
|
+
this.stop();
|
|
151
|
+
this.isStarted = false;
|
|
152
|
+
this.enableSound && playSound(chime);
|
|
153
|
+
let notifyMessage = "";
|
|
154
|
+
if (this.isWork) {
|
|
155
|
+
this.lapsCompleted++;
|
|
156
|
+
this.isWork = false;
|
|
157
|
+
if (this.lapsCompleted % this.longBreakAfter === 0) {
|
|
158
|
+
this.isLongBreak = true;
|
|
159
|
+
this.activeColor = this.longBreakColor;
|
|
160
|
+
this.currentTimeLeft = this.longBreakDurationSeconds;
|
|
161
|
+
this.caption = this.longBreakCaption;
|
|
162
|
+
} else {
|
|
163
|
+
this.isShortBreak = true;
|
|
164
|
+
this.activeColor = this.shortBreakColor;
|
|
165
|
+
this.currentTimeLeft = this.shortBreakDurationSeconds;
|
|
166
|
+
this.caption = this.shortBreakCaption;
|
|
167
|
+
}
|
|
168
|
+
this.timeLeftFormatted = countdownString(this.currentTimeLeft);
|
|
169
|
+
if (this.autoStartBreak) this.start();
|
|
170
|
+
} else if (this.isLongBreak || this.isShortBreak) {
|
|
171
|
+
this.isLongBreak = false;
|
|
172
|
+
this.isShortBreak = false;
|
|
173
|
+
this.isWork = true;
|
|
174
|
+
this.activeColor = this.workColor;
|
|
175
|
+
this.currentTimeLeft = this.workDurationSeconds;
|
|
176
|
+
this.timeLeftFormatted = countdownString(this.currentTimeLeft);
|
|
177
|
+
if (this.autoStartWork) this.start();
|
|
178
|
+
this.caption = WORK_CAPTION;
|
|
179
|
+
}
|
|
180
|
+
notifyMessage = this.caption;
|
|
181
|
+
notifyMessage.length > 0 && Bun.spawn(["notify-send", notifyMessage]);
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type PomodoroSettings = {
|
|
2
|
+
workDuration: number;
|
|
3
|
+
shortBreakDuration: number;
|
|
4
|
+
longBreakDuration: number;
|
|
5
|
+
longBreakAfter: number;
|
|
6
|
+
workColor: string;
|
|
7
|
+
shortBreakColor: string;
|
|
8
|
+
longBreakColor: string;
|
|
9
|
+
zenMode: boolean;
|
|
10
|
+
sound: boolean;
|
|
11
|
+
autoStartBreak: boolean;
|
|
12
|
+
autoStartWork: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type InitialData = {
|
|
16
|
+
timeLeft: string;
|
|
17
|
+
pomodori: number;
|
|
18
|
+
caption: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TimerState = "IDLE" | "RUNNING" | "PAUSED";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
getSeconds,
|
|
4
|
+
validateBreakInterval,
|
|
5
|
+
validateHex,
|
|
6
|
+
validateWorkInterval,
|
|
7
|
+
} from "./utils.js";
|
|
8
|
+
|
|
9
|
+
describe("convert to seconds", () => {
|
|
10
|
+
test("25 minutes", () => {
|
|
11
|
+
expect(getSeconds(25)).toBe(1500);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("5 minutes", () => {
|
|
15
|
+
expect(getSeconds(5)).toBe(300);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("validate interval", () => {
|
|
20
|
+
test("work: 0 not allowed", () => {
|
|
21
|
+
expect(validateWorkInterval(0)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test("work: >= 60 not allowed", () => {
|
|
24
|
+
expect(validateWorkInterval(60)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
test("work: 0 < x < 60 allowed", () => {
|
|
27
|
+
expect(validateWorkInterval(59)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
test("break: 0 not allowed", () => {
|
|
30
|
+
expect(validateBreakInterval(0)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
test("break: >= 60 not allowed", () => {
|
|
33
|
+
expect(validateBreakInterval(60)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
test("break: 0 < x < 60 allowed", () => {
|
|
36
|
+
expect(validateBreakInterval(59)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("validate hex code", () => {
|
|
41
|
+
test("empty", () => {
|
|
42
|
+
expect(validateHex("")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
test("no hash", () => {
|
|
45
|
+
expect(validateHex("123456")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
test("too long", () => {
|
|
48
|
+
expect(validateHex("#1234567")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
test("code not of len 3 or 6", () => {
|
|
51
|
+
expect(validateHex("#1234")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
test("correct example 1", () => {
|
|
54
|
+
expect(validateHex("#123")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
test("correct example 2", () => {
|
|
57
|
+
expect(validateHex("#123456")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { file } from "bun";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import type { PomodoroSettings } from "./types.js";
|
|
6
|
+
import defaultSettings from "../config/settings.json";
|
|
7
|
+
|
|
8
|
+
const APP_NAME = "tuidoro";
|
|
9
|
+
|
|
10
|
+
export function countdownString(seconds: number): string {
|
|
11
|
+
const minutes = Math.floor(seconds / 60);
|
|
12
|
+
const remainingSeconds = seconds % 60;
|
|
13
|
+
const minutesStr = minutes.toString().padStart(2, "0");
|
|
14
|
+
const secondsStr = remainingSeconds.toString().padStart(2, "0");
|
|
15
|
+
return `${minutesStr}:${secondsStr}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function playSound(audio: string) {
|
|
19
|
+
const tmpId = Math.random().toString(36).slice(2);
|
|
20
|
+
const playPath = path.join(os.tmpdir(), `${APP_NAME}_${tmpId}.mp3`);
|
|
21
|
+
await Bun.write(playPath, await file(audio).bytes());
|
|
22
|
+
const proc = Bun.spawn(["paplay", playPath]);
|
|
23
|
+
await proc.exited;
|
|
24
|
+
await Bun.file(playPath).unlink();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getSeconds(minute: number) {
|
|
28
|
+
return minute * 60;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validateWorkInterval(time: number) {
|
|
32
|
+
return Number.isInteger(time) && time > 0 && time < 60;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateBreakInterval(interval: number): boolean {
|
|
36
|
+
return Number.isInteger(interval) && interval > 0 && interval < 60;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateHex(hex: string) {
|
|
40
|
+
// Validates: # followed by 3 or 6 hex characters
|
|
41
|
+
return /^#([0-9A-F]{3}){1,2}$/i.test(hex);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getConfigPath() {
|
|
45
|
+
let configPath = path.join(
|
|
46
|
+
os.homedir(),
|
|
47
|
+
".config",
|
|
48
|
+
APP_NAME,
|
|
49
|
+
"settings.json",
|
|
50
|
+
);
|
|
51
|
+
const envPath = process.env.TUIDORO_SETTINGS_PATH;
|
|
52
|
+
if (envPath && existsSync(envPath)) {
|
|
53
|
+
configPath = envPath;
|
|
54
|
+
}
|
|
55
|
+
return configPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadConfig(): PomodoroSettings {
|
|
59
|
+
const rawInput = readFileSync(getConfigPath(), "utf8");
|
|
60
|
+
return JSON.parse(rawInput);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadDefaultConfig(): PomodoroSettings {
|
|
64
|
+
return defaultSettings;
|
|
65
|
+
}
|