diff --git a/core/internal/server/tailscale/client.go b/core/internal/server/tailscale/client.go index 0602fc067..b3b98b480 100644 --- a/core/internal/server/tailscale/client.go +++ b/core/internal/server/tailscale/client.go @@ -1,7 +1,6 @@ package tailscale import ( - "fmt" "sort" "strings" "time" @@ -103,35 +102,8 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg } if !ps.LastSeen.IsZero() { - peer.LastSeen = formatRelativeTime(ps.LastSeen) + peer.LastSeen = ps.LastSeen.UTC().Format(time.RFC3339) } return peer } - -// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago"). -func formatRelativeTime(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - m := int(d.Minutes()) - if m == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", m) - case d < 24*time.Hour: - h := int(d.Hours()) - if h == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", h) - default: - days := int(d.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - } -} diff --git a/core/internal/server/tailscale/client_test.go b/core/internal/server/tailscale/client_test.go index 0c2006bb2..3a1b19c42 100644 --- a/core/internal/server/tailscale/client_test.go +++ b/core/internal/server/tailscale/client_test.go @@ -169,23 +169,3 @@ func TestPeerSorting(t *testing.T) { assert.Equal(t, "beta", state.Peers[1].Hostname) assert.Equal(t, "zebra", state.Peers[2].Hostname) } - -func TestFormatRelativeTime(t *testing.T) { - tests := []struct { - name string - duration string - contains string - }{ - {"minutes", "5m", "minutes ago"}, - {"hours", "3h", "hours ago"}, - {"days", "48h", "days ago"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d, _ := time.ParseDuration(tt.duration) - result := formatRelativeTime(time.Now().Add(-d)) - assert.Contains(t, result, tt.contains) - }) - } -} diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml index 3ba59a14e..c52e724cd 100644 --- a/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml @@ -48,6 +48,17 @@ PluginComponent { property int filterIndex: 0 property string expandedHostname: "" + function formatLastSeen(iso) { + const date = new Date(iso); + const minutes = Math.floor((Date.now() - date.getTime()) / 60000); + if (minutes < 60) + return I18n.tr("%1m ago", "Tailscale peer last seen time, %1 is a number of minutes").arg(Math.max(1, minutes)); + const hours = Math.floor(minutes / 60); + if (hours < 24) + return I18n.tr("%1h ago", "Tailscale peer last seen time, %1 is a number of hours").arg(hours); + return I18n.tr("%1d ago", "Tailscale peer last seen time, %1 is a number of days").arg(Math.floor(hours / 24)); + } + readonly property string title: I18n.tr("Tailscale", "Tailscale mesh VPN widget title") readonly property string noneLabel: I18n.tr("None", "Tailscale exit node: none selected") readonly property var filteredPeers: { @@ -207,7 +218,7 @@ PluginComponent { if (modelData.online) parts.push(modelData.relay ? I18n.tr("relay: %1", "Tailscale relay server name").arg(modelData.relay) : I18n.tr("direct", "Tailscale direct connection")); else if (modelData.lastSeen) - parts.push(I18n.tr("last seen %1", "Tailscale peer last seen time").arg(modelData.lastSeen)); + parts.push(I18n.tr("last seen %1", "Tailscale peer last seen time").arg(detailRoot.formatLastSeen(modelData.lastSeen))); return parts.join(" • "); } trailingBadge: isSelf ? I18n.tr("This device", "Label for the user's own device in Tailscale") : ""