Files
clock-site/seth.js
T

250 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Seth Calendar
//
// Structure:
// - Same year numbers and Jan 1 epoch as Gregorian
// - 10 months of 36 days (6 weeks of 6 days each)
// - 5 holiday days at year end (6 on leap years)
// - Total: 365 or 366 days
//
// Gregorian alignment (non-leap year):
// Month 1 Day 1 = Jan 1
// Month 10 Day 35 = Dec 25 (Christmas)
// Month 10 Day 36 = Dec 26 (Boxing Day)
// Holiday 1 = Dec 27
// Holiday 5 = Dec 31 (New Year's Eve)
//
// Gregorian alignment (leap year):
// Month 10 Day 35 = Dec 24 (Christmas Eve)
// Month 10 Day 36 = Dec 25 (Christmas)
// Holiday 1 = Dec 26 (Boxing Day)
// Holiday 6 = Dec 31 (New Year's Eve)
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function toSethDate(year, dayOfYear) {
// dayOfYear: 1-indexed (Jan 1 = 1)
const leap = isLeapYear(year);
// Leap Day: DOY 60 in a leap year — sits between M1 W3 D4 and M1 W3 D5
if (leap && dayOfYear === 60) {
return { type: "leapday", year, dayOfYear };
}
// After leap day, subtract 1 so the rest of the calendar stays pegged
const idx = (leap && dayOfYear > 60 ? dayOfYear - 1 : dayOfYear) - 1;
if (idx < 360) {
const month = Math.floor(idx / 36); // 0..9
const dayInM = idx % 36; // 0..35
const week = Math.floor(dayInM / 6); // 0..5
const dayInW = dayInM % 6; // 0..5
return { type: "month", year, month, day: dayInM, week, weekDay: dayInW, dayOfYear };
} else {
const holiday = idx - 360; // 0..4 (or 0..5 leap)
return { type: "holiday", year, holiday, leap, dayOfYear };
}
}
function getDayOfYear(date, zone) {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
year: "numeric", month: "numeric", day: "numeric"
});
const parts = fmt.formatToParts(date).reduce((acc, p) => {
if (p.type !== "literal") acc[p.type] = parseInt(p.value, 10);
return acc;
}, {});
const { year, month, day } = parts;
// Day of year calculation
const cumDays = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
const leap = isLeapYear(year);
let doy = cumDays[month - 1] + day;
if (leap && month > 2) doy++;
return { year, doy };
}
// Notation helpers
// short: D.M e.g. "30.1"
// medium: D.M.YY e.g. "30.1.26"
// log: DD.M.YYYY e.g. "30.1.2026" (DD zero-pads to 2 digits)
// full: YYYY.M.DD e.g. "2026.1.30"
function sethShort(sd) {
if (sd.type === "leapday") return "Leap Day";
if (sd.type === "holiday") return `H${sd.holiday}`;
return `${sd.day}.${sd.month}`;
}
function sethMedium(sd) {
if (sd.type === "leapday") return "Leap Day";
if (sd.type === "holiday") return `H${sd.holiday}.${String(sd.year).slice(-2)}`;
return `${sd.day}.${sd.month}.${String(sd.year).slice(-2)}`;
}
function sethLog(sd) {
if (sd.type === "leapday") return `Leap Day ${sd.year}`;
if (sd.type === "holiday") return `H${sd.holiday}.${sd.year}`;
return `${String(sd.day).padStart(2,"0")}.${sd.month}.${sd.year}`;
}
function sethFull(sd) {
if (sd.type === "leapday") return `${sd.year}.Leap`;
if (sd.type === "holiday") return `${sd.year}.H${sd.holiday}`;
return `${sd.year}.${sd.month}.${sd.day}`;
}
// Legacy display helpers (used by seth.html dateLine)
function formatSethDate(sd) {
if (sd.type === "leapday") return "Leap Day";
if (sd.type === "holiday") {
const total = sd.leap ? 6 : 5;
return `Holiday ${sd.holiday} of ${total}`;
}
return `${sd.day}.${sd.month} (Month ${sd.month}, Day ${sd.day})`;
}
function formatSethLong(sd) {
if (sd.type === "leapday") return `${sd.year} Leap Day — Feb 29`;
if (sd.type === "holiday") {
const lastHoliday = sd.leap ? 5 : 4;
const isNYE = sd.holiday === lastHoliday;
const isBoxingDay = sd.leap && sd.holiday === 0;
let note = "";
if (isNYE) note = " — New Year's Eve";
else if (isBoxingDay) note = " — Boxing Day";
return `${sd.year} Holiday ${sd.holiday}${note}`;
}
return `${sethLog(sd)} (${sd.year}.${sd.month}.${sd.day})`;
}
// --- Decimal time (same as decimal page) ---
function getLocalParts(date, zone) {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
hour12: false,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
});
return fmt.formatToParts(date).reduce((acc, p) => {
if (p.type !== "literal") acc[p.type] = parseInt(p.value, 10);
return acc;
}, {});
}
function toDecimalTime(date, zone) {
const p = getLocalParts(date, zone);
const msIntoDay =
p.hour * 3_600_000 +
p.minute * 60_000 +
p.second * 1_000 +
date.getMilliseconds();
const dayFraction = msIntoDay / 86_400_000;
const totalDecimalSeconds = dayFraction * 100_000;
const dHour = Math.floor(totalDecimalSeconds / 10_000);
const rem1 = totalDecimalSeconds % 10_000;
const dMin = Math.floor(rem1 / 100);
const dSec = rem1 % 100;
const dCenti = Math.floor((dSec % 1) * 100);
const dSecInt = Math.floor(dSec);
return `\u00a0${dHour}:${String(dMin).padStart(2,"0")}:${String(dSecInt).padStart(2,"0")}.${String(dCenti).padStart(2,"0")}`;
}
// --- DOM & sync ---
const zoneSelect = document.getElementById("zoneSelect");
const zoneLabel = document.getElementById("zoneLabel");
const offsetLabel = document.getElementById("offsetLabel");
const decimalTime = document.getElementById("decimalTime");
const gregorianTime = document.getElementById("gregorianTime");
const dayOfYear = document.getElementById("dayOfYear");
const weekOfYear = document.getElementById("weekOfYear");
const utcLabel = document.getElementById("utcLabel");
const dateLine = document.getElementById("dateLine");
const digital = document.getElementById("digital");
const zones = [
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"UTC"
];
let selectedZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York";
if (!zones.includes(selectedZone)) selectedZone = "America/New_York";
for (const zone of zones) {
const option = document.createElement("option");
option.value = zone;
option.textContent = zone;
if (zone === selectedZone) option.selected = true;
zoneSelect.appendChild(option);
}
zoneSelect.addEventListener("change", () => {
selectedZone = zoneSelect.value;
zoneLabel.textContent = selectedZone;
});
let serverOffsetMs = 0;
async function syncWithServer() {
const start = Date.now();
try {
const response = await fetch(`/api/time?ts=${start}`, { cache: "no-store" });
const end = Date.now();
const data = await response.json();
const rtt = end - start;
serverOffsetMs = (data.epoch_ms + rtt / 2) - end;
offsetLabel.textContent = `Synchronized (${serverOffsetMs >= 0 ? "+" : ""}${serverOffsetMs.toFixed(0)} ms, RTT ${rtt} ms)`;
} catch (_) {
offsetLabel.textContent = "Sync failed";
}
}
function toGregorianTime(date, zone) {
const p = getLocalParts(date, zone);
// Format as HH:MM:SS.cc to match decimal time width
const cs = String(Math.floor(date.getMilliseconds() / 10)).padStart(2, "0");
return `${String(p.hour).padStart(2,"0")}:${String(p.minute).padStart(2,"0")}:${String(p.second).padStart(2,"0")}.${cs}`;
}
function render() {
const now = new Date(Date.now() + serverOffsetMs);
const { year, doy } = getDayOfYear(now, selectedZone);
const sd = toSethDate(year, doy);
const dt = toDecimalTime(now, selectedZone);
const gt = toGregorianTime(now, selectedZone);
dateLine.textContent = formatSethLong(sd);
digital.textContent = dt;
decimalTime.textContent = dt;
gregorianTime.textContent = gt;
dayOfYear.textContent = `${doy} of ${isLeapYear(year) ? 366 : 365}`;
const totalWeeks = 60; // 10 months × 6 weeks
const woy = sd.type === "month" ? sd.month * 6 + sd.week : "—";
const woyTotal = sd.type === "month" ? `${woy} of ${totalWeeks}`
: sd.type === "leapday" ? "— (leap day)"
: `— (holiday days)`;
weekOfYear.textContent = woyTotal;
zoneLabel.textContent = selectedZone;
utcLabel.textContent = now.toISOString().replace("T", " ").replace("Z", " UTC");
requestAnimationFrame(render);
}
syncWithServer();
setInterval(syncWithServer, 20_000);
render();