From 412fb771235a03cca73f59c5e249b057bdb06ecb Mon Sep 17 00:00:00 2001 From: Fuwn Date: Mon, 19 Jan 2026 04:13:03 -0800 Subject: feat: Add browser timezone option for client-side time display --- config.example.yaml | 8 +- internal/server/server.go | 25 +++-- internal/server/templates/index.html | 173 ++++++++++++++++++++++++++++++++--- 3 files changed, 183 insertions(+), 23 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index d5355da..81297c5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -40,8 +40,12 @@ display: # false = Grow dynamically as pings come in ping_fixed_slots: true - # Timezone for display (e.g., "UTC", "America/New_York", "Local") - timezone: "Local" + # Timezone for display: + # "Browser" - Use visitor's browser timezone (recommended for public status pages) + # "Local" - Use server's local timezone + # "UTC" - Use UTC timezone + # "America/New_York" - Use specific IANA timezone (e.g., "Europe/London", "Asia/Tokyo") + timezone: "Browser" # Show the theme toggle button (true by default) # Set to false to hide the light/dark mode toggle diff --git a/internal/server/server.go b/internal/server/server.go index abd18cb..d0f6fa5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -127,6 +127,7 @@ type PageData struct { TickCount int ShowThemeToggle bool Timezone string // Timezone for display + UseBrowserTimezone bool // Use client-side timezone conversion } // GroupData contains data for a monitor group @@ -190,11 +191,12 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { // Build page data data := PageData{ - Site: s.config.Site, - TickMode: s.config.Display.TickMode, - TickCount: s.config.Display.TickCount, - ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, - Timezone: s.config.Display.Timezone, + Site: s.config.Site, + TickMode: s.config.Display.TickMode, + TickCount: s.config.Display.TickCount, + ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, + Timezone: s.config.Display.Timezone, + UseBrowserTimezone: s.config.Display.Timezone == "Browser", } overallUp := true @@ -522,9 +524,12 @@ func templateFuncs() template.FuncMap { return string(b) } - // Convert timestamp to configured timezone + // If using browser timezone, include raw timestamp for client-side conversion + useBrowserTz := timezone == "Browser" + + // Convert timestamp to configured timezone (fallback for non-JS users) loc := time.Local - if timezone != "" && timezone != "Local" { + if timezone != "" && timezone != "Local" && timezone != "Browser" { if l, err := time.LoadLocation(timezone); err == nil { loc = l } @@ -547,6 +552,12 @@ func templateFuncs() template.FuncMap { data := make(map[string]interface{}) rows := []map[string]string{} + // Include raw timestamp for browser timezone conversion + if useBrowserTz { + data["timestamp"] = tick.Timestamp.Format(time.RFC3339) + data["mode"] = mode + } + switch mode { case "ping": header = t.Format("Jan 2, 15:04:05") diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index c351c73..c6e2a81 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -56,7 +56,7 @@ {{.OverallStatus}} - {{.CurrentTime}} + {{.CurrentTime}} @@ -153,11 +153,11 @@ {{.Title}}

{{.Message}}

- {{if .IsScheduled}} -

- Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} -

- {{end}} + {{if .IsScheduled}} +

+ Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} +

+ {{end}}
@@ -169,13 +169,13 @@ {{if .Updates}}
{{range .Updates}} -
-
- {{.Status}} - {{formatTime .Time}} -
-

{{.Message}}

-
+
+
+ {{.Status}} + {{formatTime .Time}} +
+

{{.Message}}

