Swatch Internet Time

Swatch Internet Time or .beat time is a decimal time format without time zones. It divides the day into 1000 parts called ".beats", where zero is midnight in UTC+1. The format was introduced by the Swatch corporation in 1998 to promote their "Beat" series of watches. Some products released around the year 2000 have integrated support for .beat time, as does the programming language PHP.

Tcl implementation

dbohdan: The following is a Tcl module, command-line utility, and GUI for Swatch Internet Time. On the command line, it can print .beat time as well as .beat time but in a different time zone. ("This should be UTC" is a common reaction to .beat time.) As a library, it can convert a [clock seconds] or [expr { [clock microseconds] / 1_000_000.0 }] timestamp to .beat time and .beat time to seconds since midnight in UTC. As a graphical application, it displays a Tk clock according to the same formatting and offset settings as on the command line. It requires Tcl 8.6 or 9.

This project is not affiliated with or endorsed by Swatch.

Command-line usage

Usage: beat.tcl [-h] [-G] [-T] [-V] [-@] [-f <format>] [-o <offset>] [-t
<font>] [--] [<timestamp>]

Arguments:
  [<timestamp>]
          Timestamp in fractional seconds since Epoch to convert to beats.
Current timestamp with microsecond precision is used if it is absent.

Options:
  -h, --help
          Print this message and exit

  -G, --gui
          Start Tk GUI clock that displays current time according to offset and
formatting options. Ignores timestamp argument.

  -T, --test
          Run test suite and exit

  -V, --version
          Print version number and exit

  -@
          Prefix beats with '@'

  -f, --format <format>
          Format string for beats, floating-point or integer (default: '%d')

  -o, --offset <offset>
          Either Tcl [clock add] arguments or whole number of hours to add to
UTC timestamp before converting it to beats (default: '1 hour')

  -t, --font <font>
          Tk font description for GUI clock (default: 'Helvetica 24')

You can use -o/--offset to produce the equivalent of .beat time in a
non-standard time zone. For example, use '0' for UTC.

Screenshot

> ./beat.tcl -G -@ -f '%.1f'

beat.tcl.png

Code

#! /usr/bin/env tclsh
# An Internet Time (.beat time) and general decimal time library.
# Copyright (c) 2021, 2024 D. Bohdan.
# License: MIT.

package require Tcl 8.6 9
package require TclOO

namespace eval ::beat {
    variable defaultFont {Helvetica 24}
    variable defaultFormat %d
    variable defaultOffset {1 hour}
    variable secondsInDay 86400
    variable version 3.1.0

    namespace export format scan *-to-*
    namespace ensemble create
}

# Epoch time in seconds -> .beats.
proc ::beat::format {t {offset {}} {format {}} {integer true}} {
    variable defaultFormat
    variable defaultOffset
    variable secondsInDay

    if {$format eq {}} {
        set format $defaultFormat
    }
    if {$offset eq {}} {
        set offset $defaultOffset
    }

    set beats [seconds-to-decimal [expr {
        fmod([clock-add-double $t {*}$offset], $secondsInDay)
    }]]
    if {$integer} {
        set beats [expr { int($beats) }]
    }

    ::format $format $beats
}

# .beats -> seconds since midnight in UTC.
proc ::beat::scan {beats {offset {}}} {
    variable defaultOffset
    variable secondsInDay

    if {$offset eq {}} {
        set offset $defaultOffset
    }

    set timestamp [clock-add-double \
        [decimal-to-seconds $beats] \
        {*}[negate-offset {*}$offset] \
    ]

    # Handle negative timestamps.
    set timestamp [expr {
        $timestamp
        + (1 + ceil($timestamp * 1.0 / $secondsInDay)) * $secondsInDay
    }]

    expr { fmod($timestamp, $secondsInDay) }
}

proc ::beat::seconds-to-decimal {s {unitsPerDay 1000}} {
    variable secondsInDay

    expr { $s * $unitsPerDay * 1.0 / $secondsInDay }
}

proc ::beat::decimal-to-seconds {dec {unitsPerDay 1000}} {
    variable secondsInDay

    expr { $dec * $secondsInDay * 1.0 / $unitsPerDay }
}

proc ::beat::clock-add-double {clockval args} {
    set int [expr { int($clockval) } ]
    set frac [expr { $clockval - $int }]

    set added [clock add $int {*}$args -timezone :UTC]
    expr { $added + $frac }
}

