Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
669103d
DORO-010: Add duration configurations to settings
dnim May 15, 2026
9eb2c41
DORO-010: Add input mapping for increasing/decreasing duration
dnim May 15, 2026
1a3f3c7
feedback: added feedback handling instructions
dnim May 15, 2026
7c93ace
DORO-010: Implement duration editing logic in app and state machine
dnim May 15, 2026
b2a3f1c
feedback: added explicit commit approval rule
dnim May 15, 2026
a9a9028
DORO-010: Mark task as done
dnim May 15, 2026
1648bb3
chore: remove core-x from deps and mcp configs
dnim May 15, 2026
4c5f84c
DORO-010: Display edit duration value and saved state on UI banner
dnim May 15, 2026
8ca3351
feedback: prohibit the use of git commit --amend
dnim May 15, 2026
c6162a4
test: add visual unit tests for edit duration blinking and saved state
dnim May 15, 2026
091fd49
feedback: enforce VRT updates for UI changes
dnim May 15, 2026
6f772cd
feedback: add targeted visual test commands
dnim May 15, 2026
9253fae
DORO-010: Fix visual test snapshots and node-pty resolution
dnim May 15, 2026
f89ff25
chore: exclude
dnim May 15, 2026
8a8a75e
feedback: physically block git commit --amend via pi extension
dnim May 15, 2026
394b37b
chore(vis-reg): update baseline snapshots [skip ci]
dnim May 15, 2026
d89cfb6
DORO-010: Fix code coverage on handleDurationEdit in app.ts
dnim May 15, 2026
bed9ed4
DORO-010: Fix UI tests and test coverage
dnim May 15, 2026
46aba6c
style: apply formatting to other changed files
dnim May 15, 2026
7e32296
build(coverage): adjust strict target thresholds in codecov.yml to 98%
dnim May 15, 2026
0f76883
test: add coverage for duration edit paths
dnim May 15, 2026
6ba101f
test: fix ui tests coverage for mode banners
dnim May 15, 2026
90dd8a2
DORO-010: Address Copilot review — extract duration constants and cla…
dnim May 25, 2026
42de913
DORO-010: Fix GHA runners by introducing clean shared setup action an…
dnim May 26, 2026
fabada1
Fix CI by removing npm self-upgrade in setup action
Copilot May 26, 2026
c0ab0d7
Plan CI npm cache isolation fix
Copilot May 26, 2026
9a44271
Use isolated npm cache in GitHub workflows
Copilot May 26, 2026
b08441e
chore: ignore and remove committed npm cache artifacts
Copilot May 27, 2026
e4cb3e9
ci: set setup-project default registry to npmjs
Copilot May 27, 2026
513ed57
fix(ci): pin Node.js to 24.15.0 in workflows
Copilot May 28, 2026
c43075d
Simplify npm dependency installation step
dnim May 28, 2026
9bdcfe2
Update release workflow to simplify npm install
dnim May 28, 2026
0d718ac
Update visual-tests.yml
dnim May 28, 2026
52e0f09
fix: revert non-feature changes and fix .npmrc localhost registry
Copilot May 28, 2026
1cea552
merge: resolve conflicts with main
Copilot May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
id: DORO-010
title: Add possibility to adjust long/short/work time on UI
status: In Progress
assignee:
- '@antigravity'
created_date: '2026-05-15 10:29'
updated_date: '2026-05-25 19:12'
labels: []
dependencies: []
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->

Allow users to adjust the duration (in minutes) for short breaks, long breaks, and work sessions directly from the UI (including tiny mode).

- Short break: 3-7 mins
- Long break: 10-18 mins
- Work: 20-30 mins
- Save these settings into the existing config.
<!-- SECTION:DESCRIPTION:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

1. Extract MODE_DURATION_BOUNDS and EDIT_SAVE_TIMEOUT_MS constants to config.ts
2. Update handleDurationEdit in app.ts to clamp the initial value and use the new constants
3. Correct // no latestVersion comment placement in ui.test.ts
4. Run lint, typecheck, and unit tests
5. Update visual regression test snapshots to ensure all tests pass
<!-- SECTION:PLAN:END -->

