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