This commit is contained in:
Jeffrey Paul 2025-07-14 18:43:59 -07:00
commit 91ebc8eebc
13 changed files with 2577 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.git
.gitignore
*.md
.DS_Store

15
.eslintrc.json Normal file
View File

@ -0,0 +1,15 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-unused-vars": "warn",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
*.log
.DS_Store

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true
}

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM node:18-alpine
WORKDIR /app
# Copy all files
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]

15
Makefile Normal file
View File

@ -0,0 +1,15 @@
.PHONY: all dev fmt lint install
all: dev
dev:
npm run dev
fmt:
npm run format
lint:
npm run lint
install:
npm install

0
README.md Normal file
View File

155
app.js Normal file
View File

@ -0,0 +1,155 @@
let serverTimeOffset = 0;
let intervalId = null;
function formatLocalTime(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const tenths = Math.floor(date.getMilliseconds() / 100);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${tenths}`;
}
function getTimezone() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const offset = new Date().getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(offset) / 60);
const offsetMinutes = Math.abs(offset) % 60;
const offsetSign = offset <= 0 ? '+' : '-';
const offsetString = `UTC${offsetSign}${offsetHours.toString().padStart(2, '0')}:${offsetMinutes.toString().padStart(2, '0')}`;
return `${timezone} (${offsetString})`;
}
function formatUTCTime(date) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
const tenths = Math.floor(date.getUTCMilliseconds() / 100);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${tenths}`;
}
function updateDisplay() {
const now = new Date();
const correctedTime = new Date(now.getTime() + serverTimeOffset);
// Local time with timezone (using corrected time)
const timeString = formatLocalTime(correctedTime);
const timezone = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
document.getElementById('time-display').textContent = `${timeString} ${timezone}`;
// UTC time (using corrected time)
const utcTimeString = formatUTCTime(correctedTime);
document.getElementById('utc-display').textContent = `${utcTimeString} UTC`;
// Display offset
const offsetMs = Math.abs(serverTimeOffset);
// If serverTimeOffset is positive, server is ahead, so local clock is slow
// If serverTimeOffset is negative, server is behind, so local clock is fast
const status = serverTimeOffset > 0 ? 'slow' : 'fast';
if (offsetMs < 10000) { // Less than 10 seconds
document.getElementById('offset-display').textContent = `${Math.round(offsetMs)}ms ${status}`;
} else {
const offsetSeconds = (offsetMs / 1000).toFixed(1);
document.getElementById('offset-display').textContent = `${offsetSeconds}s ${status}`;
}
document.getElementById('timezone-display').style.display = 'none';
}
async function fetchServerTime() {
try {
const roundtrips = [];
// Check if we're on localhost
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname.startsWith('192.168.') ||
window.location.hostname.startsWith('10.');
const url = isLocalhost ? '/api/time' : window.location.href;
// Make three sequential requests to measure latency
for (let i = 0; i < 3; i++) {
const startTime = Date.now();
const response = await fetch(url, {
method: isLocalhost ? 'GET' : 'HEAD'
});
const endTime = Date.now();
const roundtripTime = endTime - startTime;
if (isLocalhost) {
// Parse JSON response from our backend
const timeData = await response.json();
const serverTime = timeData.utc_epoch_milliseconds;
const localTime = startTime + (roundtripTime / 2);
const offset = serverTime - localTime;
console.log('Local backend sync:', {
serverTime: new Date(serverTime).toISOString(),
localTime: new Date(localTime).toISOString(),
roundtripTime: roundtripTime + 'ms',
offset: offset + 'ms'
});
roundtrips.push({
roundtripTime,
offset
});
} else {
const serverDateHeader = response.headers.get('Date');
if (serverDateHeader) {
const serverTime = new Date(serverDateHeader).getTime();
const localTime = startTime + (roundtripTime / 2); // Estimate time when server processed request
const offset = serverTime - localTime;
roundtrips.push({
roundtripTime,
offset
});
}
}
}
if (roundtrips.length > 0) {
// Calculate average offset
const avgOffset = roundtrips.reduce((sum, rt) => sum + rt.offset, 0) / roundtrips.length;
serverTimeOffset = avgOffset;
// Log for debugging
console.log('Time sync complete:', {
roundtrips: roundtrips.map(rt => rt.roundtripTime + 'ms'),
averageOffset: avgOffset.toFixed(3) + 'ms'
});
}
} catch (error) {
console.error('Failed to fetch server time:', error);
}
}
async function init() {
await fetchServerTime();
updateDisplay();
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(updateDisplay, 100);
setInterval(fetchServerTime, 60000);
}
init();
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
fetchServerTime();
}
});

