initial
This commit is contained in:
commit
91ebc8eebc
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal 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
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
10
Dockerfile
Normal file
10
Dockerfile
Normal 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
15
Makefile
Normal 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
|
155
app.js
Normal file
155
app.js
Normal 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
30
index.html
Normal 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
2157
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
package.json
Normal file
8
package.json
Normal 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
67
server.js
Normal 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
102
styles.css
Normal 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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user