diff --git a/.gitignore b/.gitignore index 598229e..b408389 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ Package.resolved .build/ +# Swiftly +.swift-version + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/CHANGELOG.md b/CHANGELOG.md index a636a4a..ae4cf54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +* Localization of all dates to the user's browser locale with JavaScript. + ## [0.1.3] - 2025-03-31 ### Removed * Moved CI release action into a reusable workflow in another repository: https://github.com/BertanT/SwiftPM-Cross-Comp-CI. diff --git a/Sources/HTML/WDHTML.swift b/Sources/HTML/WDHTML.swift index 12b06d2..dedbd30 100644 --- a/Sources/HTML/WDHTML.swift +++ b/Sources/HTML/WDHTML.swift @@ -35,6 +35,7 @@ struct WDHTML { // Set up external sources .stylesheet("./styles/wdstyle.css"), + .script(.src("./scripts/wdtime.js")), .favicon("./images/wdfavicon.png"), diff --git a/Sources/HTML/WDPageListSubtext.swift b/Sources/HTML/WDPageListSubtext.swift index d58db01..0dbb80d 100644 --- a/Sources/HTML/WDPageListSubtext.swift +++ b/Sources/HTML/WDPageListSubtext.swift @@ -24,8 +24,17 @@ struct WDPageListSubtext: Component { var body: Component { ComponentGroup { - H6("Last updated: \(lastUpdate.formatted(date: .abbreviated, time: .complete))") + H6 { + Text("Last updated: \(lastUpdate.formatted(date: .abbreviated, time: .complete))") + } + .attribute(named: "data-timestamp", value: "\(Int(lastUpdate.timeIntervalSince1970))") + .attribute(named: "data-format-type", value: "lastupdated") + H6("All logs are shown in \(TimeZone.current.abbreviation() ?? "the server's local time zone").") + .class("tz-notice-nojs") + + H6("All logs are shown in your browser's timezone.") + .class("tz-notice-js") } .class("help-text") } diff --git a/Sources/HTML/WDPageStatusList.swift b/Sources/HTML/WDPageStatusList.swift index 7717a34..e471755 100644 --- a/Sources/HTML/WDPageStatusList.swift +++ b/Sources/HTML/WDPageStatusList.swift @@ -56,9 +56,17 @@ struct WDPageStatusList: Component { List { for outage in status.outages { if let end = outage.end { - ListItem("\(outage.start.formatted()) - \(end.formatted())") + ListItem { + Text("\(outage.start.formatted()) - \(end.formatted())") + } + .attribute(named: "data-timestamp", value: "\(Int(outage.start.timeIntervalSince1970))") + .attribute(named: "data-end-timestamp", value: "\(Int(end.timeIntervalSince1970))") } else { - ListItem("Down since \(outage.start.formatted())") + ListItem { + Text("Down since \(outage.start.formatted())") + } + .attribute(named: "data-timestamp", value: "\(Int(outage.start.timeIntervalSince1970))") + .attribute(named: "data-format-type", value: "ongoing") } } } diff --git a/Sources/Resources/DefaultHTMLAssets/wdstyle.css b/Sources/Resources/DefaultHTMLAssets/wdstyle.css index d6e8fc4..ec7b791 100644 --- a/Sources/Resources/DefaultHTMLAssets/wdstyle.css +++ b/Sources/Resources/DefaultHTMLAssets/wdstyle.css @@ -298,6 +298,10 @@ html, body { opacity: 100%; } +.tz-notice-js { + display: none; +} + .help-text { color: var(--secondary-text-color); font-size: 0.8rem; diff --git a/Sources/Resources/DefaultHTMLAssets/wdtime.js b/Sources/Resources/DefaultHTMLAssets/wdtime.js new file mode 100644 index 0000000..6716744 --- /dev/null +++ b/Sources/Resources/DefaultHTMLAssets/wdtime.js @@ -0,0 +1,127 @@ +/* + wdtime.js + + Copyright (C) 2025 Bedir Ekim + + This file is part of WatchDuck. + + WatchDuck is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License + as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + WatchDuck is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with WatchDuck. + If not, see . +*/ + +document.addEventListener('DOMContentLoaded', function() { + formatAllTimestamps(); +}); + +/** + * Format all timestamp elements on the page. + */ +function formatAllTimestamps() { + document.querySelectorAll('[data-timestamp]').forEach(formatTimestampElement); + + // Make sure the time zone notice is correct for users who don't have JS enabled + document.querySelectorAll('.tz-notice-nojs').forEach(e => e.style.display = 'none'); + document.querySelectorAll('.tz-notice-js').forEach(e => e.style.display = 'block'); +} + +function formatTimestampElement(element) { + const timestamp = parseInt(element.getAttribute('data-timestamp'), 10) * 1000; + const date = new Date(timestamp); + + const formatType = element.getAttribute('data-format-type') || 'datetime'; + const isRange = element.hasAttribute('data-end-timestamp'); + + if (isRange) { + const endTimestamp = parseInt(element.getAttribute('data-end-timestamp'), 10) * 1000; + const endDate = new Date(endTimestamp); + element.textContent = formatDateRange(date, endDate); + } else if (formatType === 'datetime') { + element.textContent = formatDateTime(date, true); + } else if (formatType === 'lastupdated') { + element.textContent = 'Last updated: ' + formatDateTime(date, true); + } else if (formatType === 'ongoing') { + element.textContent = 'Down since ' + formatDateTime(date); + } +} + +/** + * Format a date in a user-friendly format. + * @param {Date} date - Date to format. + * @param {Boolean} includeTimezone - Whether to include timezone information. + * @param {Boolean} isOutageTime - Whether this is for an outage timestamp. + * @returns {string} Formatted date string. + */ +function formatDateTime(date, includeTimezone = false, isOutageTime = false) { + if (isOutageTime) { + const dateStr = date.toLocaleDateString(); + const timeStr = date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit' + }); + return `${dateStr}, ${timeStr}`; + } + + const options = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }; + + const formattedDateTime = date.toLocaleString(undefined, options); + + if (includeTimezone) { + const tzString = getTimezoneString(date); + return `${formattedDateTime} ${tzString}`; + } + + return formattedDateTime; +} + +/** + * Get the timezone string (like GMT+2) for a date. + * @param {Date} date - The date to get timezone for. + * @returns {string} Timezone string. + */ +function getTimezoneString(date) { + const timezoneOffset = date.getTimezoneOffset(); + const offsetHours = Math.abs(Math.floor(timezoneOffset / 60)); + const offsetMinutes = Math.abs(timezoneOffset % 60); + const offsetSign = timezoneOffset <= 0 ? '+' : '-'; + + if (offsetMinutes === 0) { + return `GMT${offsetSign}${offsetHours}`; + } else { + return `GMT${offsetSign}${offsetHours}:${offsetMinutes.toString().padStart(2, '0')}`; + } +} + +/** + * Format a date range. + * @param {Date} startDate - Start date. + * @param {Date} endDate - End date. + * @returns {string} Formatted date range. + */ +function formatDateRange(startDate, endDate) { + if (startDate.getFullYear() === endDate.getFullYear() && + startDate.getMonth() === endDate.getMonth() && + startDate.getDate() === endDate.getDate() && + startDate.getHours() === endDate.getHours() && + startDate.getMinutes() === endDate.getMinutes()) { + + const formattedStart = formatDateTime(startDate, false, true); + return `${formattedStart} - Resolved under a minute :)`; + } + + const formattedStart = formatDateTime(startDate, false, true); + const formattedEnd = formatDateTime(endDate, false, true); + return `${formattedStart} - ${formattedEnd}`; +} \ No newline at end of file