Skip to content

Instantly share code, notes, and snippets.

@felipealfonsog
Last active January 14, 2026 08:01
Show Gist options
  • Select an option

  • Save felipealfonsog/5f6e3786daa5aabd34b5ebf620fd5528 to your computer and use it in GitHub Desktop.

Select an option

Save felipealfonsog/5f6e3786daa5aabd34b5ebf620fd5528 to your computer and use it in GitHub Desktop.
TimeOps / WatchOps — atomic time drift analytics
nano watchops-v2.sh
chmod +x watchops-v2.sh
./watchops-v2.sh menu
sudo pacman -S libnewt dialog
brew install newt dialog
./watchops-v2.sh add-watch "LivingRoom-Japan" quartz 5 when_exceeds "Reloj grande living"
#!/usr/bin/env bash
set -euo pipefail
APP="WatchOps"
VERSION="2.0"
DB="${WATCHOPS_DB:-$HOME/.watchops_measurements.csv}"
WATCHES="${WATCHOPS_WATCHES:-$HOME/.watchops_watches.csv}"
# -----------------------------
# UI detection
# -----------------------------
HAS_WHIPTAIL=0
HAS_DIALOG=0
command -v whiptail >/dev/null 2>&1 && HAS_WHIPTAIL=1
command -v dialog >/dev/null 2>&1 && HAS_DIALOG=1
is_macos() { [[ "$(uname -s)" == "Darwin" ]]; }
epoch_now() { date +%s; }
fmt_date() {
local epoch="$1"
if is_macos; then date -r "$epoch" "+%Y-%m-%d %H:%M:%S"
else date -d "@$epoch" "+%Y-%m-%d %H:%M:%S"
fi
}
# -----------------------------
# Notifications
# -----------------------------
notify() {
local title="$1"
local body="$2"
if is_macos && command -v osascript >/dev/null 2>&1; then
osascript -e "display notification \"${body//\"/\\\"}\" with title \"${title//\"/\\\"}\"" >/dev/null 2>&1 || true
return 0
fi
if command -v notify-send >/dev/null 2>&1; then
notify-send "$title" "$body" >/dev/null 2>&1 || true
return 0
fi
# no notifier available (silent)
return 0
}
# -----------------------------
# Init files
# -----------------------------
init_files() {
if [[ ! -f "$DB" ]]; then
echo "epoch,iso,watch,offset_s,source,notes" > "$DB"
fi
if [[ ! -f "$WATCHES" ]]; then
echo "watch,type,tol_s,policy,notes" > "$WATCHES"
echo "Chronobike-Daily,quartz,5,when_exceeds,Daily Festina Chronobike" >> "$WATCHES"
echo "Chronobike-Special,quartz,10,scheduled,Special Festina Chronobike" >> "$WATCHES"
echo "Ruhla,automatic,30,manual,Vintage automatic (keepsake)" >> "$WATCHES"
fi
}
# -----------------------------
# DB write
# -----------------------------
add_measurement() {
local watch="$1"
local offset="$2"
local source="$3"
local notes="${4:-}"
local epoch iso
epoch="$(epoch_now)"
iso="$(fmt_date "$epoch")"
printf "%s,%s,%s,%s,%s,%s\n" \
"$epoch" "$iso" "$watch" "$offset" "$source" "${notes//,/;}" >> "$DB"
}
# -----------------------------
# Watches config
# -----------------------------
watch_exists() {
local watch="$1"
awk -F',' -v w="$watch" 'NR>1 && $1==w {found=1} END{exit(found?0:1)}' "$WATCHES"
}
add_watch() {
local watch="$1"
local type="$2" # quartz|automatic
local tol="$3" # seconds
local policy="$4" # when_exceeds|scheduled|manual
local notes="${5:-}"
if watch_exists "$watch"; then
echo "Watch already exists: $watch"
return 1
fi
printf "%s,%s,%s,%s,%s\n" "$watch" "$type" "$tol" "$policy" "${notes//,/;}" >> "$WATCHES"
}
list_watches() {
awk -F',' 'NR>1 {print $1}' "$WATCHES"
}
# -----------------------------
# Reference clock report (NTP offsets)
# -----------------------------
ntp_offset_seconds() {
local host="$1"
if command -v chronyc >/dev/null 2>&1; then
chronyc tracking 2>/dev/null | awk -F': ' '
/System time/ {
gsub(" seconds fast of NTP time","",$2);
gsub(" seconds slow of NTP time","",$2);
print $2; exit
}' || true
return 0
fi
if is_macos && command -v sntp >/dev/null 2>&1; then
sntp -sS "$host" 2>/dev/null | awk '
/offset/ {
for (i=1;i<=NF;i++) if ($i ~ /^[-+0-9.]+$/) {print $i; exit}
}' || true
return 0
fi
if command -v ntpdate >/dev/null 2>&1; then
ntpdate -q "$host" 2>/dev/null | awk '
/offset/ {for (i=1;i<=NF;i++) if ($i=="offset") {print $(i+1); exit}}' || true
return 0
fi
echo ""
}
ref_report() {
local now local_iso
now="$(epoch_now)"
local_iso="$(fmt_date "$now")"
local nist apple pool
nist="$(ntp_offset_seconds time.nist.gov || true)"
apple="$(ntp_offset_seconds time.apple.com || true)"
pool="$(ntp_offset_seconds pool.ntp.org || true)"
cat <<EOF
[$APP $VERSION] Reference Clock Report
Local epoch: $now
Local time : $local_iso
System vs NTP offsets:
- NIST (time.nist.gov): ${nist:-N/A}
- Apple (time.apple.com): ${apple:-N/A}
- Pool (pool.ntp.org) : ${pool:-N/A}
Note: time.is is a visual/manual reference here (no stable public API).
EOF
}
# -----------------------------
# Analytics: regression drift + predictions
# -----------------------------
analyze_watch_raw() {
local watch="$1"
awk -F',' -v w="$watch" '
function abs(x){return x<0?-x:x}
NR>1 && $3==w {
t = $1/86400.0
o = $4+0.0
sum_t += t
sum_o += o
sum_tt += (t*t)
sum_to += (t*o)
if (n==0) {epoch0=$1; o0=o}
epoch1=$1; o1=o
n++
}
END{
if (n < 2) { print "ERR|Need>=2"; exit 0 }
denom = (n*sum_tt - sum_t*sum_t)
if (denom == 0) { print "ERR|Degenerate"; exit 0 }
b = (n*sum_to - sum_t*sum_o) / denom # drift s/day
a = (sum_o - b*sum_t)/n
# last known
drift=b; cur=o1; last=epoch1; first=epoch0; firsto=o0
printf("OK|samples=%d|first=%d|firsto=%+.3f|last=%d|lasto=%+.3f|drift=%+.6f\n",
n, first, firsto, last, cur, drift)
# thresholds based on current offset and drift (approx)
ad = abs(drift)
ac = abs(cur)
if (ad < 0.00001) {
print "THR|5|days=INF|epoch=0"
print "THR|10|days=INF|epoch=0"
print "THR|30|days=INF|epoch=0"
print "THR|60|days=INF|epoch=0"
exit 0
}
thr[1]=5; thr[2]=10; thr[3]=30; thr[4]=60
for (i=1;i<=4;i++) {
d = (thr[i]-ac)/ad
if (d < 0) d = 0
hit = last + d*86400.0
printf("THR|%d|days=%.3f|epoch=%d\n", thr[i], d, int(hit))
}
}
' "$DB"
}
# -----------------------------
# Dashboard (table)
# -----------------------------
dashboard() {
# Prints a table for all watches in WATCHES.
# Columns: Watch, Type, Tol, Policy, LastOffset, Drift, HitTol, LastSync
local now
now="$(epoch_now)"
printf "%-18s %-9s %-6s %-12s %-10s %-12s %-19s %-19s\n" \
"WATCH" "TYPE" "TOL" "POLICY" "OFFSET" "DRIFT(s/d)" "HIT|TOL|" "LAST_SYNC"
printf "%-18s %-9s %-6s %-12s %-10s %-12s %-19s %-19s\n" \
"------------------" "---------" "------" "------------" "----------" "------------" "-------------------" "-------------------"
awk -F',' '
NR>1 {print $1 "," $2 "," $3 "," $4}
' "$WATCHES" | while IFS=',' read -r w type tol policy; do
# default values
local offset="N/A" drift="N/A" hit="N/A" lastsync="N/A" samples="0"
local hit_epoch="0"
local raw
raw="$(analyze_watch_raw "$w" 2>/dev/null || true)"
if echo "$raw" | grep -q '^OK|'; then
samples="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^samples="){sub("samples=","",$i); print $i}}')"
lastsync="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^last="){sub("last=","",$i); print $i}}')"
offset="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^lasto="){sub("lasto=","",$i); print $i}}')"
drift="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^drift="){sub("drift=","",$i); print $i}}')"
# determine time to hit tol threshold (abs(offset) -> tol)
hit_epoch="$(echo "$raw" | awk -F'|' -v T="$tol" '
/^THR\|/ {
thr=$2
for(i=1;i<=NF;i++){
if($i~"^epoch="){sub("epoch=","",$i); e=$i}
if($i~"^days="){sub("days=","",$i); d=$i}
}
if(thr==T){ print e "|" d; exit }
}')"
if [[ -n "$hit_epoch" ]]; then
local e d
e="${hit_epoch%%|*}"
d="${hit_epoch##*|}"
if [[ "$e" == "0" || "$d" == "INF" ]]; then
hit="stable"
else
hit="$(fmt_date "$e")"
fi
else
hit="(no data)"
fi
lastsync="$(fmt_date "$lastsync")"
else
samples="0"
lastsync="(no data)"
fi
printf "%-18s %-9s %-6s %-12s %-10s %-12s %-19s %-19s\n" \
"$w" "$type" "${tol}s" "$policy" "$offset" "$drift" "$hit" "$lastsync"
done
}
# -----------------------------
# Due checks + notify
# -----------------------------
due_check() {
# For each watch, evaluate if abs(offset) >= tol or within horizon days.
# Horizon default 3 days (can override WATCHOPS_HORIZON_DAYS)
local horizon="${WATCHOPS_HORIZON_DAYS:-3}"
local now
now="$(epoch_now)"
local any=0
while IFS=',' read -r w type tol policy notes; do
[[ "$w" == "watch" ]] && continue
local raw
raw="$(analyze_watch_raw "$w" 2>/dev/null || true)"
if ! echo "$raw" | grep -q '^OK|'; then
continue
fi
local offset drift last_epoch
offset="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^lasto="){sub("lasto=","",$i); print $i}}')"
drift="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^drift="){sub("drift=","",$i); print $i}}')"
last_epoch="$(echo "$raw" | awk -F'|' 'NR==1{for(i=1;i<=NF;i++) if($i~"^last="){sub("last=","",$i); print $i}}')"
# abs(offset) >= tol ?
local exceed
exceed="$(awk -v o="$offset" -v t="$tol" 'BEGIN{if((o<0?-o:o) >= t) print 1; else print 0}')"
if [[ "$exceed" == "1" && "$policy" != "manual" ]]; then
any=1
echo "DUE: $w offset=$offset tol=${tol}s policy=$policy"
notify "$APP: Sync due" "$w offset $offset s (tol ${tol}s)"
continue
fi
# Also warn if predicted within horizon days
# Find THR|tol
local d
d="$(echo "$raw" | awk -F'|' -v T="$tol" '
/^THR\|/ {
thr=$2
for(i=1;i<=NF;i++){
if($i~"^days="){sub("days=","",$i); days=$i}
}
if(thr==T){ print days; exit }
}')"
if [[ -n "$d" && "$d" != "INF" ]]; then
local soon
soon="$(awk -v days="$d" -v h="$horizon" 'BEGIN{if(days <= h) print 1; else print 0}')"
if [[ "$soon" == "1" && "$policy" != "manual" ]]; then
any=1
echo "SOON: $w reaches |tol| in ~${d} days"
notify "$APP: Sync soon" "$w hits tol in ~${d} days"
fi
fi
done < "$WATCHES"
if [[ "$any" == "0" ]]; then
echo "All good. No watches due/soon within ${horizon} days."
fi
}
# -----------------------------
# Export markdown
# -----------------------------
export_md() {
local out="${1:-$HOME/watchops_report.md}"
{
echo "# WatchOps Report"
echo
echo "- Generated: $(fmt_date "$(epoch_now)")"
echo "- DB: \`$DB\`"
echo "- Watches: \`$WATCHES\`"
echo
echo "## Dashboard"
echo
echo '```'
dashboard
echo '```'
echo
echo "## Reference Clock"
echo
echo '```'
ref_report
echo '```'
} > "$out"
echo "Wrote: $out"
}
# -----------------------------
# Minimal TUI helpers
# -----------------------------
ui_msg() {
local msg="$1"
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
whiptail --title "$APP" --msgbox "$msg" 20 86
elif [[ $HAS_DIALOG -eq 1 ]]; then
dialog --title "$APP" --msgbox "$msg" 20 86
clear
else
echo "$msg"
echo
read -r -p "Enter..."
fi
}
ui_input() {
local title="$1" prompt="$2" def="${3:-}"
local v=""
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
v="$(whiptail --title "$title" --inputbox "$prompt" 12 86 "$def" 3>&1 1>&2 2>&3 || true)"
elif [[ $HAS_DIALOG -eq 1 ]]; then
v="$(dialog --title "$title" --inputbox "$prompt" 12 86 "$def" 3>&1 1>&2 2>&3 || true)"
clear
else
read -r -p "$prompt [$def]: " v
v="${v:-$def}"
fi
echo "$v"
}
ui_menu() {
while true; do
local choice=""
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
choice="$(whiptail --title "$APP v$VERSION" --menu "Time Control Center" 20 86 10 \
"1" "Dashboard (multi-watch)" \
"2" "Add measurement" \
"3" "Add watch/profile" \
"4" "Due check + notify" \
"5" "Reference clock report" \
"6" "Export Markdown report" \
"0" "Exit" 3>&1 1>&2 2>&3 || true)"
elif [[ $HAS_DIALOG -eq 1 ]]; then
choice="$(dialog --clear --title "$APP v$VERSION" --menu "Time Control Center" 20 86 10 \
"1" "Dashboard (multi-watch)" \
"2" "Add measurement" \
"3" "Add watch/profile" \
"4" "Due check + notify" \
"5" "Reference clock report" \
"6" "Export Markdown report" \
"0" "Exit" 3>&1 1>&2 2>&3 || true)"
clear
else
echo "===== $APP v$VERSION ====="
echo "1) Dashboard"
echo "2) Add measurement"
echo "3) Add watch/profile"
echo "4) Due check + notify"
echo "5) Reference clock report"
echo "6) Export Markdown report"
echo "0) Exit"
read -r -p "> " choice
fi
case "${choice:-}" in
1) ui_msg "$(dashboard)" ;;
2) ui_add_measurement ;;
3) ui_add_watch ;;
4) ui_msg "$(due_check)" ;;
5) ui_msg "$(ref_report)" ;;
6)
local out
out="$(ui_input "$APP" "Output path for Markdown report" "$HOME/watchops_report.md")"
local res
res="$(export_md "$out")"
ui_msg "$res"
;;
0|"") break ;;
*) ui_msg "Invalid option." ;;
esac
done
}
ui_add_measurement() {
local w offset source notes
w="$(ui_input "$APP" "Watch name (must exist in watches CSV)" "Chronobike-Daily")"
if ! watch_exists "$w"; then
ui_msg "Watch '$w' not found in $WATCHES.\nAdd it first (menu: Add watch/profile)."
return
fi
offset="$(ui_input "$APP" "Offset in seconds. Example: +2.3 or -1.0" "+0.0")"
# numeric check
if ! awk "BEGIN{exit($offset==($offset+0)?0:1)}" 2>/dev/null; then
ui_msg "Invalid offset value."
return
fi
source="$(ui_input "$APP" "Source label (time.is / NIST / AppleNTP / PoolNTP / Manual)" "time.is")"
notes="$(ui_input "$APP" "Notes (optional)" "")"
add_measurement "$w" "$offset" "$source" "$notes"
ui_msg "Saved.\nWatch: $w\nOffset: ${offset}s\nSource: $source\nDB: $DB"
}
ui_add_watch() {
local w type tol policy notes
w="$(ui_input "$APP" "New watch name" "MyWatch")"
type="$(ui_input "$APP" "Type (quartz|automatic)" "quartz")"
tol="$(ui_input "$APP" "Tolerance seconds (e.g. 5, 10, 30)" "5")"
policy="$(ui_input "$APP" "Policy (when_exceeds|scheduled|manual)" "when_exceeds")"
notes="$(ui_input "$APP" "Notes (optional)" "")"
if [[ "$type" != "quartz" && "$type" != "automatic" ]]; then
ui_msg "Type must be quartz or automatic."
return
fi
if ! awk "BEGIN{exit($tol==int($tol) && $tol>0?0:1)}" 2>/dev/null; then
ui_msg "Tolerance must be a positive integer."
return
fi
if add_watch "$w" "$type" "$tol" "$policy" "$notes" 2>/dev/null; then
ui_msg "Added watch:\n$w ($type) tol=${tol}s policy=$policy\nFile: $WATCHES"
else
ui_msg "Could not add watch. Maybe it already exists."
fi
}
# -----------------------------
# CLI
# -----------------------------
usage() {
cat <<EOF
$APP $VERSION
Files:
Measurements DB: $DB
Watches config : $WATCHES
Usage:
$0 menu
$0 dashboard
$0 add-measure <watch> <offset_s> <source> [notes]
$0 add-watch <watch> <type> <tol_s> <policy> [notes]
$0 due
$0 ref
$0 export-md [output.md]
Env:
WATCHOPS_DB, WATCHOPS_WATCHES, WATCHOPS_HORIZON_DAYS
Examples:
$0 menu
$0 dashboard
$0 add-measure Chronobike-Daily +2.3 time.is "living room sync"
$0 due
$0 export-md ~/watchops_report.md
EOF
}
main() {
init_files
local cmd="${1:-menu}"
case "$cmd" in
menu) ui_menu ;;
dashboard) dashboard ;;
add-measure)
[[ $# -ge 4 ]] || { usage; exit 1; }
add_measurement "$2" "$3" "$4" "${5:-}"
echo "OK"
;;
add-watch)
[[ $# -ge 5 ]] || { usage; exit 1; }
add_watch "$2" "$3" "$4" "$5" "${6:-}"
echo "OK"
;;
due) due_check ;;
ref) ref_report ;;
export-md) export_md "${2:-$HOME/watchops_report.md}" ;;
*) usage; exit 1 ;;
esac
}
main "$@"
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# watchops.sh — Watch Drift Operator Console (macOS + Linux)
# Felipe edition: atomic time discipline
# ============================================================
APP="WatchOps"
VERSION="1.0"
DB="${WATCHOPS_DB:-$HOME/.watchops_measurements.csv}"
DEFAULT_WATCH="${WATCHOPS_WATCH:-Chronobike-Daily}"
# UI helpers
HAS_WHIPTAIL=0
HAS_DIALOG=0
command -v whiptail >/dev/null 2>&1 && HAS_WHIPTAIL=1
command -v dialog >/dev/null 2>&1 && HAS_DIALOG=1
# ------------------------------------------------------------
# Platform / date helpers
# ------------------------------------------------------------
is_macos() { [[ "$(uname -s)" == "Darwin" ]]; }
epoch_now() { date +%s; }
fmt_date() {
local epoch="$1"
if is_macos; then
date -r "$epoch" "+%Y-%m-%d %H:%M:%S"
else
date -d "@$epoch" "+%Y-%m-%d %H:%M:%S"
fi
}
# ------------------------------------------------------------
# DB
# ------------------------------------------------------------
init_db() {
if [[ ! -f "$DB" ]]; then
printf "epoch,iso,watch,offset_s,source,notes\n" > "$DB"
fi
}
db_add() {
local watch="$1"
local offset="$2"
local source="$3"
local notes="${4:-}"
local epoch iso
epoch="$(epoch_now)"
iso="$(fmt_date "$epoch")"
printf "%s,%s,%s,%s,%s,%s\n" \
"$epoch" "$iso" "$watch" "$offset" "$source" "${notes//,/;}" >> "$DB"
}
db_count_watch() {
local watch="$1"
awk -F',' -v w="$watch" 'NR>1 && $3==w {c++} END{print c+0}' "$DB"
}
db_list_watches() {
awk -F',' 'NR>1 {print $3}' "$DB" | sort -u
}
# ------------------------------------------------------------
# NTP time checks (reference comparisons)
# ------------------------------------------------------------
ntp_offset_seconds() {
# Returns offset in seconds as float-ish.
# macOS: sntp is usually present.
# Linux: try chronyc tracking (if available), else ntpdate -q, else sntp.
local host="$1"
if command -v chronyc >/dev/null 2>&1; then
# Not host-specific, but still useful: local clock vs reference
# We'll use it only if system is already configured
chronyc tracking 2>/dev/null | awk -F': ' '
/System time/ {gsub(" seconds fast of NTP time","",$2); gsub(" seconds slow of NTP time","",$2); print $2; exit}
' || true
return 0
fi
if is_macos && command -v sntp >/dev/null 2>&1; then
# sntp output example includes: "+/- offset"
# We'll parse a line that contains "offset"
sntp -sS "$host" 2>/dev/null | awk '
/offset/ {
# try to capture a numeric token
for (i=1;i<=NF;i++) if ($i ~ /^[-+0-9.]+$/) {print $i; exit}
}
' || true
return 0
fi
if command -v ntpdate >/dev/null 2>&1; then
ntpdate -q "$host" 2>/dev/null | awk '
/offset/ {
for (i=1;i<=NF;i++) if ($i=="offset") {print $(i+1); exit}
}
' || true
return 0
fi
# fallback: no tool
echo ""
}
show_reference_clock_report() {
local now local_iso
now="$(epoch_now)"
local_iso="$(fmt_date "$now")"
local nist apple pool
nist="$(ntp_offset_seconds time.nist.gov || true)"
apple="$(ntp_offset_seconds time.apple.com || true)"
pool="$(ntp_offset_seconds pool.ntp.org || true)"
cat <<EOF
[$APP $VERSION] Reference Clock Report
Local epoch: $now
Local time : $local_iso
NTP offsets (system vs reference):
- NIST (time.nist.gov): ${nist:-N/A}
- Apple (time.apple.com): ${apple:-N/A}
- Pool (pool.ntp.org) : ${pool:-N/A}
Note:
- This does NOT read time.is (no stable public API).
- Use this report to validate your device time chain.
EOF
}
# ------------------------------------------------------------
# Analytics
# ------------------------------------------------------------
analyze_watch() {
local watch="$1"
local n
n="$(db_count_watch "$watch")"
if [[ "$n" -lt 2 ]]; then
echo "Need >= 2 measurements for '$watch'. Current: $n"
return 0
fi
# We will run linear regression:
# offset = a + b * tdays
# b = drift (s/day)
# Also compute latest offset and predictions.
awk -F',' -v w="$watch" '
function abs(x){return x<0?-x:x}
NR>1 && $3==w {
t = $1/86400.0
o = $4+0.0
sum_t += t
sum_o += o
sum_tt += (t*t)
sum_to += (t*o)
if (n==0) {t0=t; o0=o; epoch0=$1}
t1=t; o1=o; epoch1=$1
n++
}
END {
if (n < 2) { print "Not enough data"; exit 0 }
denom = (n*sum_tt - sum_t*sum_t)
if (denom == 0) { print "Degenerate dataset"; exit 0 }
b = (n*sum_to - sum_t*sum_o) / denom # s/day
a = (sum_o - b*sum_t) / n
# last known
drift = b
cur = o1
epoch_last = epoch1
printf("Watch: %s\n", w)
printf("Samples: %d\n", n)
printf("First: t=%s offset=%+.3fs\n", epoch0, o0)
printf("Last: t=%s offset=%+.3fs\n", epoch_last, cur)
printf("Estimated drift: %+0.6f s/day\n", drift)
# Predictions for thresholds
# Solve for |a + b*t| = thr near future relative to last point:
# We use current offset + drift*D as approximation.
if (abs(drift) < 0.00001) {
print "Drift ~ 0 => stable. Threshold predictions meaningless."
exit 0
}
ad = abs(drift)
ac = abs(cur)
thr[1]=5
thr[2]=10
thr[3]=30
thr[4]=60
for (i=1;i<=4;i++) {
d = (thr[i] - ac)/ad
if (d < 0) d = 0
hit_epoch = epoch_last + d*86400.0
printf("THR|%ds|DAYS=%.3f|EPOCH=%d\n", thr[i], d, int(hit_epoch))
}
}
' "$DB"
}
pretty_analyze() {
local watch="$1"
local out line
out="$(analyze_watch "$watch")"
echo "$out" | while IFS= read -r line; do
if [[ "$line" =~ ^THR\|([0-9]+)s\|DAYS=([0-9.]+)\|EPOCH=([0-9]+)$ ]]; then
local thr days epoch hit
thr="${BASH_REMATCH[1]}"
days="${BASH_REMATCH[2]}"
epoch="${BASH_REMATCH[3]}"
hit="$(fmt_date "$epoch")"
printf "Predicted to reach |offset|=%ss in ~%s days -> %s\n" "$thr" "$days" "$hit"
else
echo "$line"
fi
done
}
# ------------------------------------------------------------
# TUI / menu
# ------------------------------------------------------------
ui_menu() {
local title="${APP} v${VERSION}"
local watch="${1:-$DEFAULT_WATCH}"
while true; do
local choice
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
choice="$(whiptail --title "$title" --menu "Watch: $watch" 20 78 10 \
"1" "Add measurement (manual offset)" \
"2" "Analyze drift + predictions" \
"3" "Show reference clock report (NIST/Apple/Pool)" \
"4" "Show last measurements" \
"5" "Switch watch (or create new)" \
"6" "Open DB location" \
"0" "Exit" \
3>&1 1>&2 2>&3 || true)"
elif [[ $HAS_DIALOG -eq 1 ]]; then
choice="$(dialog --clear --title "$title" --menu "Watch: $watch" 20 78 10 \
"1" "Add measurement (manual offset)" \
"2" "Analyze drift + predictions" \
"3" "Show reference clock report (NIST/Apple/Pool)" \
"4" "Show last measurements" \
"5" "Switch watch (or create new)" \
"6" "Open DB location" \
"0" "Exit" \
3>&1 1>&2 2>&3 || true)"
clear
else
echo "===== $title ====="
echo "Watch: $watch"
echo "1) Add measurement"
echo "2) Analyze drift + predictions"
echo "3) Reference clock report"
echo "4) Show last measurements"
echo "5) Switch watch"
echo "6) Open DB location"
echo "0) Exit"
read -r -p "> " choice
fi
case "${choice:-}" in
1) ui_add_measurement "$watch" ;;
2) ui_analyze "$watch" ;;
3) ui_ref_report ;;
4) ui_show_last "$watch" ;;
5) watch="$(ui_switch_watch "$watch")" ;;
6) ui_open_db ;;
0|"") break ;;
*) ui_msg "Invalid option." ;;
esac
done
}
ui_msg() {
local msg="$1"
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
whiptail --title "$APP" --msgbox "$msg" 15 80
elif [[ $HAS_DIALOG -eq 1 ]]; then
dialog --title "$APP" --msgbox "$msg" 15 80
clear
else
echo "$msg"
echo
read -r -p "Press Enter..."
fi
}
ui_input() {
local title="$1"
local prompt="$2"
local default="${3:-}"
local value
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
value="$(whiptail --title "$title" --inputbox "$prompt" 12 78 "$default" 3>&1 1>&2 2>&3 || true)"
elif [[ $HAS_DIALOG -eq 1 ]]; then
value="$(dialog --title "$title" --inputbox "$prompt" 12 78 "$default" 3>&1 1>&2 2>&3 || true)"
clear
else
read -r -p "$prompt [$default]: " value
value="${value:-$default}"
fi
echo "$value"
}
ui_add_measurement() {
local watch="$1"
local offset source notes
offset="$(ui_input "$APP" "Enter offset (seconds). Example: +2.3 or -1.0" "+0.0")"
source="$(ui_input "$APP" "Source label (time.is / NIST / AppleNTP / PoolNTP / Manual)" "time.is")"
notes="$(ui_input "$APP" "Notes (optional)" "")"
# basic validation for numeric
if ! awk "BEGIN{exit($offset==($offset+0)?0:1)}" 2>/dev/null; then
ui_msg "Invalid offset value. Must be numeric, like +2.3 or -1.0"
return
fi
db_add "$watch" "$offset" "$source" "$notes"
ui_msg "Saved measurement:\n\nWatch: $watch\nOffset: ${offset}s\nSource: $source\nDB: $DB"
}
ui_analyze() {
local watch="$1"
local report
report="$(pretty_analyze "$watch")"
ui_msg "$report"
}
ui_ref_report() {
local report
report="$(show_reference_clock_report)"
ui_msg "$report"
}
ui_show_last() {
local watch="$1"
local report
report="$(awk -F',' -v w="$watch" '
NR==1 {next}
$3==w {print $0}
' "$DB" | tail -n 20)"
if [[ -z "$report" ]]; then
ui_msg "No measurements found for watch '$watch'."
return
fi
ui_msg "Last measurements (up to 20):\n\n$report"
}
ui_switch_watch() {
local current="$1"
local list choice
list="$(db_list_watches | sed '/^\s*$/d' | head -n 30)"
if [[ -z "$list" ]]; then
choice="$(ui_input "$APP" "No watches in DB yet. Enter watch name" "$current")"
echo "$choice"
return
fi
# If we have whiptail/dialog use selection list
if [[ $HAS_WHIPTAIL -eq 1 ]]; then
local opts=()
while IFS= read -r w; do
opts+=("$w" "")
done <<< "$list"
opts+=("NEW" "Create new watch name")
choice="$(whiptail --title "$APP" --menu "Select watch" 20 78 12 \
"${opts[@]}" 3>&1 1>&2 2>&3 || true)"
[[ "$choice" == "NEW" || -z "$choice" ]] && choice="$(ui_input "$APP" "Enter new watch name" "$current")"
echo "$choice"
return
fi
if [[ $HAS_DIALOG -eq 1 ]]; then
local opts=()
while IFS= read -r w; do
opts+=("$w" "")
done <<< "$list"
opts+=("NEW" "Create new watch name")
choice="$(dialog --clear --title "$APP" --menu "Select watch" 20 78 12 \
"${opts[@]}" 3>&1 1>&2 2>&3 || true)"
clear
[[ "$choice" == "NEW" || -z "$choice" ]] && choice="$(ui_input "$APP" "Enter new watch name" "$current")"
echo "$choice"
return
fi
choice="$(ui_input "$APP" "Enter watch name (existing or new). Existing watches:\n$list" "$current")"
echo "$choice"
}
ui_open_db() {
if is_macos; then
ui_msg "DB path:\n$DB\n\n(macOS) To open folder:\nopen \"$(dirname "$DB")\""
else
ui_msg "DB path:\n$DB\n\n(Linux) To open folder:\nxdg-open \"$(dirname "$DB")\""
fi
}
# ------------------------------------------------------------
# CLI mode
# ------------------------------------------------------------
usage() {
cat <<EOF
$APP $VERSION
Usage:
$0 menu
$0 add <WatchName> <offset_s> <source> [notes]
$0 analyze <WatchName>
$0 ref
$0 last <WatchName>
Environment:
WATCHOPS_DB Path to CSV database (default: ~/.watchops_measurements.csv)
WATCHOPS_WATCH Default watch name (default: Chronobike-Daily)
Examples:
$0 menu
$0 add Chronobike-Daily +2.3 time.is "after work"
$0 analyze Chronobike-Daily
$0 ref
EOF
}
main() {
init_db
local cmd="${1:-menu}"
case "$cmd" in
menu)
ui_menu "$DEFAULT_WATCH"
;;
add)
[[ $# -ge 4 ]] || { usage; exit 1; }
db_add "$2" "$3" "$4" "${5:-}"
echo "OK: saved to $DB"
;;
analyze)
[[ $# -ge 2 ]] || { usage; exit 1; }
pretty_analyze "$2"
;;
ref)
show_reference_clock_report
;;
last)
[[ $# -ge 2 ]] || { usage; exit 1; }
awk -F',' -v w="$2" 'NR==1{next} $3==w {print $0}' "$DB" | tail -n 20
;;
*)
usage
exit 1
;;
esac
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment