From caedad78a0243bc08d97b6da264c9ad26fc69689 Mon Sep 17 00:00:00 2001 From: Alec Date: Sat, 18 Apr 2026 21:16:46 -0700 Subject: [PATCH] Add calendar month/week views to EventsWidget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle between Agenda (existing), Week, and Month views using a segmented control. Month view is a hand-rolled CSS-grid calendar showing event/task dots per day. Week view shows 7-day columns with item count badges. Both views support prev/next navigation and click-to-expand day detail inline — no modal needed. Reuses the existing combined event+task data source (agendaItems). Styled with Herstel design tokens; no new dependencies. Resolves IRL-28 --- src/components/CalendarMonthView.module.css | 148 +++++++++++++ src/components/CalendarMonthView.tsx | 148 +++++++++++++ src/components/CalendarWeekView.module.css | 137 ++++++++++++ src/components/CalendarWeekView.tsx | 133 ++++++++++++ src/components/EventsWidget.module.css | 75 +++++++ src/components/EventsWidget.tsx | 224 ++++++++++++++++---- 6 files changed, 826 insertions(+), 39 deletions(-) create mode 100644 src/components/CalendarMonthView.module.css create mode 100644 src/components/CalendarMonthView.tsx create mode 100644 src/components/CalendarWeekView.module.css create mode 100644 src/components/CalendarWeekView.tsx diff --git a/src/components/CalendarMonthView.module.css b/src/components/CalendarMonthView.module.css new file mode 100644 index 0000000..0a48312 --- /dev/null +++ b/src/components/CalendarMonthView.module.css @@ -0,0 +1,148 @@ +.monthView { + display: flex; + flex-direction: column; + gap: 8px; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.dayHeader { + text-align: center; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-secondary); + padding: 4px 0; + text-transform: uppercase; +} + +.emptyCell { + aspect-ratio: 1; +} + +.dayCell { + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-primary); + transition: all 0.15s ease; + padding: 2px; + font-family: inherit; +} + +.dayCell:hover { + background: var(--surface-hover); + border-color: var(--surface-border); +} + +.dayNumber { + font-size: 0.8rem; + line-height: 1; +} + +.today { + background: rgba(212, 148, 107, 0.1); + border-color: var(--accent-color); +} + +.today .dayNumber { + color: var(--accent-color); + font-weight: 700; +} + +.selected { + background: rgba(212, 148, 107, 0.2); + border-color: var(--accent-light); +} + +.dots { + display: flex; + gap: 2px; + align-items: center; +} + +.dot { + width: 4px; + height: 4px; + border-radius: 50%; +} + +.eventDot { + background: var(--accent-color); +} + +.taskDot { + background: var(--color-info); +} + +.moreCount { + font-size: 0.55rem; + color: var(--text-secondary); +} + +.dayDetail { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + background: rgba(0, 0, 0, 0.15); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); +} + +.dayDetailHeader { + font-size: 0.8rem; + font-weight: 600; + color: var(--accent-light); +} + +.emptyDay { + font-size: 0.8rem; + color: var(--text-secondary); + font-style: italic; +} + +.dayItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; +} + +.itemDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.dayItemTitle { + flex-grow: 1; +} + +.itemBadge { + font-size: 0.65rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + white-space: nowrap; +} + +.eventBadge { + background: rgba(212, 148, 107, 0.15); + color: var(--accent-light); +} + +.taskBadge { + background: rgba(123, 159, 191, 0.14); + color: #A3C4DB; +} diff --git a/src/components/CalendarMonthView.tsx b/src/components/CalendarMonthView.tsx new file mode 100644 index 0000000..69d2f48 --- /dev/null +++ b/src/components/CalendarMonthView.tsx @@ -0,0 +1,148 @@ +import { useMemo } from "react"; +import styles from "./CalendarMonthView.module.css"; +import type { AgendaItem } from "./EventsWidget"; + +const DAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"]; + +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export default function CalendarMonthView({ + items, + viewDate, + selectedDate, + onSelectDate, +}: { + items: AgendaItem[]; + viewDate: Date; + selectedDate: Date | null; + onSelectDate: (date: Date) => void; +}) { + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + const firstDayOfWeek = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const today = new Date(); + + const itemsByDay = useMemo(() => { + const map = new Map(); + for (const item of items) { + const d = item.date; + if (d.getFullYear() === year && d.getMonth() === month) { + const day = d.getDate(); + if (!map.has(day)) map.set(day, []); + map.get(day)!.push(item); + } + } + return map; + }, [items, year, month]); + + const cells: (number | null)[] = []; + for (let i = 0; i < firstDayOfWeek; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + while (cells.length % 7 !== 0) cells.push(null); + + const selectedDayItems = selectedDate + ? items.filter((item) => sameDay(item.date, selectedDate)) + : []; + + return ( +
+
+ {DAY_LABELS.map((label, i) => ( +
+ {label} +
+ ))} + {cells.map((day, i) => { + if (day === null) { + return
; + } + + const cellDate = new Date(year, month, day); + const isToday = sameDay(cellDate, today); + const isSelected = + selectedDate !== null && sameDay(cellDate, selectedDate); + const dayItems = itemsByDay.get(day) || []; + + return ( + + ); + })} +
+ + {selectedDate && ( +
+
+ {selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +
+ {selectedDayItems.length === 0 ? ( +

Nothing scheduled

+ ) : ( + selectedDayItems.map((item) => ( +
+ + {item.title} + + {item.kind === "task" ? "Task" : "Event"} + +
+ )) + )} +
+ )} +
+ ); +} diff --git a/src/components/CalendarWeekView.module.css b/src/components/CalendarWeekView.module.css new file mode 100644 index 0000000..fa549d7 --- /dev/null +++ b/src/components/CalendarWeekView.module.css @@ -0,0 +1,137 @@ +.weekView { + display: flex; + flex-direction: column; + gap: 8px; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.dayColumn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 4px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-primary); + transition: all 0.15s ease; + font-family: inherit; +} + +.dayColumn:hover { + background: var(--surface-hover); + border-color: var(--surface-border); +} + +.dayLabel { + font-size: 0.7rem; + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; +} + +.dayNum { + font-size: 1.1rem; + font-weight: 700; + line-height: 1; +} + +.itemCount { + font-size: 0.65rem; + background: var(--accent-color); + color: var(--bg-color); + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + margin-top: 2px; +} + +.today { + background: rgba(212, 148, 107, 0.1); + border-color: var(--accent-color); +} + +.today .dayNum { + color: var(--accent-color); +} + +.selected { + background: rgba(212, 148, 107, 0.2); + border-color: var(--accent-light); +} + +.dayDetail { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + background: rgba(0, 0, 0, 0.15); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); +} + +.dayDetailHeader { + font-size: 0.8rem; + font-weight: 600; + color: var(--accent-light); +} + +.emptyDay { + font-size: 0.8rem; + color: var(--text-secondary); + font-style: italic; +} + +.dayItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; +} + +.itemDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.eventDot { + background: var(--accent-color); +} + +.taskDot { + background: var(--color-info); +} + +.dayItemTitle { + flex-grow: 1; +} + +.itemBadge { + font-size: 0.65rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + white-space: nowrap; +} + +.eventBadge { + background: rgba(212, 148, 107, 0.15); + color: var(--accent-light); +} + +.taskBadge { + background: rgba(123, 159, 191, 0.14); + color: #A3C4DB; +} diff --git a/src/components/CalendarWeekView.tsx b/src/components/CalendarWeekView.tsx new file mode 100644 index 0000000..5ed4cd0 --- /dev/null +++ b/src/components/CalendarWeekView.tsx @@ -0,0 +1,133 @@ +import { useMemo } from "react"; +import styles from "./CalendarWeekView.module.css"; +import type { AgendaItem } from "./EventsWidget"; + +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function getWeekDays(viewDate: Date): Date[] { + const d = new Date(viewDate); + const dayOfWeek = d.getDay(); + const sunday = new Date(d); + sunday.setDate(d.getDate() - dayOfWeek); + + const days: Date[] = []; + for (let i = 0; i < 7; i++) { + const day = new Date(sunday); + day.setDate(sunday.getDate() + i); + days.push(day); + } + return days; +} + +export default function CalendarWeekView({ + items, + viewDate, + selectedDate, + onSelectDate, +}: { + items: AgendaItem[]; + viewDate: Date; + selectedDate: Date | null; + onSelectDate: (date: Date) => void; +}) { + const today = new Date(); + const weekDays = useMemo(() => getWeekDays(viewDate), [viewDate]); + + const itemsByDayIndex = useMemo(() => { + const map = new Map(); + for (let i = 0; i < 7; i++) { + map.set(i, []); + } + for (const item of items) { + for (let i = 0; i < 7; i++) { + if (sameDay(item.date, weekDays[i])) { + map.get(i)!.push(item); + break; + } + } + } + return map; + }, [items, weekDays]); + + const selectedDayItems = selectedDate + ? items.filter((item) => sameDay(item.date, selectedDate)) + : []; + + return ( +
+
+ {weekDays.map((day, i) => { + const isToday = sameDay(day, today); + const isSelected = + selectedDate !== null && sameDay(day, selectedDate); + const dayItems = itemsByDayIndex.get(i) || []; + + return ( + + ); + })} +
+ + {selectedDate && ( +
+
+ {selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +
+ {selectedDayItems.length === 0 ? ( +

Nothing scheduled

+ ) : ( + selectedDayItems.map((item) => ( +
+ + {item.title} + + {item.kind === "task" ? "Task" : "Event"} + +
+ )) + )} +
+ )} +
+ ); +} diff --git a/src/components/EventsWidget.module.css b/src/components/EventsWidget.module.css index c99a530..7f8f3c7 100644 --- a/src/components/EventsWidget.module.css +++ b/src/components/EventsWidget.module.css @@ -5,6 +5,80 @@ height: 100%; } +/* View toggle & navigation */ +.viewControls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.viewToggle { + display: flex; + background: rgba(0, 0, 0, 0.2); + border-radius: var(--radius-sm); + padding: 2px; + gap: 2px; +} + +.viewBtn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + font-family: inherit; +} + +.viewBtn:hover { + color: var(--text-primary); + background: var(--surface-hover); +} + +.viewBtnActive { + background: var(--accent-color); + color: var(--bg-color); +} + +.viewBtnActive:hover { + background: var(--accent-hover); + color: var(--bg-color); +} + +.navControls { + display: flex; + align-items: center; + gap: 4px; +} + +.navBtn { + padding: 2px; +} + +.navLabel { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; + background: none; + border: none; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: color 0.15s ease; + font-family: inherit; + white-space: nowrap; +} + +.navLabel:hover { + color: var(--accent-color); +} + +/* Agenda view */ .eventList { list-style: none; flex-grow: 1; @@ -126,6 +200,7 @@ font-size: 0.9rem; } +/* Add form */ .addForm { display: flex; gap: var(--spacing-xs); diff --git a/src/components/EventsWidget.tsx b/src/components/EventsWidget.tsx index f2ab47c..9445c7d 100644 --- a/src/components/EventsWidget.tsx +++ b/src/components/EventsWidget.tsx @@ -2,8 +2,19 @@ import { useCallback, useRef, useState } from "react"; import { addEvent, deleteEvent } from "@/app/actions/events"; -import { CalendarDays, CheckSquare2, Plus, Trash2 } from "lucide-react"; +import { + CalendarDays, + CheckSquare2, + ChevronLeft, + ChevronRight, + LayoutGrid, + List, + Plus, + Trash2, +} from "lucide-react"; import ConfirmDialog from "./ConfirmDialog"; +import CalendarMonthView from "./CalendarMonthView"; +import CalendarWeekView from "./CalendarWeekView"; import styles from "./EventsWidget.module.css"; import { daysUntilCalendarDate } from "@/lib/dates"; import { taskAssigneeMeta, type TaskAssignee } from "@/lib/tasks"; @@ -22,7 +33,7 @@ type TaskItem = { completed: boolean; }; -type AgendaItem = +export type AgendaItem = | { id: string; title: string; @@ -37,6 +48,22 @@ type AgendaItem = assignee: TaskAssignee; }; +type CalendarView = "agenda" | "week" | "month"; + +const VIEW_OPTIONS: { key: CalendarView; icon: typeof List; label: string }[] = [ + { key: "agenda", icon: List, label: "Agenda" }, + { key: "week", icon: CalendarDays, label: "Week" }, + { key: "month", icon: LayoutGrid, label: "Month" }, +]; + +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + export default function EventsWidget({ initialEvents, initialTasks, @@ -44,6 +71,9 @@ export default function EventsWidget({ initialEvents: EventItem[]; initialTasks: TaskItem[]; }) { + const [view, setView] = useState("agenda"); + const [viewDate, setViewDate] = useState(() => new Date()); + const [selectedDate, setSelectedDate] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const formRef = useRef(null); @@ -115,48 +145,164 @@ export default function EventsWidget({ })), ].sort((left, right) => left.date.getTime() - right.date.getTime()); + const navigateMonth = (delta: number) => { + setViewDate((prev) => { + const next = new Date(prev); + next.setMonth(next.getMonth() + delta); + return next; + }); + setSelectedDate(null); + }; + + const navigateWeek = (delta: number) => { + setViewDate((prev) => { + const next = new Date(prev); + next.setDate(next.getDate() + delta * 7); + return next; + }); + setSelectedDate(null); + }; + + const goToToday = () => { + setViewDate(new Date()); + setSelectedDate(null); + }; + + const getNavLabel = () => { + if (view === "month") { + return viewDate.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }); + } + // week + const d = new Date(viewDate); + const dayOfWeek = d.getDay(); + const sunday = new Date(d); + sunday.setDate(d.getDate() - dayOfWeek); + const saturday = new Date(sunday); + saturday.setDate(sunday.getDate() + 6); + const fmt = (dt: Date) => + dt.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return `${fmt(sunday)} – ${fmt(saturday)}`; + }; + + const handleSelectDate = (date: Date) => { + if (selectedDate && sameDay(selectedDate, date)) { + setSelectedDate(null); + } else { + setSelectedDate(date); + } + }; + return (
-
- {agendaItems.length === 0 ? ( -

No upcoming events or due tasks

- ) : null} - - {agendaItems.map((item) => ( -
-
- {new Date(item.date).toLocaleDateString("en-US", { month: "short" })} - {new Date(item.date).getDate()} -
-
-
- {item.title} - - {item.kind === "task" ? "Task" : "Event"} +
+
+ {VIEW_OPTIONS.map(({ key, icon: Icon, label }) => ( + + ))} +
+ + {view !== "agenda" && ( +
+ + + +
+ )} +
+ + {view === "agenda" && ( +
+ {agendaItems.length === 0 ? ( +

No upcoming events or due tasks

+ ) : null} + + {agendaItems.map((item) => ( +
+
+ {new Date(item.date).toLocaleDateString("en-US", { month: "short" })} + {new Date(item.date).getDate()} +
+
+
+ {item.title} + + {item.kind === "task" ? "Task" : "Event"} + +
+ + {formatEventDate(item.date)} · {getDaysUntil(item.date)} + {item.kind === "task" ? ( + <> + {" "}·{" "} + {taskAssigneeMeta[item.assignee].label} + + ) : null}
- - {formatEventDate(item.date)} · {getDaysUntil(item.date)} - {item.kind === "task" ? ( - <> - {" "}·{" "} - {taskAssigneeMeta[item.assignee].label} - - ) : null} - + {item.kind === "event" ? ( + + ) : ( +
+ +
+ )}
- {item.kind === "event" ? ( - - ) : ( -
- -
- )} -
- ))} -
+ ))} +
+ )} + + {view === "month" && ( + + )} + + {view === "week" && ( + + )}