proc ::beat::negate-offset args {
    set negated [lmap {count unit} $args {
        list [expr { -$count }] $unit
    }]

    concat {*}$negated
}

proc ::beat::current-time {} {
    expr { [clock microseconds] / 1000000.0 }
}

oo::class create ::beat::GUI {
    variable _at
    variable _font
    variable _format
    variable _integer
    variable _offset
    variable _root

    constructor {font at offset format integer} {
        set _at $at
        set _font $font
        set _format $format
        set _integer $integer
        set _offset $offset

        my create-gui
        my update-time
    }

    destructor {
        after cancel [list [self] update-time]
        destroy $_root
    }

    method create-gui {} {
        set _root [toplevel .beatGUI]
        wm title $_root Beats

        ttk::label $_root.timeLabel -font $_font -text {}

        grid $_root.timeLabel -padx 20 -pady 20
        grid columnconfigure $_root 0 -weight 1
        grid rowconfigure $_root 0 -weight 1

        bind $_root <Escape> [list [self] destroy]
        wm protocol $_root WM_DELETE_WINDOW [list [self] destroy]
    }

    method root {} {
        return $_root
    }

    method update-time {} {
        set beats [::beat::format \
            [::beat::current-time] \
            $_offset \
            $_format \
            $_integer \
        ]

        if {$_at} {
            set beats @$beats
        }

        $_root.timeLabel configure -text $beats

        after 100 [list [self] update-time]
    }
}

proc ::beat::test {command reference {maxDiff 0}} {
    upvar 1 errors errors

    set numeric [string is double -strict $reference]
    set value [uplevel 1 $command]

    if {
        ($numeric && (abs($value - $reference) > $maxDiff))
        || (!$numeric && $value ne $reference)
    } {
        set within [expr { $maxDiff == 0 ? {} : " within $maxDiff" }]
        lappend errors "\[$command\] -> $value instead of $reference$within"
    }
}

proc ::beat::run-tests {} {
    variable defaultOffset
    variable secondsInDay

    set ref {
        1000000000 115
        1100000000 523
        1200000000 930
        1300000000 337
        1400000000 745
        1500000000 152
        1600000000 560
        1700000000 967
        1800000000 375
        1900000000 782
        2000000000 189
        1612973550 717
    }

    set offsets {{}}
    for {set i -25} {$i <= 25} {incr i} {
        lappend offsets [list $i hours]
    }

    set errors {}

    dict for {timestamp beats} $ref {
        test [list format $timestamp $defaultOffset %d true] $beats
        test [list scan $beats] [expr { fmod($timestamp, $secondsInDay) }] 86
    }

    test [list negate-offset 1 hour] {-1 hour}
    test [list negate-offset -5 hours -32 min 5 s] {5 hours 32 min -5 s}

    foreach offset $offsets {
        foreach timestamp [dict keys $ref] {
            set beats [format $timestamp $offset %f]

            test [list scan $beats $offset] [expr {
                fmod($timestamp, $secondsInDay)
            }] 86
        }
    }

    test [list seconds-to-decimal 48400 1000] 560 1
    test [list seconds-to-decimal 48400 200] 112 1
    test [list seconds-to-decimal 48400 100] 56 1
    test [list seconds-to-decimal 48400 20] 11 1
    test [list seconds-to-decimal 48400 10] 5 1

    test [list decimal-to-seconds 560 1000] 48384
    test [list decimal-to-seconds 112 200] 48384
    test [list decimal-to-seconds 56 100] 48384
    test [list decimal-to-seconds 11 20] 47520
    test [list decimal-to-seconds 5 10] 43200

    for {set i 0} {$i < 1000} {incr i} {
        test "seconds-to-decimal \[decimal-to-seconds $i\]" $i 1
    }

    for {set i 0} {$i < 86400} {incr i} {
        test "decimal-to-seconds \[seconds-to-decimal $i\]" $i 86
    }

    if {$errors ne {}} {
        error "tests failed:\n[join $errors \n]"
    }
}

proc ::beat::usage channel {
    puts $channel [::format \
        {Usage:\
            %s [-h] [-G] [-T] [-V] [-@] [-f <format>] [-o <offset>]\
            [-t <font>] [--] [<timestamp>]} \
        [file tail [info script]] \
    ]
}

