A simple command line pomodoro timer
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

144 lines
3.7 KiB

package main
import (
const (
ConfigDir = ".config/gomodoro"
LogFileName = "gomodoro.json"
// Gomodoro holds the config for the current timer
type TimerConfig struct {
StartTime time.Time
Duration time.Duration
Interval time.Duration
// PomoStatus Represents the status of the Gomodor timer at a given moment
type PomoStatus struct {
Status string
StartTime int64
DurationSeconds float64
RemainingSeconds float64
Done bool
// timeRemaining returns the remaining duration rounded to the nearest interval
func timeRemaining(start time.Time, duration time.Duration, interval time.Duration) time.Duration {
return (duration - time.Since(start)).Round(interval)
// fmtJSON returns a JSON string for a given interface and safely returns an err
func fmtJSON(obj interface{}) (string, error) {
bstring, err := json.Marshal(obj)
if err != nil {
return "", err
return string(bstring[:]), err
// logRemaining prints a JSON formatted string logging the time remaining in the timer
func logRemaining(logFile *os.File, timerConfig TimerConfig) error {
timeRemaining := timeRemaining(timerConfig.StartTime, timerConfig.Duration, timerConfig.Interval)
err := logTextStatus(logFile, timerConfig, timeRemaining.String(), false)
return err
// logTextStatus logs the status of a timer with a given string
func logTextStatus(logFile *os.File, timerConfig TimerConfig, status string, done bool) error {
// TODO: Find a better way than calculating this twice
timeRemaining := timeRemaining(timerConfig.StartTime, timerConfig.Duration, timerConfig.Interval)
jstring, err := fmtJSON(&PomoStatus{
Status: status,
StartTime: timerConfig.StartTime.Unix(),
DurationSeconds: timerConfig.Duration.Seconds(),
RemainingSeconds: timeRemaining.Seconds(),
Done: done,
if err == nil {
if logFile != nil {
logFile.WriteString(jstring + "\n")
fmt.Printf("%s / %s\n", status, timerConfig.Duration.String())
return err
func tryLock() (net.Listener, error) {
return net.Listen("unix", "/tmp/gomodoro")
func openLogFile() (*os.File, error) {
dir := path.Join(os.Getenv("HOME"), ConfigDir)
os.MkdirAll(dir, os.ModePerm)
logFile, err := os.OpenFile(path.Join(dir, LogFileName), os.O_APPEND|os.O_RDWR|os.O_CREATE, os.ModePerm)
return logFile, err
func main() {
var duration time.Duration
flag.DurationVar(&duration, "duration", 25*time.Minute, "Gomodoro duration")
var interval time.Duration
flag.DurationVar(&interval, "interval", 1*time.Second, "Gomodoro tick interval")
/* Haven't found a good way to tail a file in golang yet
* var status bool
* flag.BoolVar(&status, "status", false, "Only display current Gomodoro status")
// Ensure obtain lock
lock, err := tryLock()
if err != nil {
// TODO: More helpful message
defer lock.Close()
// Open file for logging
logFile, err := openLogFile()
if err != nil {
defer logFile.Close()
// Start timer
start := time.Now()
timerConfig := TimerConfig{
StartTime: start,
Duration: duration,
Interval: interval,
tick := time.Tick(interval)
end := time.After(duration)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, os.Kill, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case <-tick:
logRemaining(logFile, timerConfig)
case <-end:
logTextStatus(logFile, timerConfig, "Complete", true)
case <-stop:
logTextStatus(logFile, timerConfig, "Interrupted", true)
time.Sleep(50 * time.Millisecond)