import { useState, useMemo } from "react"; import { usePersistentState } from "../lib/usePersistentState"; import { bus, Events } from "../lib/bus"; // ponytail: the calendar is user-configurable for homebrew campaigns. // `months` carry their own day count so a custom calendar can have variable // month lengths. Default = the Forgotten Realms calendar (12 Γ— 30). interface CalMonth { name: string; days: number; } interface CalConfig { months: CalMonth[]; yearLabel: string; // suffix shown after the year, e.g. "DR" } const FR_MONTHS: CalMonth[] = [ { name: "Hammer", days: 30 }, { name: "Alturiak", days: 30 }, { name: "Ches", days: 30 }, { name: "Tarsakh", days: 30 }, { name: "Mirtul", days: 30 }, { name: "Kythorn", days: 30 }, { name: "Flamerule", days: 30 }, { name: "Eleasis", days: 30 }, { name: "Eleint", days: 30 }, { name: "Marpenoth", days: 30 }, { name: "Uktar", days: 30 }, { name: "Nightal", days: 30 }, ]; const DEFAULT_CONFIG: CalConfig = { months: FR_MONTHS, yearLabel: "DR" }; // ponytail: named categories with fixed colors so events are color-coded by // intent, not cycled arbitrarily. A DM picks the category when creating. const CATEGORIES = [ { label: "Festival", color: "#e6cc80" }, { label: "Quest", color: "var(--color-gold-bright)" }, { label: "NPC", color: "var(--color-info)" }, { label: "Political", color: "#a335ee" }, { label: "Combat", color: "var(--color-danger)" }, { label: "Custom", color: "var(--color-success)" }, ]; const catColor = (cat: string) => CATEGORIES.find((c) => c.label === cat)?.color ?? "var(--color-gold-bright)"; type Recur = "none" | "yearly" | "monthly"; interface CalendarEvent { day: number; month: number; text: string; category: string; recur: Recur; } function yearLen(cfg: CalConfig): number { return cfg.months.reduce((s, m) => s + m.days, 0); } function daysInMonth(cfg: CalConfig, m: number): number { return cfg.months[m]?.days ?? 30; } function monthName(cfg: CalConfig, m: number): string { return cfg.months[m]?.name ?? `Month ${m + 1}`; } // absolute day index for a (day,month,year) under a variable-length calendar. function dayIndex(day: number, month: number, year: number, cfg: CalConfig): number { let idx = year * yearLen(cfg); for (let i = 0; i < month; i++) idx += cfg.months[i].days; return idx + (day - 1); } // inverse of dayIndex. function fromIndex(idx: number, cfg: CalConfig): { day: number; month: number; year: number } { const yl = yearLen(cfg); let year = Math.floor(idx / yl); let rem = idx % yl; let month = 0; while (month < cfg.months.length && rem >= cfg.months[month].days) { rem -= cfg.months[month].days; month++; } return { day: rem + 1, month, year }; } export function CalendarWidget() { const [config, setConfig] = usePersistentState("calendar.config", DEFAULT_CONFIG); const [month, setMonth] = useState(0); const [year, setYear] = useState(1492); const [events, setEvents] = usePersistentState("calendar.events", []); const [selectedDay, setSelectedDay] = useState(null); const [newEvent, setNewEvent] = useState(""); const [newCategory, setNewCategory] = useState(CATEGORIES[0].label); const [newRecur, setNewRecur] = useState("none"); const [view, setView] = useState<"month" | "agenda" | "edit">("month"); // ponytail: the campaign's current date, persisted so it survives reloads. const [today, setToday] = usePersistentState<{ day: number; month: number; year: number }>( "calendar.today", { day: 15, month: 5, year: 1492 }, ); const MOON_PHASES = ["πŸŒ‘", "πŸŒ’", "πŸŒ“", "πŸŒ”", "πŸŒ•", "πŸŒ–", "πŸŒ—", "🌘"]; // ponytail: 8 moon phases on a 30-day cycle, derived from the absolute day // index so the phase is stable for any date under any calendar config. function moonPhase(day: number, month: number, year: number): string { const idx = dayIndex(day, month, year, config); const p = Math.floor(((idx % 30) / 30) * 8); return MOON_PHASES[((p % 8) + 8) % 8]; } const WEATHER = [ "β˜€ Clear", "β›… Fair", "☁ Overcast", "🌧 Light rain", "β›ˆ Storm", "❄ Snow", "🌫 Fog", "πŸ’¨ Windy", "πŸŒ€ Unnatural", ]; function weatherFor(day: number, month: number, year: number): string { const idx = dayIndex(day, month, year, config); return WEATHER[Math.abs(idx) % WEATHER.length]; } function nextDay() { let { day, month, year } = today; day += 1; if (day > daysInMonth(config, month)) { day = 1; month += 1; } if (month >= config.months.length) { month = 0; year += 1; } setToday({ day, month, year }); // ponytail: the campaign clock ticks β€” the session log hears about it. const label = config.months[month]?.name ?? String(month + 1); bus.emit(Events.LogEntry, { text: `πŸ—“ Day advanced: ${day} ${label} ${config.yearLabel} ${year} β€” ${weatherFor(day, month, year)}`, }); } function jumpToToday() { setMonth(today.month); setYear(today.year); setSelectedDay(today.day); } function addEvent() { if (!newEvent.trim() || selectedDay === null) return; setEvents((prev) => [ ...prev, { day: selectedDay, month, text: newEvent.trim(), category: newCategory, recur: newRecur }, ]); setNewEvent(""); } function removeEvent(idx: number) { setEvents((prev) => prev.filter((_, i) => i !== idx)); } // ponytail: does an event fall on a given (day,month)? Recurring events // match on month (yearly) or every month (monthly). One-off events match // day+month only β€” we don't store the creation year, so a one-off repeats // visually each year. Acceptable for a DM calendar; store year if it bites. function eventOn(e: CalendarEvent, day: number, m: number): boolean { if (e.recur === "monthly") return e.day === day; if (e.recur === "yearly") return e.day === day && e.month === m; return e.day === day && e.month === m; } const dayEvents = useMemo( () => events.filter((e) => e.day === selectedDay && (e.recur === "monthly" || e.month === month)), [events, selectedDay, month], ); // ponytail: agenda β€” the next N events from today forward, recurring expanded. const agenda = useMemo(() => { const startIdx = dayIndex(today.day, today.month, today.year, config); const upcoming: { e: CalendarEvent; day: number; month: number; year: number }[] = []; const scan = 2 * yearLen(config); // ~2 years for (let off = 0; off < scan && upcoming.length < 12; off++) { const { day, month: dm, year: dy } = fromIndex(startIdx + off, config); for (const e of events) { if (eventOn(e, day, dm)) upcoming.push({ e, day, month: dm, year: dy }); } } return upcoming; }, [events, today, config]); const dim = daysInMonth(config, month); return (
{/* View toggle */}
Today: {monthName(config, today.month)} {today.day}, {today.year} {config.yearLabel} {moonPhase(today.day, today.month, today.year)}
{view === "edit" ? (
Edit months, day counts, and the year label. Changes apply immediately.
setConfig({ ...config, yearLabel: e.target.value })} placeholder="DR" /> β†’ shown as β€œ{year} {config.yearLabel}”
{config.months.map((m, i) => (
{i + 1} setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })} /> setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, days: Math.max(1, parseInt(e.target.value) || 1) } : x) })} />
))}
) : view === "month" ? ( <> {/* Month navigation */}
{monthName(config, month)}
{year} {config.yearLabel}
{/* Day grid */}
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
{d}
))} {Array.from({ length: dim }, (_, i) => { const dayNum = i + 1; const isToday = dayNum === today.day && month === today.month && year === today.year; const dayEvts = events.filter((e) => e.day === dayNum && (e.recur === "monthly" || e.month === month)); const isSelected = dayNum === selectedDay; return ( ); })}
{/* Events for selected day */} {selectedDay !== null && (
{monthName(config, month)} {selectedDay}
{weatherFor(selectedDay, month, year)}
{dayEvents.length === 0 && (
No events
)} {dayEvents.map((e, i) => (
{e.text} {e.recur !== "none" && ( ↻{e.recur === "monthly" ? "m" : "y"} )}
))}
setNewEvent(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addEvent()} placeholder="Add event…" />
)} ) : ( // Agenda view β€” next events from today forward.
Upcoming events
{agenda.length === 0 && (
No upcoming events.
)} {agenda.map((a, i) => (
{monthName(config, a.month).slice(0, 3)} {a.day} {a.e.text} {a.e.recur !== "none" && ( ↻{a.e.recur === "monthly" ? "m" : "y"} )}
))}
)}
); }