// Seth Calendar — calendar view // // 10 months × 36 days (6 weeks × 6 days), then 5 or 6 holiday days. // Jan 1 = Month 1 Day 1. Same year numbers as Gregorian. // ── Moon phase rendering ────────────────────────────────────────────────────── const KNOWN_NEW_MOON_JD = 2451549.756; // Jan 6, 2000 18:14 UTC const SYNODIC_MONTH = 29.53058867; function jdFromCalDate(year, month, day) { if (month <= 2) { year--; 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; } // Returns { age (0–29.53), illumination (0–1), waxing (bool) } function moonPhaseForDate(jsDate) { const jd = jdFromCalDate(jsDate.getFullYear(), jsDate.getMonth() + 1, jsDate.getDate()) + 0.5; const daysSince = jd - KNOWN_NEW_MOON_JD; const cycles = daysSince / SYNODIC_MONTH; const age = (cycles - Math.floor(cycles)) * SYNODIC_MONTH; const illumination = (1 - Math.cos(2 * Math.PI * age / SYNODIC_MONTH)) / 2; const waxing = age < SYNODIC_MONTH / 2; return { age, illumination, waxing }; } // Draw a moon phase into a canvas element (size x size pixels) function drawMoon(canvas, illumination, waxing) { const size = canvas.width; const r = size / 2 - 0.5; const cx = size / 2; const cy = size / 2; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, size, size); // Dark side color const dark = "#1a1a2e"; // Light side color const light = "#e8dfc0"; // Draw full dark circle first ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.fillStyle = dark; ctx.fill(); // The terminator: lit fraction maps to an ellipse x-scale // illumination 0 = new (all dark), 0.5 = quarter (semi-circle), 1 = full (all light) // Ellipse x-radius: at 0.5 it's 0, at 0 or 1 it's r // Phase angle: 0=new, π/2=first quarter, π=full, 3π/2=last quarter const phaseAngle = (age => 2 * Math.PI * age / SYNODIC_MONTH)( waxing ? Math.acos(1 - 2 * illumination) * SYNODIC_MONTH / (2 * Math.PI) : (SYNODIC_MONTH / 2) + Math.acos(2 * illumination - 1) * SYNODIC_MONTH / (2 * Math.PI) ); // Simpler direct approach: draw lit half-circle + terminator ellipse // Lit side: right if waxing, left if waning // x-scale of terminator ellipse: cos(phase_angle) where angle 0=new, π=full const angle = 2 * Math.PI * (waxing ? illumination < 0.5 ? illumination : illumination : illumination); // Direct: ellipse terminator x-width = (1 - 2*illumination)*r for waxing, // (2*illumination - 1)*r for waning const termX = Math.abs(1 - 2 * illumination) * r; const litOnRight = waxing; // Draw lit semicircle on the appropriate side ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI / 2, !litOnRight); ctx.fillStyle = light; ctx.fill(); ctx.restore(); // Draw terminator ellipse to carve/extend into the lit side ctx.save(); ctx.beginPath(); // Clip to the dark half ctx.arc(cx, cy, r + 1, -Math.PI / 2, Math.PI / 2, litOnRight); ctx.lineTo(cx, cy - r - 1); ctx.closePath(); ctx.clip(); // Fill terminator ellipse (same as lit color if < half, dark if > half) ctx.beginPath(); ctx.ellipse(cx, cy, termX, r, 0, 0, 2 * Math.PI); ctx.fillStyle = illumination < 0.5 ? dark : light; ctx.fill(); ctx.restore(); // Thin border ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.strokeStyle = "rgba(255,255,255,0.15)"; ctx.lineWidth = 0.5; ctx.stroke(); } function makeMoonCanvas(jsDate, size) { const { illumination, waxing } = moonPhaseForDate(jsDate); const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; canvas.style.width = size + "px"; canvas.style.height = size + "px"; drawMoon(canvas, illumination, waxing); return canvas; } const GREG_MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const GREG_WEEKDAYS = ["Su","M","T","W","Tr","F","S"]; const WEEK_DAY_LABELS = ["D0","D1","D2","D3","D4","D5"]; // --- Holidays --- // Fixed: { month, day, name, emoji } // Floating: computed per year via buildFloatingHolidays(year) const FIXED_HOLIDAYS = [ { month: 1, day: 1, name: "New Year's Day", emoji: "🎆", url: "https://en.wikipedia.org/wiki/New_Year%27s_Day" }, { month: 2, day: 13, name: "1 1 1 Day", emoji: "1️⃣" }, { month: 3, day: 28, name: "2 2 2 Day", emoji: "2️⃣" }, { month: 5, day: 10, name: "3 3 3 Day", emoji: "3️⃣" }, { month: 6, day: 22, name: "4 4 4 Day", emoji: "4️⃣" }, { month: 8, day: 4, name: "5 5 5 Day", emoji: "5️⃣" }, { month: 4, day: 20, name: "420", emoji: "🌿", url: "https://en.wikipedia.org/wiki/420_(cannabis_culture)" }, { month: 6, day: 6, name: "4 2 0 Day", emoji: "🌿" }, { month: 7, day: 10, name: "710", emoji: "🍯", url: "https://weedmaps.com/learn/cannabis-and-its-evolution/everything-you-need-to-know-about-710" }, { month: 9, day: 16, name: "7 1 0 Day", emoji: "🍯" }, { month: 1, day: 2, name: "Science Fiction Day", emoji: "🚀", url: "https://en.wikipedia.org/wiki/Isaac_Asimov" }, { month: 1, day: 3, name: "Festival of Sleep Day", emoji: "😴" }, { month: 1, day: 16, name: "Nothing Day", emoji: "🕳️", url: "https://en.wikipedia.org/wiki/National_Nothing_Day" }, { month: 1, day: 20, name: "Penguin Awareness Day", emoji: "🐧", url: "https://en.wikipedia.org/wiki/Penguin" }, { month: 1, day: 21, name: "Squirrel Appreciation Day", emoji: "🐿️", url: "https://en.wikipedia.org/wiki/Squirrel" }, { month: 1, day: 25, name: "Opposite Day", emoji: "🙃", url: "https://en.wikipedia.org/wiki/Opposite_Day" }, { month: 1, day: 27, name: "Chocolate Cake Day", emoji: "🎂", url: "https://en.wikipedia.org/wiki/Chocolate_cake" }, { month: 1, day: 27, name: "e-Day", emoji: "📐", url: "https://en.wikipedia.org/wiki/E_(mathematical_constant)" }, { month: 1, day: 31, name: "Backwards Day", emoji: "🔄" }, { month: 2, day: 2, name: "Groundhog Day", emoji: "🦔", url: "https://en.wikipedia.org/wiki/Groundhog_Day" }, { month: 2, day: 6, name: "Work Naked Day", emoji: "😳" }, { month: 2, day: 7, name: "Eat Ice Cream for Breakfast Day", emoji: "🍦" }, { month: 2, day: 12, name: "Darwin Day", emoji: "🐒", url: "https://en.wikipedia.org/wiki/Darwin_Day" }, { month: 2, day: 14, name: "Valentine's Day", emoji: "❤️", url: "https://en.wikipedia.org/wiki/Valentine%27s_Day" }, { month: 2, day: 17, name: "Random Act of Kindness Day", emoji: "🤝", url: "https://en.wikipedia.org/wiki/Random_act_of_kindness" }, { month: 2, day: 22, name: "Be Humble Day", emoji: "🙇" }, { month: 2, day: 28, name: "Public Sleeping Day", emoji: "💤" }, { month: 3, day: 4, name: "March Forth and Do Something Day", emoji: "🚶" }, { month: 3, day: 9, name: "Napping Day", emoji: "😪", url: "https://en.wikipedia.org/wiki/Napping" }, { month: 3, day: 10, name: "Mario Day", emoji: "🍄", url: "https://en.wikipedia.org/wiki/Mario_Day" }, { month: 3, day: 14, name: "Pi Day", emoji: "🥧", url: "https://en.wikipedia.org/wiki/Pi_Day" }, { month: 3, day: 15, name: "Everything You Think is Wrong Day", emoji: "🤔" }, { month: 3, day: 17, name: "St. Patrick's Day", emoji: "🍀", url: "https://en.wikipedia.org/wiki/Saint_Patrick%27s_Day" }, { month: 3, day: 22, name: "International Goof Off Day", emoji: "🤪" }, { month: 3, day: 23, name: "Puppy Day", emoji: "🐶", url: "https://en.wikipedia.org/wiki/Dog" }, { month: 3, day: 26, name: "Make Up Your Own Holiday Day", emoji: "📅" }, { month: 4, day: 1, name: "April Fools' Day", emoji: "🃏", url: "https://en.wikipedia.org/wiki/April_Fools%27_Day" }, { month: 4, day: 5, name: "First Contact Day", emoji: "👽", url: "https://en.wikipedia.org/wiki/Star_Trek:_First_Contact" }, { month: 4, day: 12, name: "Grilled Cheese Day", emoji: "🧀", url: "https://en.wikipedia.org/wiki/Grilled_cheese" }, { month: 4, day: 12, name: "Yuri's Night", emoji: "🛸", url: "https://en.wikipedia.org/wiki/Yuri%27s_Night" }, { month: 4, day: 16, name: "Wear Pajamas to Work Day", emoji: "🛌" }, { month: 4, day: 22, name: "Jelly Bean Day", emoji: "🫘", url: "https://en.wikipedia.org/wiki/Jelly_bean" }, { month: 4, day: 23, name: "Impossible Astronaut Day", emoji: "🕐", url: "https://en.wikipedia.org/wiki/The_Impossible_Astronaut" }, { month: 5, day: 1, name: "No Pants Day", emoji: "👖", url: "https://en.wikipedia.org/wiki/No_Pants_Day" }, { month: 5, day: 4, name: "Star Wars Day", emoji: "⚔️", url: "https://en.wikipedia.org/wiki/Star_Wars_Day" }, { month: 5, day: 5, name: "Cinco de Mayo", emoji: "🌮", url: "https://en.wikipedia.org/wiki/Cinco_de_Mayo" }, { month: 5, day: 9, name: "Lost Sock Memorial Day", emoji: "🧦" }, { month: 5, day: 11, name: "Eat What You Want Day", emoji: "🍕" }, { month: 5, day: 21, name: "Talk Like Yoda Day", emoji: "🟢", url: "https://en.wikipedia.org/wiki/Yoda" }, { month: 5, day: 25, name: "Towel Day", emoji: "🏖️", url: "https://en.wikipedia.org/wiki/Towel_Day" }, { month: 5, day: 29, name: "Put a Pillow on Your Fridge Day", emoji: "🛋️" }, { month: 6, day: 3, name: "Repeat Day", emoji: "🔁", url: "https://en.wikipedia.org/wiki/Repetition" }, { month: 6, day: 3, name: "Repeat Day", emoji: "🔁", url: "https://en.wikipedia.org/wiki/Repetition" }, { month: 6, day: 4, name: "Hug Your Cat Day", emoji: "🐱", url: "https://en.wikipedia.org/wiki/Cat" }, { month: 6, day: 18, name: "International Panic Day", emoji: "😱" }, { month: 6, day: 19, name: "Juneteenth", emoji: "✊", url: "https://en.wikipedia.org/wiki/Juneteenth" }, { month: 6, day: 22, name: "Onion Ring Day", emoji: "🧅", url: "https://en.wikipedia.org/wiki/Onion_ring" }, { month: 6, day: 26, name: "Seth's Birthday!", emoji: "🎂" }, { month: 6, day: 28, name: "Tau Day", emoji: "📐", url: "https://en.wikipedia.org/wiki/Turn_(angle)" }, { month: 7, day: 1, name: "International Joke Day", emoji: "😂", url: "https://en.wikipedia.org/wiki/Joke" }, { month: 7, day: 2, name: "World UFO Day", emoji: "🛸", url: "https://en.wikipedia.org/wiki/World_UFO_Day" }, { month: 7, day: 4, name: "Independence Day", emoji: "🎇", url: "https://en.wikipedia.org/wiki/Independence_Day_(United_States)" }, { month: 7, day: 4, name: "Sidewalk Egg Frying Day", emoji: "🍳", url: "https://en.wikipedia.org/wiki/Frying_an_egg_on_the_sidewalk" }, { month: 7, day: 11, name: "Cheer Up the Lonely Day", emoji: "🤗" }, { month: 7, day: 13, name: "Embrace Your Geekness Day", emoji: "🤓" }, { month: 7, day: 14, name: "Pandemonium Day", emoji: "🌀", url: "https://en.wikipedia.org/wiki/Pandemonium" }, { month: 7, day: 17, name: "World Emoji Day", emoji: "😄", url: "https://en.wikipedia.org/wiki/World_Emoji_Day" }, { month: 7, day: 19, name: "Stick Out Your Tongue Day", emoji: "👅" }, { month: 7, day: 22, name: "Pi Approximation Day", emoji: "≈", url: "https://en.wikipedia.org/wiki/Pi_Approximation_Day" }, { month: 7, day: 27, name: "Take Your Pants for a Walk Day", emoji: "🚶" }, { month: 8, day: 2, name: "Ice Cream Sandwich Day", emoji: "🍨", url: "https://en.wikipedia.org/wiki/Ice_cream_sandwich" }, { month: 8, day: 10, name: "Lazy Day", emoji: "🛋️", url: "https://en.wikipedia.org/wiki/Laziness" }, { month: 8, day: 12, name: "Middle Child Day", emoji: "😐", url: "https://en.wikipedia.org/wiki/Middle_child_syndrome" }, { month: 8, day: 13, name: "Left-Handers Day", emoji: "🤚", url: "https://en.wikipedia.org/wiki/Left-Handers_Day" }, { month: 8, day: 15, name: "Relaxation Day", emoji: "🧘" }, { month: 8, day: 16, name: "Tell a Joke Day", emoji: "🤣" }, { month: 8, day: 24, name: "Pluto Demoted Day", emoji: "🔭", url: "https://en.wikipedia.org/wiki/IAU_definition_of_planet" }, { month: 8, day: 24, name: "National Waffle Day", emoji: "🧇", url: "https://en.wikipedia.org/wiki/Waffle" }, { month: 8, day: 30, name: "Frankenstein Day", emoji: "⚡", url: "https://en.wikipedia.org/wiki/Frankenstein" }, { month: 9, day: 5, name: "Be Late for Something Day", emoji: "⏰" }, { month: 9, day: 6, name: "Fight Procrastination Day", emoji: "✅", url: "https://en.wikipedia.org/wiki/Procrastination" }, { month: 9, day: 13, name: "Blame Someone Else Day", emoji: "👉" }, { month: 9, day: 13, name: "Fortune Cookie Day", emoji: "🥠", url: "https://en.wikipedia.org/wiki/Fortune_cookie" }, { month: 9, day: 19, name: "Talk Like a Pirate Day", emoji: "🏴‍☠️", url: "https://en.wikipedia.org/wiki/International_Talk_Like_a_Pirate_Day" }, { month: 9, day: 22, name: "Elephant Appreciation Day", emoji: "🐘", url: "https://en.wikipedia.org/wiki/Elephant" }, { month: 9, day: 28, name: "Ask a Stupid Question Day", emoji: "❓" }, { month: 10, day: 4, name: "National Taco Day", emoji: "🌮", url: "https://en.wikipedia.org/wiki/Taco" }, { month: 10, day: 12, name: "Moment of Frustration Day", emoji: "😤" }, { month: 10, day: 14, name: "Dessert Day", emoji: "🍮", url: "https://en.wikipedia.org/wiki/Dessert" }, { month: 10, day: 21, name: "Back to the Future Day", emoji: "⏱️", url: "https://en.wikipedia.org/wiki/Back_to_the_Future_Day" }, { month: 10, day: 23, name: "Mole Day", emoji: "🦡", url: "https://en.wikipedia.org/wiki/Mole_Day" }, { month: 10, day: 31, name: "Halloween", emoji: "🎃", url: "https://en.wikipedia.org/wiki/Halloween" }, { month: 11, day: 8, name: "Cook Something Bold Day", emoji: "🔥" }, { month: 11, day: 11, name: "Veterans Day", emoji: "🎖️", url: "https://en.wikipedia.org/wiki/Veterans_Day" }, { month: 11, day: 13, name: "World Kindness Day", emoji: "💛", url: "https://en.wikipedia.org/wiki/World_Kindness_Day" }, { month: 11, day: 17, name: "Take a Hike Day", emoji: "🥾" }, { month: 11, day: 19, name: "Have a Bad Day Day", emoji: "😞" }, { month: 12, day: 12, name: "Gingerbread House Day", emoji: "🏠", url: "https://en.wikipedia.org/wiki/Gingerbread_house" }, { month: 12, day: 21, name: "Crossword Puzzle Day", emoji: "📝", url: "https://en.wikipedia.org/wiki/Crossword" }, { month: 12, day: 25, name: "Christmas", emoji: "🎄", url: "https://en.wikipedia.org/wiki/Christmas" }, { month: 12, day: 30, name: "Bacon Day", emoji: "🥓", url: "https://en.wikipedia.org/wiki/Bacon" }, { month: 12, day: 31, name: "New Year's Eve", emoji: "🥂", url: "https://en.wikipedia.org/wiki/New_Year%27s_Eve" }, ]; function nthWeekday(year, month, n, weekday) { // n: 1=first, -1=last. weekday: 0=Mon..6=Sun const base = new Date(year, month - 1, 1); if (n > 0) { const diff = (weekday - base.getDay() + 7) % 7; base.setDate(1 + diff + (n - 1) * 7); } else { const lastDay = new Date(year, month, 0).getDate(); base.setDate(lastDay); const diff = (base.getDay() - weekday + 7) % 7; base.setDate(lastDay - diff); } return base; } function easterDate(year) { // Anonymous Gregorian algorithm const a = year % 19, b = Math.floor(year / 100), c = year % 100; const d = Math.floor(b / 4), e = b % 4, f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3), h = (19*a + b - d - g + 15) % 30; const i = Math.floor(c / 4), k = c % 4; const l = (32 + 2*e + 2*i - h - k) % 7; const m = Math.floor((a + 11*h + 22*l) / 451); const month = Math.floor((h + l - 7*m + 114) / 31); const day = ((h + l - 7*m + 114) % 31) + 1; return new Date(year, month - 1, day); } // Approximate JD of solstice/equinox (Meeus Table 27.a) // season: 0=March equinox, 1=June solstice, 2=September equinox, 3=December solstice function seasonJD(year, season) { const Y = (year - 2000) / 1000; const jde = [ 2451623.80984 + 365242.37404*Y + 0.05169*Y*Y - 0.00411*Y*Y*Y - 0.00057*Y*Y*Y*Y, 2451716.56767 + 365241.62603*Y + 0.00325*Y*Y + 0.00888*Y*Y*Y - 0.00030*Y*Y*Y*Y, 2451810.05917 + 365242.01767*Y - 0.11575*Y*Y + 0.00337*Y*Y*Y + 0.00078*Y*Y*Y*Y, 2451900.05952 + 365242.74049*Y - 0.06223*Y*Y - 0.00823*Y*Y*Y + 0.00032*Y*Y*Y*Y, ][season]; // Convert JD to JS Date (JD 2440587.5 = Unix epoch) const ms = (jde - 2440587.5) * 86400000; return new Date(ms); } function buildFloatingHolidays(year) { const results = []; const add = (date, name, emoji, url) => { results.push({ month: date.getMonth() + 1, day: date.getDate(), name, emoji, url }); }; add(nthWeekday(year, 1, 3, 1), "MLK Day", "✊", "https://en.wikipedia.org/wiki/Martin_Luther_King_Jr._Day"); add(nthWeekday(year, 2, 3, 1), "Presidents Day", "🏛️", "https://en.wikipedia.org/wiki/Presidents%27_Day"); add(nthWeekday(year, 5, -1, 1), "Memorial Day", "🎖️", "https://en.wikipedia.org/wiki/Memorial_Day"); add(nthWeekday(year, 9, 1, 1), "Labor Day", "🔨", "https://en.wikipedia.org/wiki/Labor_Day"); add(nthWeekday(year, 10, 2, 1), "Columbus Day", "⛵", "https://en.wikipedia.org/wiki/Columbus_Day"); add(nthWeekday(year, 11, 4, 4), "Thanksgiving", "🦃", "https://en.wikipedia.org/wiki/Thanksgiving_(United_States)"); add(easterDate(year), "Easter", "🐣", "https://en.wikipedia.org/wiki/Easter"); const shrove = new Date(easterDate(year)); shrove.setDate(shrove.getDate() - 47); add(shrove, "Pancake Day", "🥞", "https://en.wikipedia.org/wiki/Shrove_Tuesday"); // Solstices & equinoxes add(seasonJD(year, 0), "March Equinox", "🌱", "https://en.wikipedia.org/wiki/March_equinox"); add(seasonJD(year, 1), "June Solstice", "☀️", "https://en.wikipedia.org/wiki/June_solstice"); add(seasonJD(year, 2), "September Equinox", "🍂", "https://en.wikipedia.org/wiki/September_equinox"); add(seasonJD(year, 3), "December Solstice", "❄️", "https://en.wikipedia.org/wiki/December_solstice"); return results; } // Build a DOY-keyed map of holidays for a given year function buildHolidayMap(year) { const map = {}; const cum = [0,31,59,90,120,151,181,212,243,273,304,334]; const leap = isLeapYear(year); const addHol = ({ month, day, name, emoji, url }) => { if (month === 2 && day === 29 && !leap) return; let doy = cum[month - 1] + day; if (leap && month > 2) doy++; if (!map[doy]) map[doy] = []; map[doy].push({ name, emoji, url }); }; FIXED_HOLIDAYS.forEach(addHol); buildFloatingHolidays(year).forEach(addHol); return map; } // --- Seth calendar helpers --- function isLeapYear(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; } // Returns { year, doy } for a given JS Date (local time) function getDayOfYear(date) { const y = date.getFullYear(); const m = date.getMonth() + 1; const d = date.getDate(); const cum = [0,31,59,90,120,151,181,212,243,273,304,334]; let doy = cum[m - 1] + d; if (isLeapYear(y) && m > 2) doy++; return { year: y, doy }; } // Returns a JS Date for day-of-year `doy` in `year` function dateFromDoy(year, doy) { return new Date(year, 0, doy); // Jan 0 + doy = doy-th day } // Gregorian month/day string for a doy function gregLabel(year, doy) { const d = dateFromDoy(year, doy); return `${GREG_MONTHS[d.getMonth()]} ${d.getDate()}`; } // Holiday names (0-indexed: n = 0..4 or 0..5) function holidayName(n, leap) { const lastIdx = leap ? 5 : 4; const names = [ leap ? "Holiday 0 — Boxing Day" : "Holiday 0", "Holiday 1", "Holiday 2", "Holiday 3", leap ? "Holiday 4" : "Holiday 4 — New Year's Eve", "Holiday 5 — New Year's Eve", // leap only ]; return names[n] || `Holiday ${n}`; } // --- State --- const today = new Date(); const todayDoy = getDayOfYear(today); let viewYear = todayDoy.year; let holidayMap = buildHolidayMap(viewYear); let viewMonth = (() => { // Start on today's Seth month (or holiday block) const doy = todayDoy.doy; return doy <= 360 ? Math.floor((doy - 1) / 36) : 10; // 10 = holidays })(); // --- DOM --- const navTitle = document.getElementById("navTitle"); const navSub = document.getElementById("navSub"); const calGrid = document.getElementById("calGrid"); const monthTabs = document.getElementById("monthTabs"); document.getElementById("prevYear").addEventListener("click", () => { viewYear--; holidayMap = buildHolidayMap(viewYear); render(); }); document.getElementById("nextYear").addEventListener("click", () => { viewYear++; holidayMap = buildHolidayMap(viewYear); render(); }); document.getElementById("prevMonth").addEventListener("click", () => { stepMonth(-1); render(); }); document.getElementById("nextMonth").addEventListener("click", () => { stepMonth(+1); render(); }); function stepMonth(dir) { viewMonth += dir; if (viewMonth < 0) { viewMonth = 10; viewYear--; holidayMap = buildHolidayMap(viewYear); } if (viewMonth > 10) { viewMonth = 0; viewYear++; holidayMap = buildHolidayMap(viewYear); } } // --- Render --- function render() { renderTabs(); if (viewMonth <= 9) { renderMonth(viewYear, viewMonth); } else { renderHolidays(viewYear); } } function renderTabs() { monthTabs.innerHTML = ""; for (let m = 0; m <= 9; m++) { const tab = document.createElement("button"); tab.className = "month-tab" + (m === viewMonth ? " active" : ""); tab.textContent = `Month ${m}`; tab.addEventListener("click", () => { viewMonth = m; render(); }); monthTabs.appendChild(tab); } const htab = document.createElement("button"); htab.className = "month-tab holiday-tab" + (viewMonth === 10 ? " active" : ""); htab.textContent = "Holidays"; htab.addEventListener("click", () => { viewMonth = 10; render(); }); monthTabs.appendChild(htab); } function renderMonth(year, month) { const leap = isLeapYear(year); // doy of Month N Day 0. In a leap year, months 2-9 start one DOY later // because leap day (DOY 60) sits inside Month 1. const monthStart = month * 36 + 1 + (leap && month >= 2 ? 1 : 0); // Approx gregorian range for subtitle // Leap M1 ends at DOY 73 (Mar 13) not 72, because the split cell adds 1 const isLeapM1 = leap && month === 1; const gStart = gregLabel(year, monthStart); const gEnd = gregLabel(year, monthStart + 35 + (isLeapM1 ? 1 : 0)); navTitle.childNodes[0].textContent = `${year} · Month ${month}`; navSub.textContent = `${gStart} – ${gEnd}`; // Build grid const grid = document.createElement("div"); grid.className = "cal-grid"; // Column headers: D0 D1 D2 D3 D4 D5 for (const lbl of WEEK_DAY_LABELS) { const h = document.createElement("div"); h.className = "col-header"; h.textContent = lbl; grid.appendChild(h); } // In a leap year, M1 W3 D4 is split: top=Feb28(DOY59), bottom=LeapDay(DOY60) // All cells from dayInMonth>=23 (W3 D5 onward) get doy+1 to skip over leap day for (let week = 0; week <= 5; week++) { for (let wd = 0; wd <= 5; wd++) { const dayInMonth = week * 6 + wd; // 0..35 // In leap M1, dayInMonth 22 = D4 W3 = Feb28/LeapDay split cell // dayInMonth 23+ shift by +1 to account for leap day const doy = monthStart + dayInMonth + (isLeapM1 && dayInMonth >= 23 ? 1 : 0); const isLeapSplit = isLeapM1 && dayInMonth === 22; // the split cell const cell = document.createElement("div"); cell.className = "cal-cell"; let topHalf = null; // only set for split cells; used by holiday marker logic below // Weekend coloring if (wd === 4 || wd === 5) cell.classList.add("seth-weekend"); // For the split cell, gregDate is Feb 28 (the top half) const gregDate = dateFromDoy(year, doy); const gregWd = GREG_WEEKDAYS[gregDate.getDay()]; const gregDay = gregDate.getDay(); // 0=Sun, 6=Sat if (gregDay === 0 || gregDay === 6) cell.classList.add("greg-weekend"); // Highlight today (today could be Feb 28 or Feb 29 in this cell) const leapDoyTop = doy; // Feb 28 const leapDoyBot = doy + 1; // Feb 29 (leap day) const isToday = year === todayDoy.year && (todayDoy.doy === doy || (isLeapSplit && todayDoy.doy === leapDoyBot)); if (isToday) cell.classList.add("today"); const astroParam = `${gregDate.getFullYear()}-${String(gregDate.getMonth()+1).padStart(2,"0")}-${String(gregDate.getDate()).padStart(2,"0")}`; if (isLeapSplit) { // Split cell: transparent outer wrapper containing two independent mini-cells cell.classList.add("has-leap-split"); // Remove border/bg classes — the outer cell is invisible; halves handle their own styling cell.classList.remove("seth-weekend", "greg-weekend", "today"); // ── Top half: normal D4 (Feb 28), Seth weekend ── topHalf = document.createElement("div"); topHalf.className = "leap-top"; const isTopToday = year === todayDoy.year && todayDoy.doy === doy; if (isTopToday) topHalf.classList.add("leap-top-today"); const topMoonLink = document.createElement("a"); topMoonLink.className = "moon-link"; topMoonLink.href = `/astro?date=${astroParam}`; topMoonLink.title = `Astronomy for ${gregLabel(year, doy)}`; topMoonLink.appendChild(makeMoonCanvas(gregDate, 14)); topHalf.appendChild(topMoonLink); const topNum = document.createElement("div"); topNum.className = "seth-day"; topNum.textContent = dayInMonth; const topGreg = document.createElement("div"); topGreg.className = "greg-date"; topGreg.textContent = `${gregWd} ${gregLabel(year, doy)}`; topHalf.appendChild(topNum); topHalf.appendChild(topGreg); cell.appendChild(topHalf); // ── Bottom half: Leap Day (Feb 29), its own independent mini-cell ── const botHalf = document.createElement("div"); botHalf.className = "leap-bottom"; const isLeapToday = year === todayDoy.year && todayDoy.doy === 60; if (isLeapToday) botHalf.classList.add("leap-today"); const leapDate = new Date(year, 1, 29); // Feb 29 const leapAstroParam = `${year}-02-29`; const botMoonLink = document.createElement("a"); botMoonLink.className = "moon-link"; botMoonLink.href = `/astro?date=${leapAstroParam}`; botMoonLink.title = "Astronomy for Feb 29"; botMoonLink.appendChild(makeMoonCanvas(leapDate, 14)); botHalf.appendChild(botMoonLink); const botNum = document.createElement("div"); botNum.className = "seth-day"; botNum.textContent = "Leap Day"; const botGreg = document.createElement("div"); botGreg.className = "greg-date"; const leapGregWd = GREG_WEEKDAYS[leapDate.getDay()]; botGreg.textContent = `${leapGregWd} Feb 29`; botHalf.appendChild(botNum); botHalf.appendChild(botGreg); cell.appendChild(botHalf); } else { // Normal cell const moonLink = document.createElement("a"); moonLink.className = "moon-link"; moonLink.href = `/astro?date=${astroParam}`; moonLink.title = `Astronomy for ${gregLabel(year, doy)}`; moonLink.appendChild(makeMoonCanvas(gregDate, 14)); cell.appendChild(moonLink); const dayNum = document.createElement("div"); dayNum.className = "seth-day"; dayNum.textContent = dayInMonth; const greg = document.createElement("div"); greg.className = "greg-date"; greg.textContent = `${gregWd} ${gregLabel(year, doy)}`; cell.appendChild(dayNum); cell.appendChild(greg); } // Holiday markers — for split cells, attach to topHalf (Feb 28); otherwise to cell const holTarget = isLeapSplit ? topHalf : cell; const hols = holidayMap[doy]; if (hols) { holTarget.classList.add("has-holiday"); hols.forEach(({ name, emoji, url }) => { const hl = document.createElement("div"); hl.className = "hol-label"; if (url) { const a = document.createElement("a"); a.href = url; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = `${emoji} ${name}`; hl.appendChild(a); } else { hl.textContent = `${emoji} ${name}`; } holTarget.appendChild(hl); }); } grid.appendChild(cell); } } calGrid.innerHTML = ""; calGrid.appendChild(grid); } function renderHolidays(year) { const leap = isLeapYear(year); const count = leap ? 6 : 5; const doyBase = 361; // H0 = doy 361 navTitle.childNodes[0].textContent = `${year} · Holidays`; navSub.textContent = `${gregLabel(year, doyBase)} – ${gregLabel(year, doyBase + count - 1)}`; const grid = document.createElement("div"); grid.className = "cal-grid holiday-grid"; for (let h = 0; h < count; h++) { const doy = doyBase + h; const isToday = year === todayDoy.year && doy === todayDoy.doy; const cell = document.createElement("div"); cell.className = "cal-cell holiday-cell"; if (isToday) cell.classList.add("today"); const gregDate = dateFromDoy(year, doy); const gregWd = GREG_WEEKDAYS[gregDate.getDay()]; const astroParam = `${gregDate.getFullYear()}-${String(gregDate.getMonth()+1).padStart(2,"0")}-${String(gregDate.getDate()).padStart(2,"0")}`; const moonLink = document.createElement("a"); moonLink.className = "moon-link"; moonLink.href = `/astro?date=${astroParam}`; moonLink.title = `Astronomy for ${gregLabel(year, doy)}`; moonLink.appendChild(makeMoonCanvas(gregDate, 14)); cell.appendChild(moonLink); const hNum = document.createElement("div"); hNum.className = "seth-day"; hNum.textContent = `H${h}`; const greg = document.createElement("div"); greg.className = "greg-date"; greg.textContent = `${gregWd} ${gregLabel(year, doy)}`; const name = document.createElement("div"); name.className = "holiday-name"; name.textContent = holidayName(h, leap); cell.appendChild(hNum); cell.appendChild(greg); cell.appendChild(name); grid.appendChild(cell); } calGrid.innerHTML = ""; calGrid.appendChild(grid); } render(); // "Today" legend link — jumps back to today's month/year document.getElementById("todayLink").addEventListener("click", e => { e.preventDefault(); viewYear = todayDoy.year; viewMonth = todayDoy.doy <= 360 ? Math.floor((todayDoy.doy - 1) / 36) : 10; holidayMap = buildHolidayMap(viewYear); render(); }); // Draw sample moon in legend (waxing gibbous ~0.7 illumination) const legendMoon = document.getElementById("legendMoon"); if (legendMoon) drawMoon(legendMoon, 0.7, true);