// Public lunar calendar — elegant, responsive, read-only. // Renders Chinese lunar dates, 节气, traditional festivals, Chinese public // holidays (法定节假日 + 调休), and common Western/international holidays. const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']; const MONTH_CN_S = ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月']; const WEEKDAYS_SUN = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; const WEEKDAYS_MON = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const WEEKDAYS_CN = ['日','一','二','三','四','五','六']; // ── Helpers ─────────────────────────────────────────────────── function buildGrid(year, month, weekStart) { const daysInMonth = new Date(year, month, 0).getDate(); const daysInPrev = new Date(year, month - 1, 0).getDate(); const firstDow = new Date(year, month - 1, 1).getDay(); const offset = (firstDow - weekStart + 7) % 7; const cells = []; for (let i = 0; i < offset; i++) { cells.push({ day: daysInPrev - offset + 1 + i, month: month - 1, year, outside: true }); } for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, month, year, outside: false }); let nxt = 1; while (cells.length < 42) cells.push({ day: nxt++, month: month + 1, year, outside: true }); return cells.slice(0, 42); } function useMediaQuery(q) { const [m, setM] = React.useState(() => window.matchMedia(q).matches); React.useEffect(() => { const mql = window.matchMedia(q); const fn = e => setM(e.matches); mql.addEventListener('change', fn); return () => mql.removeEventListener('change', fn); }, [q]); return m; } // ── Themes ──────────────────────────────────────────────────── // Shared calligraphic font stack — all themes use the Rice (WenKai) feel. const SHARED_FONTS = { fontSans: '"LXGW WenKai Screen", "Noto Serif SC", "Cormorant Garamond", Georgia, serif', fontSerif: '"LXGW WenKai Screen", "Cormorant Garamond", "Noto Serif SC", Georgia, serif', fontMono: '"JetBrains Mono", "LXGW WenKai Screen", ui-monospace, monospace', fontKai: '"LXGW WenKai Screen", "Noto Serif SC", serif', }; const THEMES = { ink: { name: 'Ink on Paper', bg: '#fafaf7', panel: '#ffffff', sidebar: '#f3f2ed', text: '#1a1a18', dim: '#6b6a63', faint: '#b8b7b0', line: '#e5e3dc', accent: '#8a2a1a', ...SHARED_FONTS, radius: 2, lunarColor: '#8a6a3a', todayBg: '#1a1a18', todayFg: '#fafaf7', sunday: '#a85038', kindColors: { festival: '#a84a3a', jieqi: '#5e7d4a', lunar: '#6b5a7a', obs: '#8a6a3a', pub: '#a84a3a', work: '#6b7a8a' }, }, rice: { name: 'Rice Paper', bg: '#f1ead8', panel: '#f7f1e0', sidebar: '#eae1c8', text: '#2a1f14', dim: '#6b5537', faint: '#b39a6c', line: '#d9cca8', accent: '#8a2a1a', ...SHARED_FONTS, radius: 0, lunarColor: '#8a2a1a', todayBg: '#8a2a1a', todayFg: '#f7f1e0', sunday: '#8a2a1a', kindColors: { festival: '#8a2a1a', jieqi: '#5e6d3a', lunar: '#6b5537', obs: '#8a6a3a', pub: '#8a2a1a', work: '#6b5537' }, }, dark: { name: 'Dark Pro', bg: '#14151a', panel: '#1c1d24', sidebar: '#0f1014', text: '#e8e6e0', dim: '#8a8a85', faint: '#4a4a48', line: '#2a2b34', accent: '#d8a85a', ...SHARED_FONTS, radius: 4, lunarColor: '#c89460', todayBg: '#d8a85a', todayFg: '#14151a', sunday: '#d46a5a', kindColors: { festival: '#d46a5a', jieqi: '#9ab87a', lunar: '#a895c4', obs: '#d8a85a', pub: '#d46a5a', work: '#7a8ca8' }, }, }; // ── Control toolbar (in-page theme / script) ───────────────── // Note: we deliberately don't expose a font-size switch — browser zoom // (⌘/Ctrl +/−) scales the whole layout proportionally and persists per-site, // which is what users reach for anyway. function Toolbar({ T, theme, setTheme, script, setScript, isMobile }) { const btn = (active) => ({ border: `1px solid ${active ? T.text : T.line}`, background: active ? T.text : 'transparent', color: active ? T.bg : T.text, padding: isMobile ? '5px 8px' : '6px 10px', fontSize: isMobile ? 11 : 12, fontFamily: T.fontSans, cursor: 'pointer', borderRadius: T.radius, letterSpacing: 0.3, }); const Group = ({ children }) => (
{children}
); return (
Theme {[['ink','Ink'],['rice','Rice'],['dark','Dark']].map(([v, l]) => ( ))} {!isMobile && {[['s','简'],['t','繁']].map(([v, l]) => ( ))} }
); } // ── Sidebar: identity + Subscribe ───────────────────────────── function Sidebar({ T, script, year, month, isMobile, toolbar }) { const pillar = window.LunarData.yearPillar(year, month, 15, script); const seasonName = ['冬','冬','春','春','春','夏','夏','夏','秋','秋','秋','冬'][month-1]; const [toast, setToast] = React.useState(null); const [modal, setModal] = React.useState(null); const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(null), 1800); }; const openSubscribe = (kind) => { setModal(kind); }; if (isMobile) { // Collapsed mobile header, no full sidebar return ( setModal('all')} toolbar={toolbar} /> ); } return ( ); } function MobileHeader({ T, pillar, year, month, seasonName, onSubscribe, toolbar }) { return (
Almanac {pillar.ganzhi}年·{pillar.zodiac}
{toolbar}
); } function SectionTitle({ T, children }) { return
{children}
; } function SubRow({ T, label, value, onClick }) { return ( ); } // ── Subscribe Modal: big URL + Copy + QR + client hints ────── function SubscribeModal({ T, kind, year, onClose, onToast }) { const urls = { caldav: { label: 'CalDAV Collection', url: 'https://cal.memojar.net/lunar', proto: 'https://' }, webcal: { label: 'iCal Subscription', url: 'webcal://cal.memojar.net/lunar.ics', proto: 'webcal://' }, all: { label: 'Subscribe', url: 'https://cal.memojar.net/lunar', proto: 'https://' }, }; const [currentKind, setKind] = React.useState(kind === 'all' ? 'caldav' : kind); const current = urls[currentKind]; const copy = () => { navigator.clipboard?.writeText(current.url); onToast('Copied ' + current.url); }; React.useEffect(() => { const onKey = e => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose]); const steps = { caldav: [ ['Apple Calendar', 'File › New CalDAV Account › Manual. Server: cal.memojar.net'], ['Fastmail / Nextcloud', 'Add subscription → paste URL'], ['Thunderbird', 'New Calendar → On the Network → CalDAV'], ], webcal: [ ['iPhone / Mac', 'Tap the webcal:// link → "Subscribe"'], ['Google Calendar', 'Other calendars → From URL → use https://'], ['Outlook', 'Add calendar → Subscribe from web'], ], }; return (
e.stopPropagation()} style={{ background: T.panel, color: T.text, borderRadius: 8, width: '100%', maxWidth: 520, maxHeight: '90vh', overflow: 'auto', fontFamily: T.fontSans, padding: '28px 28px 24px', border: `1px solid ${T.line}`, boxShadow: '0 20px 60px rgba(0,0,0,.25)', }}>
{current.label}
{kind === 'all' && (
{[['caldav','CalDAV'],['webcal','iCal']].map(([v, l]) => ( ))}
)}
{current.url}
{currentKind === 'webcal' && ( Open in Calendar ↗ )}
How to subscribe
{(steps[currentKind] || []).map(([who, how], i) => (
{who} {how}
))}
); } function Toast({ T, children }) { return (
{children}
); } // Minimal iCal generator — snapshot of lunar events for one year. function downloadIcs(year) { const pad = n => String(n).padStart(2, '0'); const events = []; // Collect festivals + jieqi + public holidays for the year for (let m = 1; m <= 12; m++) { const daysIn = new Date(year, m, 0).getDate(); for (let d = 1; d <= daysIn; d++) { const f = window.LunarData.lunarFestival(year, m, d, 's'); const j = window.LunarData.solarTerm(year, m, d, 's'); const p = window.LunarData.chinaPublicHoliday(year, m, d); const s = window.LunarData.solarHoliday(year, m, d); const dstr = `${year}${pad(m)}${pad(d)}`; if (f) events.push({ d: dstr, title: f.zh }); if (j) events.push({ d: dstr, title: j }); if (p && p.type === 'off') events.push({ d: dstr, title: p.name + ' (放假)' }); if (s && s.major) events.push({ d: dstr, title: s.zh }); } } const now = new Date(); const ts = `${now.getUTCFullYear()}${pad(now.getUTCMonth()+1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`; const lines = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//cal.memojar.net//Almanac//EN', 'X-WR-CALNAME:Almanac · 农历','X-WR-TIMEZONE:Asia/Shanghai']; for (const e of events) { const y = e.d.slice(0,4), mo = e.d.slice(4,6), da = e.d.slice(6,8); const nextDay = new Date(Date.UTC(+y, +mo-1, +da+1)); const dtend = `${nextDay.getUTCFullYear()}${pad(nextDay.getUTCMonth()+1)}${pad(nextDay.getUTCDate())}`; lines.push('BEGIN:VEVENT', `UID:${e.d}-${encodeURIComponent(e.title)}@cal.memojar.net`, `DTSTAMP:${ts}`, `DTSTART;VALUE=DATE:${e.d}`, `DTEND;VALUE=DATE:${dtend}`, `SUMMARY:${e.title}`, 'END:VEVENT'); } lines.push('END:VCALENDAR'); const blob = new Blob([lines.join('\r\n')], { type: 'text/calendar' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `lunar-${year}.ics`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 500); } // ── Month grid ──────────────────────────────────────────────── function MonthGrid({ T, year, month, weekStart, script, today, isMobile, onPrev, onNext, onToday, onJump, toolbar }) { const cells = buildGrid(year, month, weekStart); const weekdays = script === 's' && isMobile ? WEEKDAYS_CN : (weekStart === 0 ? WEEKDAYS_SUN : WEEKDAYS_MON); const orderedWeekdays = (script === 's' && isMobile) ? (weekStart === 0 ? WEEKDAYS_CN : [...WEEKDAYS_CN.slice(1), WEEKDAYS_CN[0]]) : weekdays; return (
{orderedWeekdays.map((wd, i) => { const isSun = (weekStart === 0 && i === 0) || (weekStart === 1 && i === 6); return (
{wd}
); })}
{cells.map((cell, i) => ( ))}
); } function TopBar({ T, year, month, script, isMobile, onPrev, onNext, onToday, onJump, toolbar }) { const daysIn = new Date(year, month, 0).getDate(); const pillar = window.LunarData.yearPillar(year, month, 15, script); const seasonName = ['冬','冬','春','春','春','夏','夏','夏','秋','秋','秋','冬'][month-1]; const termsThisMonth = []; for (let d = 1; d <= daysIn; d++) { const t = window.LunarData.solarTerm(year, month, d, script); if (t) termsThisMonth.push(`${t} ${d}`); } const [pickerOpen, setPickerOpen] = React.useState(false); return (
{!isMobile &&
{pillar.ganzhi}年 · {pillar.zodiac} · {seasonName}
} {!isMobile && termsThisMonth.length > 0 && (
{termsThisMonth.join(' · ')}
)}
{isMobile && (
)}
{!isMobile && toolbar} {!isMobile && (
Today
)}
{pickerOpen && { onJump(y, m); setPickerOpen(false); }} onClose={() => setPickerOpen(false)} />}
); } const MIN_YEAR = 1990, MAX_YEAR = 2040; function YearMonthPicker({ T, year, month, isMobile, onPick, onClose }) { const [viewYear, setViewYear] = React.useState(year); const ref = React.useRef(null); React.useEffect(() => { const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; setTimeout(() => document.addEventListener('pointerdown', close), 0); return () => document.removeEventListener('pointerdown', close); }, [onClose]); // All years in range as a scroll strip const years = []; for (let y = MIN_YEAR; y <= MAX_YEAR; y++) years.push(y); return (
{viewYear}
{/* Year scroll strip */}
{years.map(y => ( ))}
setViewYear(Number(e.target.value))} style={{ width: '100%', accentColor: T.accent, marginBottom: 14 }} />
{MONTH_NAMES.map((mn, i) => { const active = viewYear === year && i + 1 === month; return ( ); })}
); } function NavBtn({ T, children, filled, onClick, small }) { return ( ); } function DayCell({ T, cell, index, script, isToday, weekStart, isMobile }) { const col = index % 7; const row = Math.floor(index / 7); const isSun = (weekStart === 0 && col === 0) || (weekStart === 1 && col === 6); const LD = window.LunarData; const lunar = LD.lunarLabel(cell.year, cell.month, cell.day, script); const jieqi = LD.solarTerm(cell.year, cell.month, cell.day, script); const lunarFest = LD.lunarFestival(cell.year, cell.month, cell.day, script); const solarFest = LD.solarHoliday(cell.year, cell.month, cell.day); const floatFest = LD.floatingHoliday(cell.year, cell.month, cell.day); const publicH = LD.chinaPublicHoliday(cell.year, cell.month, cell.day); // Primary lunar/festival line (priority order) let lunarPrimary = null, lunarTone = T.lunarColor, isStrong = false; if (lunarFest) { lunarPrimary = lunarFest.zh; lunarTone = T.kindColors.festival; isStrong = true; } else if (jieqi) { lunarPrimary = jieqi; lunarTone = T.kindColors.jieqi; isStrong = true; } else if (lunar) { lunarPrimary = lunar.display; if (lunar.ld === 1) { isStrong = true; lunarTone = T.kindColors.lunar; } } // Secondary labels: solar holiday + floating holiday (shown as small chip list) const secondaryLabels = []; if (solarFest) secondaryLabels.push({ text: solarFest.zh, tone: solarFest.major ? T.kindColors.pub : T.kindColors.obs, strong: !!solarFest.major }); if (floatFest) secondaryLabels.push({ text: floatFest.zh, tone: T.kindColors.obs, strong: false }); // Public holiday badge (休 / 班) — 'off' = statutory, 'inLieu' = 调休补休 (visually same). const badge = publicH ? (publicH.type === 'work' ? { text: '班', bg: T.kindColors.work } : { text: '休', bg: T.kindColors.pub }) : null; return (
{/* Date row */}
{cell.day} {badge && !isMobile && ( {badge.text} )}
{/* Lunar/festival primary */} {lunarPrimary && (
{lunarPrimary}
)} {/* Secondary holidays */} {!isMobile && secondaryLabels.slice(0, 2).map((s, i) => (
{s.text}
))} {/* Mobile badge */} {badge && isMobile && (
{badge.text}
)}
); } // ── Main Calendar shell ─────────────────────────────────────── function Calendar({ theme, setTheme, accent, weekStart: ws0 = 1, script, setScript }) { const baseTheme = THEMES[theme]; const T = accent ? { ...baseTheme, accent, lunarColor: theme === 'rice' ? accent : baseTheme.lunarColor, todayBg: theme === 'dark' ? accent : baseTheme.todayBg, kindColors: { ...baseTheme.kindColors, festival: accent, pub: accent }, } : baseTheme; const isMobile = useMediaQuery('(max-width: 760px)'); const now = new Date(); const today = { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() }; const [nav, setNav] = React.useState(() => { try { const raw = localStorage.getItem('almanac-nav'); if (raw) { const o = JSON.parse(raw); if (o.y && o.m) return o; } } catch {} return { y: today.y, m: today.m }; }); React.useEffect(() => { try { localStorage.setItem('almanac-nav', JSON.stringify(nav)); } catch {} }, [nav]); const goto = (y, m) => { while (m < 1) { m += 12; y -= 1; } while (m > 12) { m -= 12; y += 1; } if (y < MIN_YEAR) { y = MIN_YEAR; m = 1; } if (y > MAX_YEAR) { y = MAX_YEAR; m = 12; } setNav({ y, m }); }; // Keyboard: arrow keys nav month, shift+arrow nav year React.useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'ArrowLeft') goto(nav.y, nav.m - (e.shiftKey ? 12 : 1)); if (e.key === 'ArrowRight') goto(nav.y, nav.m + (e.shiftKey ? 12 : 1)); if (e.key === 't' || e.key === 'T') setNav({ y: today.y, m: today.m }); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [nav, today.y, today.m]); const toolbar = ; return (
goto(nav.y, nav.m - 1)} onNext={() => goto(nav.y, nav.m + 1)} onToday={() => setNav({ y: today.y, m: today.m })} onJump={(y, m) => goto(y, m)} toolbar={toolbar} />
); } Object.assign(window, { Calendar, THEMES });