30
index.html Normal file
View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Time Display</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="header">
<h1>Current Time</h1>
<div class="status-box">
<div class="status-label">Local Computer Clock Offset</div>
<div id="offset-display" class="status-value">--</div>
</div>
</header>
<div class="time-container">
<div id="time-display" class="time">--:--:--</div>
<div id="utc-display" class="time utc">--:--:--</div>
<div id="timezone-display" class="timezone">--</div>
</div>
<footer class="footer">
<a href="https://git.eeqj.de/sneak/timepage" target="_blank">Project Page</a>
</footer>
<script src="app.js"></script>
</body>
</html>

2157
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "timesite-frontend",
"version": "1.0.0",
"description": "Time display frontend",
"scripts": {
"serve": "python3 -m http.server 8080"
}
}

67
server.js Normal file
View File

@ -0,0 +1,67 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 8080;
// MIME types
const mimeTypes = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json'
};
// Create server
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'GET' && req.url === '/api/time') {
// API endpoint for time
const now = new Date();
const epochNanoseconds = BigInt(now.getTime()) * BigInt(1000000);
const payload = {
utc_epoch_nanoseconds: epochNanoseconds.toString(),
utc_epoch_milliseconds: now.getTime(),
iso_string: now.toISOString()
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
} else if (req.method === 'GET' || req.method === 'HEAD') {
// Serve static files
let filePath = '.' + req.url;
if (filePath === './') {
filePath = './index.html';
}
const extname = path.extname(filePath);
const contentType = mimeTypes[extname] || 'text/plain';
fs.readFile(filePath, (error, content) => {
if (error) {
if (error.code === 'ENOENT') {
res.writeHead(404);
res.end('File not found');
} else {
res.writeHead(500);
res.end('Server error: ' + error.code);
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
}
});
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}/`);
});

102
styles.css Normal file
View File

@ -0,0 +1,102 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #000000;
color: #FFFFFF;
font-family: 'IBM Plex Mono', monospace;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
overflow: hidden;
}
.time-container {
text-align: center;
}
.time {
font-size: clamp(1.3rem, 5vw, 8vh);
font-weight: 300;
letter-spacing: 0.05em;
margin-bottom: 3rem;
line-height: 1;
white-space: nowrap;
}
.time.utc {
margin-bottom: 1rem;
}
.timezone {
font-size: clamp(1rem, 3vw, 2rem);
font-weight: 400;
opacity: 0.8;
}
.header {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #999999;
padding: 2rem;
text-align: left;
width: 100%;
position: relative;
}
.header h1 {
font-size: 3rem;
font-weight: 300;
margin: 0;
}
.status-box {
position: absolute;
top: 2rem;
right: 2rem;
background-color: #1a1a1a;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5), inset 0 1px 2px rgba(0, 0, 0, 0.3);
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.status-label {
font-size: 0.8rem;
color: #666666;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-value {
font-size: 1.2rem;
color: #cccccc;
font-family: 'IBM Plex Mono', monospace;
font-weight: 300;
}
.footer {
font-family: Tahoma, Arial, sans-serif;
color: #333333;
padding: 2rem;
text-align: center;
font-size: 1rem;
background-color: #666666;
box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.9), 0 -4px 8px rgba(0, 0, 0, 0.8), 0 -2px 4px rgba(0, 0, 0, 0.6);
width: 100%;
margin: 0;
}
.footer a {
color: #222222;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}