+
{{end}}
{{end}} @@ -188,7 +188,7 @@
- Updated {{timeAgo .LastUpdated}} + Updated {{timeAgo $.LastUpdated}} Powered by Kaze
@@ -258,6 +258,43 @@ let hideTimeout = null; function renderTooltip(data) { + {{if .UseBrowserTimezone}} + // Convert tick tooltip timestamps to browser timezone + if (data.timestamp && data.mode) { + const date = new Date(data.timestamp); + const tzInfo = { + tzName: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local', + offset: -date.getTimezoneOffset() + }; + const hours = Math.floor(tzInfo.offset / 60); + const minutes = Math.abs(tzInfo.offset % 60); + const utcOffset = 'UTC' + (hours >= 0 ? '+' : '') + hours + (minutes ? ':' + String(minutes).padStart(2, '0') : ''); + + // Update header based on mode + if (data.mode === 'ping') { + data.header = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + + date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + } else if (data.mode === 'minute') { + data.header = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + + date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); + } else if (data.mode === 'hour') { + const hourStr = String(date.getHours()).padStart(2, '0') + ':00'; + data.header = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + hourStr; + } else if (data.mode === 'day') { + data.header = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + } + + // Update timezone info in rows + if (data.rows) { + data.rows.forEach(function(row) { + if (row.label === 'Timezone') { + row.value = tzInfo.tzName + ' (' + utcOffset + ')'; + } + }); + } + } + {{end}} + let html = '' + data.header + ''; if (data.error) { html += '
' + data.error + '
'; @@ -352,6 +389,114 @@ // Auto-refresh every 30 seconds setTimeout(() => location.reload(), 30000); + + // Client-side timezone conversion + {{if .UseBrowserTimezone}} + (function() { + function formatDateTime(date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return month + ' ' + day + ', ' + year + ' ' + hours + ':' + minutes; + } + + function formatDateTimeShort(date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getMonth()]; + const day = date.getDate(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return month + ' ' + day + ', ' + hours + ':' + minutes; + } + + function timeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + if (seconds < 60) return seconds + ' seconds ago'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + ' minute' + (minutes === 1 ? '' : 's') + ' ago'; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago'; + const days = Math.floor(hours / 24); + return days + ' day' + (days === 1 ? '' : 's') + ' ago'; + } + + function getTimezoneInfo() { + const date = new Date(); + const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local'; + const offset = -date.getTimezoneOffset(); + const hours = Math.floor(offset / 60); + const minutes = Math.abs(offset % 60); + const utcOffset = 'UTC' + (hours >= 0 ? '+' : '') + hours + (minutes ? ':' + String(minutes).padStart(2, '0') : ''); + const gmtOffset = 'GMT' + (hours >= 0 ? '+' : '') + hours + (minutes ? ':' + String(minutes).padStart(2, '0') : ''); + return { tzName: tzName, utcOffset: utcOffset, gmtOffset: gmtOffset }; + } + + // Convert all timestamps + document.querySelectorAll('[data-timestamp]').forEach(function(el) { + const timestamp = el.getAttribute('data-timestamp'); + const format = el.getAttribute('data-format'); + const date = new Date(timestamp); + + if (format === 'datetime') { + el.textContent = formatDateTime(date); + } else if (format === 'datetime-short') { + el.textContent = formatDateTimeShort(date); + } else if (format === 'timeago') { + el.textContent = 'Updated ' + timeAgo(date); + } else if (format === 'scheduled') { + const startTimestamp = el.getAttribute('data-timestamp-start'); + const endTimestamp = el.getAttribute('data-timestamp-end'); + let text = 'Scheduled: '; + if (startTimestamp) { + text += formatDateTimeShort(new Date(startTimestamp)); + } + text += ' - '; + if (endTimestamp) { + text += formatDateTimeShort(new Date(endTimestamp)); + } + el.textContent = text; + } + + // Update tooltips to show browser timezone + const tooltipData = el.getAttribute('data-tooltip'); + if (tooltipData) { + try { + const data = JSON.parse(tooltipData); + if (data.rows) { + const tzInfo = getTimezoneInfo(); + data.rows.forEach(function(row) { + if (row.label === 'Timezone') { + row.value = tzInfo.tzName; + } else if (row.label === 'UTC Offset') { + row.value = tzInfo.utcOffset; + } else if (row.label === 'GMT Offset') { + row.value = tzInfo.gmtOffset; + } else if (row.label === 'Date & Time' && format === 'timeago') { + // Update "Last Check" tooltip with browser timezone + const dtFormat = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + row.value = dtFormat.format(date); + } + }); + el.setAttribute('data-tooltip', JSON.stringify(data)); + } + } catch (e) { + // Ignore JSON parse errors + } + } + }); + })(); + {{end}} -- cgit v1.2.3