Skip to content
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
.PHONY: build install clean test list release-dry-run install-deps

VERSION := $(shell git describe --tags 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.version=$(VERSION)"

# Display available tasks
list:
@$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs

# Build the application
build:
go build
go build $(LDFLAGS)

# Install the application
install:
go install
go install $(LDFLAGS)

# Clean the build
clean:
@rm -f tiny-timer
go clean -testcache

# Run tests
Expand Down
66 changes: 49 additions & 17 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
"tiny-timer/status"
)

// Top level event handler that is called each time the screen is updated
Expand All @@ -31,6 +32,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.progress = progressModel.(progress.Model)
return m, cmd

case status.InfoMsg, status.ClearStatusMsg:
// Handle status component updates
statusModel, cmd := m.status.Update(msg)
m.status = statusModel
return m, cmd

default:
return m, nil
}
Expand All @@ -49,14 +56,18 @@ func updatePercent(m model) (tea.Model, tea.Cmd) {
return m, tea.Batch(tickCmd(), cmd)
}

percentCompleted := float64(elapsed) / float64(m.targetDuration)
// Calculate completion for further evaluation
//
// For count down mode (default), fill progress bar
// and work backwards as time elapses
percentCompleted := float64(m.targetDuration-elapsed) / float64(m.targetDuration)

// Check for completion based on actual elapsed time
if percentCompleted >= 1.0 {
// Ensure progress is set to 100% for final display
m.progress.SetPercent(1.0)
if percentCompleted <= 0.0 {
// Ensure progress is set to 0% for final display
m.progress.SetPercent(0.0)

if err := sendNotification("Pomodoro CLI", "Timer has finished"); err != nil {
if err := sendNotification("tiny-timer", "Timer has finished"); err != nil {
fmt.Println("Error sending notification:", err)
}

Expand All @@ -67,21 +78,26 @@ func updatePercent(m model) (tea.Model, tea.Cmd) {
return m, nil
}

// Activate normal progress bar update
cmd := m.progress.SetPercent(percentCompleted)
return m, tea.Batch(tickCmd(), cmd)
}

func updateWindowSize(m model, msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.progress.Width = msg.Width - padding*2 - 4
if m.progress.Width > maxWidth {
m.progress.Width = maxWidth
}
m.progress.Width = min(m.progress.Width, maxWidth)
m.help.Width = msg.Width
return m, nil

// Update status component with window size
s, cmd := m.status.Update(msg)
m.status = s

return m, cmd
}

func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) {
m.promptActive = false
var cmds []tea.Cmd

switch m.promptType {
case promptLogAndReset:
Expand All @@ -90,7 +106,21 @@ func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) {
log.Printf("handlePromptInput: Saving session, elapsed=%d, title=%q", elapsed, msg.title)
if err := saveSessionToDB(elapsed, true, msg.title); err != nil {
log.Printf("handlePromptInput: Error saving session: %v", err)
fmt.Println("Error saving session to DB:", err)
// Show error status
cmds = append(cmds, func() tea.Msg {
return status.InfoMsg{
Type: status.InfoTypeError,
Msg: fmt.Sprintf("Failed to save session: %v", err),
}
})
} else {
// Show success status
cmds = append(cmds, func() tea.Msg {
return status.InfoMsg{
Type: status.InfoTypeSuccess,
Msg: fmt.Sprintf("Saved: %s (%d:%02d)", msg.title, elapsed/60, elapsed%60),
}
})
}
// Refresh history table if we are logging
log.Printf("handlePromptInput: Building table view after save")
Expand All @@ -104,7 +134,8 @@ func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) {
m.startTime = time.Now().Unix()
cmd := m.progress.SetPercent(0)
m.title = ""
return m, tea.Batch(tickCmd(), cmd)
cmds = append(cmds, tickCmd(), cmd)
return m, tea.Batch(cmds...)
case promptEditTitle:
// Just update title without logging
m.title = msg.title
Expand All @@ -129,23 +160,24 @@ func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) {

// handlePromptKeyInput handles key input when prompt is active
func handlePromptKeyInput(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEnter {
switch msg.Type {
case tea.KeyEnter:
return handlePromptInput(m, promptMsg{title: m.inputBuffer, logDB: m.promptType == promptLogAndReset})
} else if msg.Type == tea.KeyEsc {
case tea.KeyEsc:
m.promptActive = false
return m, nil
} else if msg.Type == tea.KeyBackspace {
case tea.KeyBackspace:
if len(m.inputBuffer) > 0 {
m.inputBuffer = m.inputBuffer[:len(m.inputBuffer)-1]
}
return m, nil
} else if msg.Type == tea.KeySpace {
case tea.KeySpace:
// Only allow space for title prompts, not duration
if m.promptType != promptSetDuration {
m.inputBuffer += " "
}
return m, nil
} else if msg.Type == tea.KeyRunes {
case tea.KeyRunes:
for _, r := range msg.Runes {
// For duration prompts, only allow numeric characters
if m.promptType == promptSetDuration {
Expand All @@ -162,7 +194,7 @@ func handlePromptKeyInput(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}

// handleTableViewKey handles key input when in table view mode
func handleTableViewKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func handleTableViewKey(m model, _ tea.KeyMsg) (tea.Model, tea.Cmd) {
// Any key exits table view
m.mode = timerView
return m, nil
Expand Down
136 changes: 86 additions & 50 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,45 @@ import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
"tiny-timer/status"
)

var version = "dev"

func main() {
// Parse CLI flags
title, countUp, clean, debug := parseFlags()

configureLogging(debug)

if clean {
handleCleanFlag()
return
}

if err := initDB(); err != nil {
fmt.Println("Error initializing database:", err)
os.Exit(1)
}

defer closeDBConnection()

targetDuration := calculateTargetDuration(countUp)
keys := createKeyBindings()
m := createModel(title, countUp, targetDuration, keys)

if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Oh no!", err)
os.Exit(1)
}
}

func parseFlags() (title string, countUp bool, clean bool, debug bool) {
titleFlag := flag.String("title", "", "Optional title for the timer session")
countUpFlag := flag.Bool("count-up", false, "Enable count-up mode (logs task time after completion)")
cleanFlag := flag.Bool("clean", false, "Delete the database and exit")
debugFlag := flag.Bool("debug", false, "Enable debug logging to debug.log")
versionFlag := flag.Bool("version", false, "Print version and exit")

// Customize usage to include positional argument
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [minutes] [flags]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Positional arguments:\n")
Expand All @@ -35,64 +64,62 @@ func main() {
flag.PrintDefaults()
}

// Pre-process arguments to allow positional argument (minutes) before flags
// flag.Parse() stops at the first non-flag argument.
// We check if the first argument is a number and move it to the end if so.
preprocessArgs()
flag.Parse()

if *versionFlag {
fmt.Println("tiny-timer version", version)
os.Exit(0)
}

return *titleFlag, *countUpFlag, *cleanFlag, *debugFlag
}

func preprocessArgs() {
args := os.Args[1:]
if len(args) > 0 {
if _, err := strconv.ParseInt(args[0], 10, 64); err == nil {
// First arg is a number, move it to the end so flag.Parse() can see the flags
minutes := args[0]
newArgs := append(args[1:], minutes)
os.Args = append([]string{os.Args[0]}, newArgs...)
}
}
}

flag.Parse()

// Enable debug logging if flag is set
// tea.LogToFile configures the standard log package to write to debug.log
// All log.Printf() calls throughout the codebase will write to this file
if *debugFlag {
func configureLogging(debug bool) {
if debug {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to enable debug logging: %v\n", err)
} else {
defer f.Close()
}
} else {
// Silence all log output in normal mode
log.SetOutput(io.Discard)
}
}

if *cleanFlag {
dbPath, err := getDBPath()
if err != nil {
fmt.Println("Error getting database path:", err)
os.Exit(1)
}
if _, err := os.Stat(dbPath); err == nil {
if err := os.Remove(dbPath); err != nil {
fmt.Println("Error deleting database:", err)
os.Exit(1)
}
fmt.Println("Database deleted successfully.")
} else if os.IsNotExist(err) {
fmt.Println("Database does not exist.")
} else {
fmt.Println("Error checking database:", err)
func handleCleanFlag() {
dbPath, err := getDBPath()
if err != nil {
fmt.Println("Error getting database path:", err)
os.Exit(1)
}
if _, err := os.Stat(dbPath); err == nil {
if err := os.Remove(dbPath); err != nil {
fmt.Println("Error deleting database:", err)
os.Exit(1)
}
os.Exit(0)
}

// Initialize database on launch
if err := initDB(); err != nil {
fmt.Println("Error initializing database:", err)
fmt.Println("Database deleted successfully.")
} else if os.IsNotExist(err) {
fmt.Println("Database does not exist.")
} else {
fmt.Println("Error checking database:", err)
os.Exit(1)
}
}

// Read positional arg for duration, or use default
func calculateTargetDuration(countUp bool) int64 {
var targetDurationInMinutes int64 = defaultDurationInMinutes
if flag.NArg() > 0 {
if arg, err := strconv.ParseInt(flag.Arg(0), 10, 64); err == nil && arg > 0 {
Expand All @@ -101,11 +128,15 @@ func main() {
}

targetDuration := targetDurationInMinutes * 60
if *countUpFlag && flag.NArg() == 0 {
if countUp && flag.NArg() == 0 {
targetDuration = defaultCountUpDuration
}

keys := keyMap{
return targetDuration
}

func createKeyBindings() keyMap {
return keyMap{
Done: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "done"),
Expand Down Expand Up @@ -143,22 +174,27 @@ func main() {
key.WithHelp("backspace", "delete"),
),
}
}

func createModel(title string, countUp bool, targetDuration int64, keys keyMap) model {
prog := progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage())
if countUp {
prog.SetPercent(0)
} else {
prog.SetPercent(1.0)
}

statusCmp := status.NewStatusCmp()
statusCmp.SetKeyMap(keys)

m := model{
progress: progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()),
return model{
progress: prog,
startTime: time.Now().Unix(),
targetDuration: targetDuration,
title: *titleFlag,
countUpMode: *countUpFlag,
title: title,
countUpMode: countUp,
help: newHelpModel(),
keys: keys,
}

// Ensure database connection is closed on exit
defer closeDBConnection()

if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Oh no!", err)
os.Exit(1)
status: statusCmp,
}
}
2 changes: 2 additions & 0 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"tiny-timer/status"
)

type tickMsg time.Time
Expand Down Expand Up @@ -82,6 +83,7 @@ type model struct {
promptType promptType
help help.Model
keys keyMap
status *status.StatusCmp
}

// Start the event loop
Expand Down
Loading