## Definition of Done

<!-- DOD:BEGIN -->

- [ ] #1 code coverage is passing
- [ ] #2 VRTs added for ui changes
- [ ] #3 tests/linting/typecheck is green.
<!-- DOD:END -->
8 changes: 8 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
target: 98%
patch:
default:
target: 98%
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default tseslint.config(
'dist/**',
'coverage/**',
'node_modules/**',
'.pi/**',
'playwright.config.ts',
'vitest.config.ts',
'.pi/**'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"maintained node versions"
],
"lint-staged": {
"src/**/*.{ts,tsx}": [
"{src,tests}/**/*.{ts,tsx,mts}": [
"eslint --fix",
"prettier --write"
]
Expand Down
233 changes: 232 additions & 1 deletion src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ describe('DoroApp', () => {
toggleLock: vi.fn(),
togglePause: vi.fn(),
debugJumpToNearEnd: vi.fn(),
resetCurrentAndRun: vi.fn()
resetCurrentAndRun: vi.fn(),
updateConfig: vi.fn()
// Add other methods of TimerStateMachine as they are used
} as unknown as Mocked<TimerStateMachine>;

Expand Down Expand Up @@ -432,6 +433,53 @@ describe('DoroApp', () => {
expect(playClip).toHaveBeenCalled();
});

it('should bail handleDurationEdit if value is null', () => {
app.start();
(app as any).handleDurationEdit('increaseDuration'); // Start edit
(app as any).editDurationValue = null; // force null for test
(app as any).handleDurationEdit('increaseDuration'); // should early return
expect((app as any).editDurationValue).toBeNull();
});

it('should edit duration and cancel edit if invalid', () => {
app.start();
(app as any).handleDurationEdit('increaseDuration'); // Start edit
expect((app as any).editDurationState).toBe('editing');

(app as any).editDurationValue = null;
(app as any).saveDurationEdit(); // Submit invalid

// With invalid / null editDurationValue it should cancel
expect((app as any).editDurationState).toBe('none');
});

it('should edit duration and apply short mode', () => {
app.start();
mockTimerStateMachine.getState.mockReturnValue({ mode: 'short' } as any);

(app as any).handleDurationEdit('increaseDuration'); // Start edit
expect((app as any).editDurationState).toBe('editing');

(app as any).editDurationValue = 5;
(app as any).saveDurationEdit(); // Submit

expect(mockTimerStateMachine.updateConfig).toHaveBeenCalled();
});

it('should edit duration and apply long mode', () => {
app.start();
mockTimerStateMachine.getState.mockReturnValue({ mode: 'long' } as any);

(app as any).handleDurationEdit('increaseDuration'); // Start edit
expect((app as any).editDurationState).toBe('editing');
(app as any).handleDurationEdit('decreaseDuration'); // Trigger line 424

(app as any).editDurationValue = 15;
(app as any).saveDurationEdit(); // Submit

expect(mockTimerStateMachine.updateConfig).toHaveBeenCalled();
});

it('should play completion and reset beeps', () => {
// Test completion beep
mockTimerStateMachine.getState.mockReturnValue({ status: 'running' } as any);
Expand Down Expand Up @@ -569,6 +617,189 @@ describe('DoroApp', () => {
});

describe('additional input handling', () => {
it('should enter edit duration mode and toggle blink', () => {
// Mock basic state
mockTimerStateMachine.getState.mockReturnValue({
mode: 'work',
status: 'running',
remainingSeconds: 1200,
isLocked: false,
switchPrompt: null,
completedWorkSessions: 1
});
mockTimerStateMachine.getConfig.mockReturnValue({
workSeconds: 22 * 60,
shortRestSeconds: 5 * 60,
longRestSeconds: 12 * 60,
longRestEveryWorkSessions: 4,
switchConfirmSeconds: 300,
audioVolume: 0.5,
theme: 'modern',
tickSoundEnabled: true,
mascotVariant: 'default'
} as any);

const mockRender = vi.spyOn(mockDoroUi, 'render');

// 1. Initial increase to start editing
vi.mocked(resolveControlCommand).mockReturnValue('increaseDuration');
(app as any).handleInput({
type: 'key',
ch: '+',
keyName: '+',
keyFull: '+',
shift: false,
ctrl: false
});

expect((app as any).editDurationState).toBe('editing');
expect((app as any).editDurationValue).toBe(22);
expect(mockRender).toHaveBeenCalled();

// 2. Second increase to actually change value
(app as any).handleInput({
type: 'key',
ch: '+',
keyName: '+',
keyFull: '+',
shift: false,
ctrl: false
});

expect((app as any).editDurationValue).toBe(23);

// 3. Decrease value
vi.mocked(resolveControlCommand).mockReturnValue('decreaseDuration');
(app as any).handleInput({
type: 'key',
ch: '-',
keyName: '-',
keyFull: '-',
shift: false,
ctrl: false
});

expect((app as any).editDurationValue).toBe(22);

// 4. Tick should toggle blink every 3 ticks
// Initialize internal counter so first tick doesn't immediately false out
(app as any).blinkCounter = 0;
(app as any).editDurationBlink = false;

// Fast forward 250ms ticks to trigger blink toggle
vi.useFakeTimers();
(app as any).start(); // Need to start the app to get the setInterval
vi.advanceTimersByTime(250); // count 1 (blink = false)
vi.advanceTimersByTime(250); // count 2 (blink = false)
vi.advanceTimersByTime(250); // count 3 - should toggle
expect((app as any).editDurationBlink).toBe(true);
});

it('should save duration on timeout', () => {
vi.useFakeTimers();

// Mock state and enter edit mode
mockTimerStateMachine.getState.mockReturnValue({
mode: 'work',
status: 'running',
remainingSeconds: 1200,
isLocked: false,
switchPrompt: null,
completedWorkSessions: 1
});
mockTimerStateMachine.getConfig.mockReturnValue({
workSeconds: 22 * 60,
shortRestSeconds: 5 * 60,
longRestSeconds: 12 * 60,
longRestEveryWorkSessions: 4,
switchConfirmSeconds: 300,
audioVolume: 0.5,
theme: 'modern',
tickSoundEnabled: true,
mascotVariant: 'default'
} as any);

vi.mocked(resolveControlCommand).mockReturnValue('increaseDuration');
(app as any).handleInput({ type: 'key', keyName: '+' });
(app as any).handleInput({ type: 'key', keyName: '+' }); // value = 23

expect((app as any).editDurationState).toBe('editing');

// Fast forward 2 seconds to trigger save timeout
vi.advanceTimersByTime(2000);

expect((app as any).editDurationState).toBe('saved');
expect(mockTimerStateMachine.updateConfig).toHaveBeenCalledWith(
expect.objectContaining({ workSeconds: 23 * 60 })
);

// Fast forward another 2 seconds to trigger clear timeout
vi.advanceTimersByTime(2000);

expect((app as any).editDurationState).toBe('none');
expect((app as any).editDurationValue).toBeNull();
});

it('should clamp duration values', () => {
mockTimerStateMachine.getState.mockReturnValue({
mode: 'work',
status: 'running',
remainingSeconds: 1200,
isLocked: false,
switchPrompt: null,
completedWorkSessions: 1
});
mockTimerStateMachine.getConfig.mockReturnValue({
workSeconds: 30 * 60, // max work time
shortRestSeconds: 5 * 60,
longRestSeconds: 12 * 60,
longRestEveryWorkSessions: 4,
switchConfirmSeconds: 300,
audioVolume: 0.5,
theme: 'modern',
tickSoundEnabled: true,
mascotVariant: 'default'
} as any);

// Start editing
vi.mocked(getDurationForMode).mockReturnValue(30 * 60);
vi.mocked(resolveControlCommand).mockReturnValue('increaseDuration');
(app as any).handleInput({ type: 'key', keyName: '+' }); // Sets initial to 30

// Try to exceed max
(app as any).handleInput({ type: 'key', keyName: '+' });
expect((app as any).editDurationValue).toBe(30); // should clamp to 30

// Reset test state for min clamp
mockTimerStateMachine.getState.mockReturnValue({
mode: 'short',
status: 'running',
remainingSeconds: 300,
isLocked: false,
switchPrompt: null,
completedWorkSessions: 1
});
mockTimerStateMachine.getConfig.mockReturnValue({
workSeconds: 22 * 60,
shortRestSeconds: 3 * 60, // min short rest time
longRestSeconds: 12 * 60,
longRestEveryWorkSessions: 4,
switchConfirmSeconds: 300,
audioVolume: 0.5,
theme: 'modern',
tickSoundEnabled: true,
mascotVariant: 'default'
} as any);

(app as any).editDurationState = 'none';
vi.mocked(getDurationForMode).mockReturnValue(3 * 60);
vi.mocked(resolveControlCommand).mockReturnValue('decreaseDuration');
(app as any).handleInput({ type: 'key', keyName: '-' }); // Start edit mode
(app as any).handleInput({ type: 'key', keyName: '-' }); // Try to go below min

expect((app as any).editDurationValue).toBe(3); // should clamp to 3
});

it('should handle quit command', () => {
const mockShutdown = vi.spyOn(app as any, 'shutdown').mockImplementation(() => {});
vi.mocked(resolveControlCommand).mockReturnValue('quit');
Expand Down
20 changes: 16 additions & 4 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ describe('config', () => {
expect(settings).toEqual({
volumeMode: 'normal',
colorScheme: 'modern',
checkIntervalHours: 24
checkIntervalHours: 24,
workDuration: 22,
shortBreakDuration: 5,
longBreakDuration: 12
});
});

Expand All @@ -52,7 +55,10 @@ describe('config', () => {
expect(settings).toEqual({
volumeMode: 'quiet',
colorScheme: 'calm',
checkIntervalHours: 24
checkIntervalHours: 24,
workDuration: 22,
shortBreakDuration: 5,
longBreakDuration: 12
});
});

Expand All @@ -63,7 +69,10 @@ describe('config', () => {
expect(settings).toEqual({
volumeMode: 'normal',
colorScheme: 'modern',
checkIntervalHours: 24
checkIntervalHours: 24,
workDuration: 22,
shortBreakDuration: 5,
longBreakDuration: 12
});
});
});
Expand Down Expand Up @@ -106,7 +115,10 @@ describe('config', () => {
expect(settings).toEqual({
volumeMode: 'normal',
colorScheme: 'modern',
checkIntervalHours: 24
checkIntervalHours: 24,
workDuration: 22,
shortBreakDuration: 5,
longBreakDuration: 12
});
expect(fs.promises.writeFile).toHaveBeenCalled();
});
Expand Down
14 changes: 13 additions & 1 deletion src/__tests__/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,21 @@ describe('input mapping', () => {
expect(resolveControlCommand(createKeyEvent('N', 'n'))).toBe('updateNo');
});

it('maps duration commands correctly', () => {
expect(resolveControlCommand(createKeyEvent('+', '+'))).toBe('increaseDuration');
expect(resolveControlCommand(createKeyEvent('=', '='))).toBe('increaseDuration');
expect(resolveControlCommand(createKeyEvent(undefined, 'up'))).toBe('increaseDuration');
expect(resolveControlCommand(createKeyEvent(undefined, 'right'))).toBe('increaseDuration');

expect(resolveControlCommand(createKeyEvent('-', '-'))).toBe('decreaseDuration');
expect(resolveControlCommand(createKeyEvent('_', '_'))).toBe('decreaseDuration');
expect(resolveControlCommand(createKeyEvent(undefined, 'down'))).toBe('decreaseDuration');
expect(resolveControlCommand(createKeyEvent(undefined, 'left'))).toBe('decreaseDuration');
});

it('does not crash when a key event arrives without a character', () => {
expect(resolveControlCommand(createKeyEvent(undefined, 'q'))).toBe('quit');
expect(resolveControlCommand(createKeyEvent(undefined, 'up'))).toBe('none');
expect(resolveControlCommand(createKeyEvent(undefined, 'unknown'))).toBe('none');
});

it('allows only quit, pause, toggle lock, and update check when locked', () => {
Expand Down
Loading
Loading