fix(rcal): fix Invalid Date crash + add reminder button to day detail

- folk-calendar: fix data-date format for prev/next month padding days
  (month=0 produced "2026--1-28" which split into NaN month → Invalid Date)
- folk-calendar: guard toJSON against invalid dates to prevent toISOString crash
- folk-calendar-view: add "+" button to expanded day detail panel
  with inline title input + time picker for creating reminders
- Styles for the add-reminder form matching existing dark theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 19:28:12 -07:00
parent 8ff3e83a12
commit ae73e20c28
2 changed files with 100 additions and 8 deletions

View File

@ -350,7 +350,8 @@ export class FolkCalendar extends FolkShape {
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startPadding - 1; i >= 0; i--) {
const day = prevMonthLastDay - i;
html += `<div class="day other-month" data-date="${year}-${month - 1}-${day}">${day}</div>`;
const d = new Date(year, month - 1, day);
html += `<div class="day other-month" data-date="${d.getFullYear()}-${d.getMonth()}-${d.getDate()}">${day}</div>`;
}
// Current month days
@ -372,7 +373,8 @@ export class FolkCalendar extends FolkShape {
const totalCells = startPadding + daysInMonth;
const nextPadding = totalCells <= 35 ? 35 - totalCells : 42 - totalCells;
for (let day = 1; day <= nextPadding; day++) {
html += `<div class="day other-month" data-date="${year}-${month + 1}-${day}">${day}</div>`;
const d = new Date(year, month + 1, day);
html += `<div class="day other-month" data-date="${d.getFullYear()}-${d.getMonth()}-${d.getDate()}">${day}</div>`;
}
this.#daysContainer.innerHTML = html;
@ -451,11 +453,13 @@ export class FolkCalendar extends FolkShape {
return {
...super.toJSON(),
type: "folk-calendar",
selectedDate: this.selectedDate?.toISOString() || null,
events: this.events.map((e) => ({
...e,
date: e.date.toISOString(),
})),
selectedDate: this.selectedDate && !isNaN(this.selectedDate.getTime()) ? this.selectedDate.toISOString() : null,
events: this.events
.filter((e) => e.date && !isNaN(e.date.getTime()))
.map((e) => ({
...e,
date: e.date.toISOString(),
})),
};
}

View File

@ -1688,7 +1688,10 @@ class FolkCalendarView extends HTMLElement {
return `<div class="day-detail">
<div class="dd-header">
<span class="dd-date">${label}</span>
<button class="dd-close" id="dd-close">\u2715</button>
<div class="dd-header-actions">
<button class="dd-add" id="dd-add" data-add-date="${dateStr}" title="Add reminder">+</button>
<button class="dd-close" id="dd-close">\u2715</button>
</div>
</div>
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => {
@ -1866,6 +1869,70 @@ class FolkCalendarView extends HTMLElement {
setTimeout(() => document.addEventListener("click", closeHandler), 100);
}
private showDayAddForm(dateStr: string) {
// Remove any existing add-form
this.shadow.querySelector(".dd-add-form")?.remove();
const friendlyDate = new Date(dateStr + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
const form = document.createElement("div");
form.className = "dd-add-form";
form.innerHTML = `
<input type="text" class="dd-add-title" placeholder="Reminder title..." autofocus>
<div class="dd-add-times">
<button class="dd-add-time" data-hour="9">\u{1F305} 9 AM</button>
<button class="dd-add-time" data-hour="12">\u2600\uFE0F Noon</button>
<button class="dd-add-time" data-hour="17">\u{1F307} 5 PM</button>
<button class="dd-add-time" data-hour="21">\u{1F319} 9 PM</button>
</div>
<div class="dd-add-custom">
<input type="time" class="dd-add-time-input" value="09:00">
<button class="dd-add-submit">Add</button>
</div>
`;
const detail = this.shadow.querySelector(".day-detail");
if (!detail) return;
detail.appendChild(form);
const titleInput = form.querySelector(".dd-add-title") as HTMLInputElement;
titleInput.focus();
const createReminder = async (hour: number, minute = 0) => {
const title = titleInput.value.trim();
if (!title) { titleInput.focus(); titleInput.style.borderColor = "#ef4444"; return; }
form.remove();
const remindAt = new Date(`${dateStr}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`).getTime();
const base = this.getScheduleApiBase();
try {
await fetch(`${base}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, remindAt, allDay: false, syncToCalendar: true }),
});
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
} catch (err) {
console.error("[rCal] Failed to create reminder:", err);
}
};
// Quick-pick time buttons
form.querySelectorAll<HTMLButtonElement>(".dd-add-time[data-hour]").forEach((btn) => {
btn.addEventListener("click", () => createReminder(parseInt(btn.dataset.hour!)));
});
// Custom time + submit
form.querySelector(".dd-add-submit")?.addEventListener("click", () => {
const input = form.querySelector(".dd-add-time-input") as HTMLInputElement;
const [h, m] = (input.value || "09:00").split(":").map(Number);
createReminder(h, m);
});
// Enter key in title → use 9 AM default
titleInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") createReminder(9);
});
}
startTour() { this._tour.start(); }
// ── Attach Listeners ──
@ -2178,6 +2245,14 @@ class FolkCalendarView extends HTMLElement {
e.stopPropagation(); this.expandedDay = ""; this.render();
});
// Add reminder from day detail
$("dd-add")?.addEventListener("click", (e) => {
e.stopPropagation();
const dateStr = (e.target as HTMLElement).dataset.addDate;
if (!dateStr) return;
this.showDayAddForm(dateStr);
});
// Modal close
$("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
@ -2644,7 +2719,20 @@ class FolkCalendarView extends HTMLElement {
.day-detail { grid-column: 1 / -1; background: var(--rs-bg-surface); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 12px; }
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.dd-date { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); }
.dd-header-actions { display: flex; gap: 4px; align-items: center; }
.dd-add { background: none; border: 1px solid var(--rs-border-strong, #444); color: var(--rs-primary-hover, #818cf8); font-size: 18px; cursor: pointer; padding: 2px 8px; border-radius: 6px; line-height: 1; }
.dd-add:hover { background: var(--rs-bg-hover); }
.dd-close { background: none; border: none; color: var(--rs-text-muted); font-size: 18px; cursor: pointer; padding: 4px 8px; }
.dd-add-form { margin-top: 8px; padding: 10px; background: var(--rs-bg-surface-raised, #2a2a3e); border-radius: 8px; border: 1px solid var(--rs-border-strong, #444); }
.dd-add-title { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); font-size: 13px; margin-bottom: 8px; box-sizing: border-box; }
.dd-add-title:focus { outline: none; border-color: var(--rs-primary-hover, #818cf8); }
.dd-add-times { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px; }
.dd-add-time { padding: 6px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); cursor: pointer; font-size: 12px; text-align: center; }
.dd-add-time:hover { border-color: var(--rs-primary-hover, #818cf8); background: var(--rs-bg-hover); }
.dd-add-custom { display: flex; gap: 6px; }
.dd-add-time-input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); font-size: 12px; }
.dd-add-submit { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-primary-hover, #818cf8); background: var(--rs-primary-hover, #818cf8); color: #fff; cursor: pointer; font-size: 12px; font-weight: 500; }
.dd-add-submit:hover { opacity: 0.9; }
.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.dd-event:hover { background: var(--rs-bg-hover); }
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }