// 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 (
);
}
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)',
}}>
{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 });