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 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
+ ![screenshot](./assets/screenshot1.png)
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
+ }