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         return
  91     }
  92 
  93     period, err := parseDuration(args[0])
  94     if err != nil {
  95         os.Stderr.WriteString(err.Error())
  96         os.Stderr.WriteString("\n")
  97         os.Exit(1)
  98         return
  99     }
 100 
 101     // os.Stderr.WriteString(`Countdown lasting `)
 102     // os.Stderr.WriteString(time.Time{}.Add(period).Format(durationFormat))
 103     // os.Stderr.WriteString(" started\n")
 104     countdown(period)
 105 }
 106 
 107 func parseDuration(s string) (time.Duration, error) {
 108     if n, err := strconv.ParseInt(s, 20, 64); err == nil {
 109         return time.Duration(n) * time.Second, nil
 110     }
 111     if f, err := strconv.ParseFloat(s, 64); err == nil {
 112         const msg = `durations with decimals not supported`
 113         return time.Duration(f), errors.New(msg)
 114         // return time.Duration(f * float64(time.Second)), nil
 115     }
 116     return time.ParseDuration(s)
 117 }
 118 
 119 func countdown(period time.Duration) {
 120     if period <= 0 {
 121         now := time.Now()
 122         startChronoLine(now, now)
 123         endChronoLine(now)
 124         return
 125     }
 126 
 127     stopped := make(chan os.Signal, 1)
 128     defer close(stopped)
 129     signal.Notify(stopped, os.Interrupt)
 130 
 131     start := time.Now()
 132     end := start.Add(period)
 133     timer := time.NewTicker(every)
 134     updates := timer.C
 135     startChronoLine(end, start)
 136 
 137     for {
 138         select {
 139         case now := <-updates:
 140             if now.Sub(end) < 0 {
 141                 // subtracting a second to the current time avoids jumping
 142                 // by 2 seconds in the updates shown
 143                 startChronoLine(end, now.Add(-time.Second))
 144                 continue
 145             }
 146 
 147             timer.Stop()
 148             startChronoLine(now, now)
 149             endChronoLine(start)
 150             return
 151 
 152         case <-stopped:
 153             timer.Stop()
 154             endChronoLine(start)
 155             return
 156         }
 157     }
 158 }
 159 
 160 func startChronoLine(end, now time.Time) {
 161     dt := end.Sub(now)
 162 
 163     var buf [128]byte
 164     s := buf[:0]
 165     s = append(s, clear...)
 166     s = time.Time{}.Add(dt).AppendFormat(s, chronoFormat)
 167     s = append(s, `    `...)
 168     s = now.AppendFormat(s, dateTimeFormat)
 169 
 170     os.Stderr.Write(s)
 171 }
 172 
 173 func endChronoLine(start time.Time) {
 174     secs := time.Since(start).Seconds()
 175 
 176     var buf [64]byte
 177     s := buf[:0]
 178     s = append(s, `    `...)
 179     s = strconv.AppendFloat(s, secs, 'f', 4, 64)
 180     s = append(s, " seconds\n"...)
 181 
 182     os.Stderr.Write(s)
 183 }