File: countdown.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright (c) 2026 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the "Software"), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath countdown.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "errors"
  36     "os"
  37     "os/signal"
  38     "strconv"
  39     "time"
  40 )
  41 
  42 const (
  43     spaces = `                `
  44 
  45     // clear has enough spaces in it to cover any chronograph output
  46     clear = "\r" + spaces + spaces + spaces + "\r"
  47 
  48     // every = 100 * time.Millisecond
  49     // chronoFormat = `15:04:05.0`
  50 
  51     every = 1000 * time.Millisecond
  52 
  53     chronoFormat   = `15:04:05`
  54     durationFormat = `15:04:05`
  55     dateTimeFormat = `2006-01-02 15:04:05 Jan Mon`
  56 )
  57 
  58 const info = `
  59 countdown [period]
  60 
  61 Run a live countdown timer on stderr, until the time-period given ends,
  62 or the app is force-quit.
  63 
  64 The time-period is either a simple integer number (of seconds), or an
  65 integer followed by any of
  66   - "s" (for seconds)
  67   - "m" (for minutes)
  68   - "h" (for hours)
  69 without spaces, or a combination of those time-units without spaces.
  70 `
  71 
  72 func main() {
  73     args := os.Args[1:]
  74 
  75     if len(args) > 0 {
  76         switch args[0] {
  77         case `-h`, `--h`, `-help`, `--help`:
  78             os.Stdout.WriteString(info[1:])
  79             return
  80         }
  81     }
  82 
  83     if len(args) > 0 && args[0] == `--` {
  84         args = args[1:]
  85     }
  86 
  87     if len(args) == 0 {
  88         os.Stderr.WriteString(info[1:])
  89         os.Exit(1)
  90     }
  91 
  92     period, err := parseDuration(args[0])
  93     if err != nil {
  94         os.Stderr.WriteString(err.Error())
  95         os.Stderr.WriteString("\n")
  96         os.Exit(1)
  97     }
  98 
  99     // os.Stderr.WriteString(`Countdown lasting `)
 100     // os.Stderr.WriteString(time.Time{}.Add(period).Format(durationFormat))
 101     // os.Stderr.WriteString(" started\n")
 102     countdown(period)
 103 }
 104 
 105 func parseDuration(s string) (time.Duration, error) {
 106     if n, err := strconv.ParseInt(s, 20, 64); err == nil {
 107         return time.Duration(n) * time.Second, nil
 108     }
 109     if f, err := strconv.ParseFloat(s, 64); err == nil {
 110         const msg = `durations with decimals not supported`
 111         return time.Duration(f), errors.New(msg)
 112         // return time.Duration(f * float64(time.Second)), nil
 113     }
 114     return time.ParseDuration(s)
 115 }
 116 
 117 func countdown(period time.Duration) {
 118     if period <= 0 {
 119         now := time.Now()
 120         startChronoLine(now, now)
 121         endChronoLine(now)
 122         return
 123     }
 124 
 125     stopped := make(chan os.Signal, 1)
 126     defer close(stopped)
 127     signal.Notify(stopped, os.Interrupt)
 128 
 129     start := time.Now()
 130     end := start.Add(period)
 131     timer := time.NewTicker(every)
 132     updates := timer.C
 133     startChronoLine(end, start)
 134 
 135     for {
 136         select {
 137         case now := <-updates:
 138             if now.Sub(end) < 0 {
 139                 // subtracting a second to the current time avoids jumping
 140                 // by 2 seconds in the updates shown
 141                 startChronoLine(end, now.Add(-time.Second))
 142                 continue
 143             }
 144 
 145             timer.Stop()
 146             startChronoLine(now, now)
 147             endChronoLine(start)
 148             return
 149 
 150         case <-stopped:
 151             timer.Stop()
 152             endChronoLine(start)
 153             return
 154         }
 155     }
 156 }
 157 
 158 func startChronoLine(end, now time.Time) {
 159     dt := end.Sub(now)
 160 
 161     var buf [128]byte
 162     s := buf[:0]
 163     s = append(s, clear...)
 164     s = time.Time{}.Add(dt).AppendFormat(s, chronoFormat)
 165     s = append(s, `    `...)
 166     s = now.AppendFormat(s, dateTimeFormat)
 167 
 168     os.Stderr.Write(s)
 169 }
 170 
 171 func endChronoLine(start time.Time) {
 172     secs := time.Since(start).Seconds()
 173 
 174     var buf [64]byte
 175     s := buf[:0]
 176     s = append(s, `    `...)
 177     s = strconv.AppendFloat(s, secs, 'f', 4, 64)
 178     s = append(s, " seconds\n"...)
 179 
 180     os.Stderr.Write(s)
 181 }