// Decimal time & French Republican Calendar // // Decimal time: 1 day = 10 decimal hours // 1 decimal hour = 100 decimal minutes // 1 decimal minute = 100 decimal seconds // // French Republican Calendar epoch: 22 September 1792 (Gregorian) = 1 Vendémiaire An I // Each year has 12 months of 30 days + 5 (or 6 in sextile years) complementary days. // Sextile (leap) years: the Republican leap rule approximates the Gregorian one — // years 3, 7, 11, 15, 20 of each 20-year cycle were designated sextile in the original // Romme proposal. For simplicity we use the widely accepted rule: // A Republican year is sextile if the *following* Gregorian year is a Gregorian leap year. const REPUBLICAN_EPOCH_JD = 2375840; // Julian Day Number of 1 Vendémiaire An I (22 Sep 1792) const MONTH_NAMES = [ "Vendémiaire", "Brumaire", "Frimaire", "Nivôse", "Pluviôse", "Ventôse", "Germinal", "Floréal", "Prairial", "Messidor", "Thermidor", "Fructidor" ]; const MONTH_NAMES_EN = [ "Vintage", "Mist", "Frost", "Snowy", "Rainy", "Windy", "Budding", "Flowery", "Meadow", "Harvest", "Heat", "Fruit" ]; const COMPLEMENTARY_NAMES = [ "Jour de la Vertu", // 1 "Jour du Génie", // 2 "Jour du Travail", // 3 "Jour de l'Opinion", // 4 "Jour des Récompenses", // 5 "Jour de la Révolution", // 6 (sextile only) ]; const COMPLEMENTARY_NAMES_EN = [ "Day of Virtue", "Day of Genius", "Day of Labour", "Day of Opinion", "Day of Rewards", "Day of the Revolution", ]; const WEEKDAY_NAMES = [ "Primidi", "Duodi", "Tridi", "Quartidi", "Quintidi", "Sextidi", "Septidi", "Octidi", "Nonidi", "Décadi" ]; const WEEKDAY_NAMES_EN = [ "First day", "Second day", "Third day", "Fourth day", "Fifth day", "Sixth day", "Seventh day", "Eighth day", "Ninth day", "Tenth day" ]; // --- Julian Day Number helpers --- function gregorianToJD(year, month, day) { // Algorithm from Jean Meeus, "Astronomical Algorithms" if (month <= 2) { year -= 1; month += 12; } const A = Math.floor(year / 100); const B = 2 - A + Math.floor(A / 4); return Math.floor(365.25 * (year + 4716)) + Math.floor(30.6001 * (month + 1)) + day + B - 1524.5; } function isGregorianLeap(year) { return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; } function isSextile(republicanYear) { // The year following An N is Gregorian year 1792 + N return isGregorianLeap(1792 + republicanYear); } function toRepublicanDate(utcDate) { // JD at midnight UTC for the given date const jd = Math.floor(gregorianToJD( utcDate.getUTCFullYear(), utcDate.getUTCMonth() + 1, utcDate.getUTCDate() ) + 0.5); // +0.5 because JD starts at noon; floor gives previous midnight const daysSinceEpoch = jd - REPUBLICAN_EPOCH_JD; if (daysSinceEpoch < 0) { return null; // Before the Republic } // Walk years: each year is 365 or 366 days let year = 1; let remaining = daysSinceEpoch; while (true) { const yearLen = isSextile(year) ? 366 : 365; if (remaining < yearLen) break; remaining -= yearLen; year++; } let month, day, weekday, complementary; if (remaining < 360) { // Normal months (0-indexed day within year) month = Math.floor(remaining / 30) + 1; // 1..12 day = (remaining % 30) + 1; // 1..30 weekday = ((remaining % 10)); // 0..9 → Primidi..Décadi complementary = null; } else { // Complementary days (jours complémentaires / sans-culottides) const compIdx = remaining - 360; // 0..4 (or 0..5 sextile) month = null; day = compIdx + 1; weekday = null; complementary = COMPLEMENTARY_NAMES[compIdx] || "Jour inconnu"; } return { year, month, day, weekday, complementary }; } function toRomanNumeral(n) { const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1]; const syms = ["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"]; let result = ""; for (let i = 0; i < vals.length; i++) { while (n >= vals[i]) { result += syms[i]; n -= vals[i]; } } return result; } function formatRepublicanDate(r) { if (!r) return { full: "Before the Republic", translation: "", dayOfYear: "-", year: "-" }; let full, translation; if (r.complementary) { const compIdx = r.day - 1; full = `${r.complementary}, An ${toRomanNumeral(r.year)}`; translation = `${COMPLEMENTARY_NAMES_EN[compIdx]}, Year ${r.year}`; } else { const weekdayName = WEEKDAY_NAMES[r.weekday]; const weekdayNameEn = WEEKDAY_NAMES_EN[r.weekday]; const monthName = MONTH_NAMES[r.month - 1]; const monthNameEn = MONTH_NAMES_EN[r.month - 1]; full = `${weekdayName}, ${r.day} ${monthName} An ${toRomanNumeral(r.year)}`; translation = `${weekdayNameEn}, ${r.day} ${monthNameEn}, Year ${r.year}`; } const dayOfYear = r.complementary ? `360 + ${r.day} (Jour complémentaire)` : `${(r.month - 1) * 30 + r.day}`; return { full, translation, dayOfYear, year: `An ${toRomanNumeral(r.year)}` }; } // --- Decimal time --- // Decimal time counts from local midnight in the selected time zone. 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" }); const parts = fmt.formatToParts(date).reduce((acc, p) => { if (p.type !== "literal") acc[p.type] = parseInt(p.value, 10); return acc; }, {}); return parts; // { year, month, day, hour, minute, second } } function toDecimalTime(date, zone) { const p = getLocalParts(date, zone); // ms since local midnight (ignoring DST transitions within the day — good enough) 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; // 10h * 100m * 100s 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 { str: `${dHour}:${String(dMin).padStart(2,"0")}:${String(dSecInt).padStart(2,"0")}.${String(dCenti).padStart(2,"0")}` }; } function toRepublicanDateLocal(date, zone) { const p = getLocalParts(date, zone); return toRepublicanDate(new Date(Date.UTC(p.year, p.month - 1, p.day))); } // --- DOM & sync --- const zoneSelect = document.getElementById("zoneSelect"); const zoneLabel = document.getElementById("zoneLabel"); const dateTrans = document.getElementById("dateTrans"); const offsetLabel = document.getElementById("offsetLabel"); const decimalUtc = document.getElementById("decimalUtc"); const dayOfYear = document.getElementById("dayOfYear"); const repYear = document.getElementById("repYear"); 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 render() { const now = new Date(Date.now() + serverOffsetMs); const dt = toDecimalTime(now, selectedZone); const rep = toRepublicanDateLocal(now, selectedZone); const fmt = formatRepublicanDate(rep); digital.textContent = dt.str; dateLine.textContent = fmt.full; dateTrans.textContent = fmt.translation; decimalUtc.textContent = dt.str; dayOfYear.textContent = fmt.dayOfYear; repYear.textContent = fmt.year; zoneLabel.textContent = selectedZone; utcLabel.textContent = now.toISOString().replace("T", " ").replace("Z", " UTC"); requestAnimationFrame(render); } syncWithServer(); setInterval(syncWithServer, 20_000); render();