Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Package.resolved

.build/

# Swiftly
.swift-version

# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Sources/HTML/WDHTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct WDHTML {

// Set up external sources
.stylesheet("./styles/wdstyle.css"),
.script(.src("./scripts/wdtime.js")),

.favicon("./images/wdfavicon.png"),

Expand Down
11 changes: 10 additions & 1 deletion Sources/HTML/WDPageListSubtext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
12 changes: 10 additions & 2 deletions Sources/HTML/WDPageStatusList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Resources/DefaultHTMLAssets/wdstyle.css
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,10 @@ html, body {
opacity: 100%;
}

.tz-notice-js {
display: none;
}

.help-text {
color: var(--secondary-text-color);
font-size: 0.8rem;
Expand Down
127 changes: 127 additions & 0 deletions Sources/Resources/DefaultHTMLAssets/wdtime.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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}`;
}