Files
dm-pal/src/components/CalendarWidget.tsx
T
itsamejms 3c599553fc
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 6m3s
feat: session auto-capture + NPC→Initiative push + lore grounding everywhere
- SessionLogger listens for a new LogEntry bus event; dice rolls,
  initiative turns/rounds, generated encounters and calendar day
  advances land in the active session as timestamped entries — the
  raw material the AI summary actually needs. Mute toggle in the
  toolbar (persisted, default on).
- NPC Generator: '⚔ to initiative' button pushes the generated stat
  block (HP) into the tracker via the existing AddCombatants bus event.
- ragQuery now set on Encounter, Quest and Session summary too
  (NPC/Item/World already had it) — every generator is world-grounded.
2026-09-07 10:25:38 +01:00

444 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CalConfig>("calendar.config", DEFAULT_CONFIG);
const [month, setMonth] = useState(0);
const [year, setYear] = useState(1492);
const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [newEvent, setNewEvent] = useState("");
const [newCategory, setNewCategory] = useState(CATEGORIES[0].label);
const [newRecur, setNewRecur] = useState<Recur>("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 (
<div className="flex flex-col gap-2 h-full">
{/* View toggle */}
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
onClick={() => setView("month")}
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "month" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
>
Month
</button>
<button
onClick={() => setView("agenda")}
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "agenda" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
>
Agenda
</button>
<button
onClick={() => setView("edit")}
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "edit" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
title="Edit the calendar (months, day counts, year label)"
>
⚙
</button>
</div>
<span className="text-[10px] text-[var(--color-text-secondary)]">
Today: {monthName(config, today.month)} {today.day}, {today.year} {config.yearLabel} {moonPhase(today.day, today.month, today.year)}
</span>
</div>
{view === "edit" ? (
<div className="flex-1 overflow-y-auto flex flex-col gap-2">
<div className="text-[10px] text-[var(--color-text-dim)]">
Edit months, day counts, and the year label. Changes apply immediately.
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-[var(--color-text-secondary)]">Year label</label>
<input
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] w-20"
value={config.yearLabel}
onChange={(e) => setConfig({ ...config, yearLabel: e.target.value })}
placeholder="DR"
/>
<span className="text-[10px] text-[var(--color-text-dim)]">→ shown as “{year} {config.yearLabel}”</span>
</div>
<div className="flex flex-col gap-1">
{config.months.map((m, i) => (
<div key={i} className="flex gap-1 items-center">
<span className="text-[9px] text-[var(--color-text-dim)] w-4 font-mono shrink-0">{i + 1}</span>
<input
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
value={m.name}
onChange={(e) => setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })}
/>
<input
type="number"
min={1}
max={99}
className="w-14 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
value={m.days}
onChange={(e) => setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, days: Math.max(1, parseInt(e.target.value) || 1) } : x) })}
/>
<button
onClick={() => setConfig({ ...config, months: config.months.filter((_, j) => j !== i) })}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer shrink-0"
title="Remove month"
>
×
</button>
</div>
))}
</div>
<div className="flex gap-2">
<button
onClick={() => setConfig({ ...config, months: [...config.months, { name: `Month ${config.months.length + 1}`, days: 30 }] })}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
>
+ month
</button>
<button
onClick={() => { setConfig(DEFAULT_CONFIG); setMonth(0); }}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] cursor-pointer"
title="Reset to the Forgotten Realms calendar"
>
Reset to Forgotten Realms
</button>
<button
onClick={() => setView("month")}
className="ml-auto rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
>
Done
</button>
</div>
</div>
) : view === "month" ? (
<>
{/* Month navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => { if (month === 0) { setMonth(config.months.length - 1); setYear(year - 1); } else setMonth(month - 1); }}
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
>
‹
</button>
<div className="text-center">
<div className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
{monthName(config, month)}
</div>
<div className="text-[var(--color-text-dim)] text-[10px]">{year} {config.yearLabel}</div>
</div>
<button
onClick={() => { if (month === config.months.length - 1) { setMonth(0); setYear(year + 1); } else setMonth(month + 1); }}
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
>
›
</button>
</div>
<div className="flex items-center justify-end gap-1">
<button
onClick={jumpToToday}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
title="Jump the view to today"
>
Today
</button>
<button
onClick={nextDay}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
title="Advance the campaign date by one day"
>
Next day →
</button>
</div>
{/* Day grid */}
<div className="grid grid-cols-7 gap-0.5">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
{d}
</div>
))}
{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 (
<button
key={dayNum}
onClick={() => setSelectedDay(dayNum)}
className={`rounded text-[10px] py-1 cursor-pointer transition-colors relative ${
isToday
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-bold"
: isSelected
? "bg-[var(--color-bg-card-hover)] text-[var(--color-text-primary)]"
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
}`}
>
{dayNum}
{dayEvts.length > 0 && !isToday && (
<span className="flex justify-center gap-0.5 mt-0.5">
{dayEvts.slice(0, 3).map((e, j) => (
<span key={j} className="w-1 h-1 rounded-full" style={{ backgroundColor: catColor(e.category) }} />
))}
</span>
)}
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
</button>
);
})}
</div>
{/* Events for selected day */}
{selectedDay !== null && (
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
{monthName(config, month)} {selectedDay}
</div>
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
{weatherFor(selectedDay, month, year)}
</span>
</div>
{dayEvents.length === 0 && (
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
)}
{dayEvents.map((e, i) => (
<div key={i} className="flex items-center gap-1.5 py-0.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: catColor(e.category) }} />
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
{e.recur !== "none" && (
<span className="text-[8px] text-[var(--color-text-dim)]" title={`Repeats ${e.recur}`}>↻{e.recur === "monthly" ? "m" : "y"}</span>
)}
<button
onClick={() => removeEvent(events.indexOf(e))}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
>
×
</button>
</div>
))}
<div className="flex flex-col gap-1 mt-1">
<input
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
value={newEvent}
onChange={(e) => setNewEvent(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addEvent()}
placeholder="Add event…"
/>
<div className="flex gap-1">
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="flex-1 min-w-0 rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[9px] cursor-pointer"
>
{CATEGORIES.map((c) => <option key={c.label} value={c.label}>{c.label}</option>)}
</select>
<select
value={newRecur}
onChange={(e) => setNewRecur(e.target.value as Recur)}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[9px] cursor-pointer"
title="Repeat"
>
<option value="none">Once</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
<button
onClick={addEvent}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
>
+
</button>
</div>
</div>
</div>
)}
</>
) : (
// Agenda view — next events from today forward.
<div className="flex-1 overflow-y-auto">
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1">
Upcoming events
</div>
{agenda.length === 0 && (
<div className="text-[var(--color-text-dim)] text-[10px] italic">No upcoming events.</div>
)}
{agenda.map((a, i) => (
<div key={i} className="flex items-center gap-1.5 py-1 border-b border-[var(--color-border-subtle)]">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: catColor(a.e.category) }} />
<span className="text-[var(--color-text-secondary)] text-[10px] font-mono shrink-0">
{monthName(config, a.month).slice(0, 3)} {a.day}
</span>
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{a.e.text}</span>
{a.e.recur !== "none" && (
<span className="text-[8px] text-[var(--color-text-dim)]">↻{a.e.recur === "monthly" ? "m" : "y"}</span>
)}
</div>
))}
<div className="mt-2 flex justify-end">
<button
onClick={nextDay}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
>
Next day →
</button>
</div>
</div>
)}
</div>
);
}