proc ::beat::help {} {
    variable defaultFont
    variable defaultFormat
    variable defaultOffset

    usage stdout

    puts -nonewline [::format {
Arguments:
  [<timestamp>]
          Timestamp in fractional seconds since Epoch to convert to beats.\
          Current timestamp with microsecond precision is used if it is absent.

Options:
  -h, --help
          Print this message and exit

  -G, --gui
          Start Tk GUI clock that displays current time\
          according to offset and formatting options.\
          Ignores timestamp argument.

  -T, --test
          Run test suite and exit

  -V, --version
          Print version number and exit

  -@
          Prefix beats with '@'

  -f, --format <format>
          Format string for beats, floating-point or integer\
          (default: '%s')

  -o, --offset <offset>
          Either Tcl [clock add] arguments or whole number of hours\
          to add to UTC timestamp before converting it to beats\
          (default: '%s')

  -t, --font <font>
          Tk font description for GUI clock\
          (default: '%s')

You can use -o/--offset to produce the equivalent of .beat time\
in a non-standard time zone. For example, use '0' for UTC.
} $defaultFormat $defaultOffset $defaultFont]
}

proc ::beat::exit-with-error {message code} {
    puts stderr "Error: $message"
    exit $code
}

proc ::beat::usage-error message {
    usage stderr
    exit-with-error $message 2
}

proc ::beat::main argv {
    variable defaultFont
    variable version

    set at false
    set font $defaultFont
    set format {}
    set integer true
    set offset {}
    set printHelp false
    set printVersion false
    set startGUI false

    set len [llength $argv]
    for {set i 0} {$i < $len} {incr i} {
        set arg [lindex $argv $i]
        set value [lindex $argv $i+1]
        set haveValue [expr { $i + 1 < $len }]

        switch -glob -- $arg {
            -@ {
                set at true
            }

            -h -
            -help -
            --help -
            help {
                set printHelp true
            }

            -f -
            -format -
            --format {
                if {!$haveValue} {
                    usage-error "$arg needs a value"
                }
                set format $value
                incr i

                if {$value eq {}} {
                    continue
                }

                set validFormat false
                foreach testValue {123.45 123} {
                    try {
                        ::format $format $testValue
                        set validFormat true
                        set integer [string is integer -strict $testValue]
                        break
                    } trap {TCL FORMAT} _ {} trap {TCL VALUE} _ {
                        # Do nothing.
                    }
                }

                if {!$validFormat} {
                    usage-error "invalid format: $format"
                }
            }

            -G -
            -gui -
            --gui {
                set startGUI true
            }

            -o -
            -offset -
            --offset {
                if {!$haveValue} {
                    usage-error "$arg needs a value"
                }
                set offset $value
                incr i

                if {[string is double -strict $offset]} {
                    set offset [list $offset hours]
                }

                try {
                    clock-add-double 0 {*}$offset
                } trap CLOCK _ {
                    usage-error "invalid offset: $offset"
                }
            }

            -t -
            -font -
            --font {
                if {!$haveValue} {
                    usage-error "$arg needs a value"
                }
                set font $value
                incr i
            }

            -T -
            -test -
            --test -
            test {
                run-tests
                exit 0
            }

            -V -
            -version -
            --version -
            version {
                set printVersion true
            }

            -- {
                incr i
                break
            }

            -* {
                if {[llength $argv] == 1 && [string is double -strict $arg]} {
                    break
                }

                usage-error "unknown option: $arg"
            }

            default {
                break
            }
        }
    }

    set argv [lrange $argv $i end]

    switch -- [llength $argv] {
        0 {
            set timestamp [current-time]
        }

        1 {
            set timestamp $argv

            # Check the format and reject NaN and the infinities.
            if {
                ![string is double -strict $timestamp]
                || $timestamp != $timestamp
                || abs($timestamp) == inf
            } {
                usage-error "invalid timestamp: $timestamp"
            }
        }

        default {
            usage-error {too many arguments}
        }
    }

    if {$printHelp} {
        help
        exit 0
    }

    if {$printVersion} {
        puts $version
        exit 0
    }

    if {$startGUI} {
        try {
            package require Tk
            package require Ttk
            wm withdraw .

            set gui [GUI new $font $at $offset $format $integer]
            bind [$gui root] <Destroy> [list exit 0]
        } on error e {
            exit-with-error $e 1
        }

        return
    }

    if {$at} {
        puts -nonewline @
    }
    puts [format $timestamp $offset $format $integer]
}

# If this is the main script...
if {[info exists argv0] && ([file tail [info script]] eq [file tail $argv0])} {
    beat::main $argv
}

See also