Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ Example `pomo.yaml`:
# options: "ask" | "start" | "quit"
onSessionEnd: "ask"

# if enabled pomo will write current state to /tmp/pomo.json every second with format:
# {
# "state": "running",
# "type": "work",
# "elapsed_seconds": 312,
# "duration_seconds": 1500,
# "remaining_seconds": 1188,
# "sessions_done": 1,
# "cycle_position": 2
# }
writeStateFile: false

asciiArt:
# use ASCII art for timer display
enabled: true
Expand Down
14 changes: 8 additions & 6 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ type ASCIIArt struct {
}

type Config struct {
OnSessionEnd string
ASCIIArt ASCIIArt
Work Task
Break Task
LongBreak LongBreak
OnSessionEnd string
WriteStateFile bool
ASCIIArt ASCIIArt
Work Task
Break Task
LongBreak LongBreak
}

var (
Expand All @@ -64,7 +65,8 @@ var (
C Config

DefaultConfig = map[string]any{
"onSessionEnd": "ask",
"onSessionEnd": "ask",
"writeStateFile": false,
"asciiArt": map[string]any{
"enabled": true,
"font": ascii.DefaultFont,
Expand Down
8 changes: 8 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ longBreak:
assert.Equal(t, defaults.ASCIIArt.Font, C.ASCIIArt.Font)
}

func TestLoadConfigWriteStateFile(t *testing.T) {
setupViper()
writeAndLoadConfig(t, "writeStateFile: true")

assert.True(t, C.WriteStateFile)
}

func TestLoadConfigThenCommands(t *testing.T) {
configYAML := `
work:
Expand Down Expand Up @@ -292,6 +299,7 @@ func getDefaultConfig() Config {
func assertConfigMatches(t *testing.T, expected Config, actual Config) {
// main config assertion
assert.Equal(t, expected.OnSessionEnd, actual.OnSessionEnd)
assert.Equal(t, expected.WriteStateFile, actual.WriteStateFile)

// ASCII Art assertions
assert.Equal(t, expected.ASCIIArt.Enabled, actual.ASCIIArt.Enabled)
Expand Down
1 change: 1 addition & 0 deletions pomo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# https://github.com/Bahaaio/pomo

onSessionEnd: ask
writeStateFile: false

asciiArt:
enabled: true
Expand Down
8 changes: 8 additions & 0 deletions ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func (m *Model) handleKeys(msg tea.KeyMsg) tea.Cmd {
m.sessionState = Paused
}

m.writeState()

if m.sessionState == Running {
return m.timer.Start()
}
Expand Down Expand Up @@ -118,6 +120,8 @@ func (m *Model) handleTimerTick(msg timer.TickMsg) tea.Cmd {
percent := m.getPercent()
cmds = append(cmds, m.progressBar.SetPercent(percent))

m.writeState()

return tea.Batch(cmds...)
}

Expand Down Expand Up @@ -182,6 +186,7 @@ func (m *Model) handleCompletion() tea.Cmd {
case "ask":
m.sessionState = ShowingConfirm
m.confirmStartTime = time.Now()
m.writeState()

// send first confirm tick
return func() tea.Msg {
Expand Down Expand Up @@ -262,6 +267,7 @@ func (m *Model) startSession(taskType config.TaskType, task config.Task, isShort
m.timer = timer.New(m.currentTask.Duration)

m.sessionState = Running
m.writeState()
return tea.Batch(
m.progressBar.SetPercent(0.0),
m.timer.Start(),
Expand Down Expand Up @@ -299,6 +305,7 @@ func (m *Model) recordSession() {

// handles the completion of post actions and quits the application
func (m *Model) handleCommandsDone() tea.Cmd {
m.removeStateFile()
m.sessionState = Quitting
return tea.Quit
}
Expand Down Expand Up @@ -345,6 +352,7 @@ func (m *Model) Quit() tea.Cmd {
return m.waitForCommands()
}

m.removeStateFile()
m.sessionState = Quitting
return tea.Quit
}
4 changes: 4 additions & 0 deletions ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type Model struct {
timerFont ascii.Font
asciiTimerStyle lipgloss.Style

// state file
writeStateFile bool

// databse
repo *db.SessionRepo
}
Expand Down Expand Up @@ -97,6 +100,7 @@ func NewModel(taskType config.TaskType, cfg config.Config) Model {
longBreak: cfg.LongBreak,
cyclePosition: 1,

writeStateFile: cfg.WriteStateFile,
useTimerArt: cfg.ASCIIArt.Enabled,
timerFont: timerFont,
asciiTimerStyle: timerStyle,
Expand Down
77 changes: 77 additions & 0 deletions ui/statefile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package ui

import (
"encoding/json"
"log"
"os"

"github.com/Bahaaio/pomo/config"
)

const stateFilePath = "/tmp/pomo.json"

type sessionStateFile struct {
State string `json:"state"`
Type string `json:"type"`
ElapsedSeconds int `json:"elapsed_seconds"`
DurationSeconds int `json:"duration_seconds"`
RemainingSeconds int `json:"remaining_seconds"`
Percent float64 `json:"percent"`
SessionsDone int `json:"sessions_done"`
CyclePosition int `json:"cycle_position"`
}

func (m Model) writeState() {
if !m.writeStateFile {
return
}

s := sessionStateFile{
State: m.stateString(),
Type: m.typeString(),
ElapsedSeconds: int(m.elapsed.Seconds()),
DurationSeconds: int(m.duration.Seconds()),
RemainingSeconds: int((m.duration - m.elapsed).Seconds()),
Percent: m.getPercent(),
SessionsDone: m.sessionSummary.WorkSessions(),
CyclePosition: m.cyclePosition,
}

data, err := json.Marshal(s)
if err != nil {
log.Printf("failed to marshal state file: %v", err)
return
}

if err := os.WriteFile(stateFilePath, data, 0644); err != nil {
log.Printf("failed to write state file: %v", err)
}
}

func (m Model) removeStateFile() {
if !m.writeStateFile {
return
}

if err := os.Remove(stateFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("failed to remove state file: %v", err)
}
}

func (m Model) stateString() string {
switch m.sessionState {
case Paused:
return "paused"
case ShowingConfirm:
return "completed"
default:
return "running"
}
}

func (m Model) typeString() string {
if m.currentTaskType == config.WorkTask {
return "work"
}
return "break"
}
120 changes: 120 additions & 0 deletions ui/statefile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package ui

import (
"encoding/json"
"os"
"testing"
"time"

"github.com/Bahaaio/pomo/config"
"github.com/Bahaaio/pomo/ui/summary"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newTestModel(writeStateFile bool) Model {
return Model{
writeStateFile: writeStateFile,
sessionState: Running,
currentTaskType: config.WorkTask,
elapsed: 5 * time.Minute,
duration: 25 * time.Minute,
sessionSummary: summary.SessionSummary{},
cyclePosition: 2,
}
}

func readStateFile(t *testing.T) sessionStateFile {
t.Helper()
data, err := os.ReadFile(stateFilePath)
require.NoError(t, err)

var s sessionStateFile
require.NoError(t, json.Unmarshal(data, &s))
return s
}

func TestWriteState(t *testing.T) {
t.Cleanup(func() { os.Remove(stateFilePath) })

m := newTestModel(true)
m.writeState()

s := readStateFile(t)
assert.Equal(t, "running", s.State)
assert.Equal(t, "work", s.Type)
assert.Equal(t, 300, s.ElapsedSeconds)
assert.Equal(t, 1500, s.DurationSeconds)
assert.Equal(t, 1200, s.RemainingSeconds)
assert.Equal(t, 0.2, s.Percent)
assert.Equal(t, 0, s.SessionsDone)
assert.Equal(t, 2, s.CyclePosition)
}

func TestWriteStateDisabled(t *testing.T) {
os.Remove(stateFilePath)

m := newTestModel(false)
m.writeState()

_, err := os.Stat(stateFilePath)
assert.True(t, os.IsNotExist(err), "state file should not be created when disabled")
}

func TestWriteStatePaused(t *testing.T) {
t.Cleanup(func() { os.Remove(stateFilePath) })

m := newTestModel(true)
m.sessionState = Paused
m.writeState()

s := readStateFile(t)
assert.Equal(t, "paused", s.State)
}

func TestWriteStateBreak(t *testing.T) {
t.Cleanup(func() { os.Remove(stateFilePath) })

m := newTestModel(true)
m.currentTaskType = config.BreakTask
m.writeState()

s := readStateFile(t)
assert.Equal(t, "break", s.Type)
}

func TestWriteStateCompleted(t *testing.T) {
t.Cleanup(func() { os.Remove(stateFilePath) })

m := newTestModel(true)
m.sessionState = ShowingConfirm
m.writeState()

s := readStateFile(t)
assert.Equal(t, "completed", s.State)
}

func TestRemoveStateFile(t *testing.T) {
m := newTestModel(true)
m.writeState()

_, err := os.Stat(stateFilePath)
require.NoError(t, err, "state file should exist before removal")

m.removeStateFile()

_, err = os.Stat(stateFilePath)
assert.True(t, os.IsNotExist(err), "state file should be removed")
}

func TestRemoveStateFileDisabled(t *testing.T) {
// create the file manually to ensure removeStateFile respects the flag
require.NoError(t, os.WriteFile(stateFilePath, []byte("{}"), 0644))
t.Cleanup(func() { os.Remove(stateFilePath) })

m := newTestModel(false)
m.removeStateFile()

_, err := os.Stat(stateFilePath)
assert.NoError(t, err, "state file should not be removed when disabled")
}
4 changes: 4 additions & 0 deletions ui/summary/session_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func (t *SessionSummary) AddDuration(taskType config.TaskType, duration time.Dur
}
}

func (t SessionSummary) WorkSessions() int {
return t.totalWorkSessions
}

// SetDatabaseUnavailable marks the database as unavailable.
// prints a warning in the summary.
func (t *SessionSummary) SetDatabaseUnavailable() {
Expand Down