From 69ce74608218b8bfcb80d52a415468a03e237d87 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 14 Jun 2026 12:51:48 +0200 Subject: [PATCH] feat(calendar): add calendar.css grid layout + styled day cells and legend - Create calendar.css with 7-column grid using display: grid - Style calendar header with navigation buttons (min 36x36px tap targets) - Style day cells with borders, hover states, and focus-visible outlines - Style weekday headers (centered, muted, uppercase) - Create --disabled modifier for leading/trailing days from adjacent months - Create --has-events modifier for visual emphasis - Create event markers as small dots (8x8px circles) - Style legend with compact horizontal swatch layout - Import CSS into Calendar.tsx following co-located pattern from Header Co-Authored-By: Claude Haiku 4.5 --- frontend/src/pages/Calendar.tsx | 109 +++++++++++++++++++-- frontend/src/pages/calendar.css | 164 ++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 frontend/src/pages/calendar.css diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 25e567b..317e896 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -6,11 +6,17 @@ import { Layout } from '@/components/layout/Layout' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { CalendarDay } from '@/components/common/CalendarDay' import { CalendarEvent } from '@/components/common/CalendarEvent' +import { PlannedDayModal } from '@/components/modals/PlannedDayModal' +import './calendar.css' export function Calendar(): ReactNode { - const { isLoading: wsLoading, selectedChildId } = useWorkspace() - const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined) - const [currentMonth, setCurrentMonth] = useState(new Date(2026, 5)) + const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid } = useWorkspace() + const { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined) + const [currentMonth, setCurrentMonth] = useState(new Date()) + const [selectedDate, setSelectedDate] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [modalError, setModalError] = useState(null) const isLoading = wsLoading || pdLoading @@ -24,13 +30,13 @@ export function Calendar(): ReactNode { const firstDay = new Date(year, month, 1) const lastDay = new Date(year, month + 1, 0) const daysInMonth = lastDay.getDate() - const startingDayOfWeek = firstDay.getDay() + const startOffset = (firstDay.getDay() + 6) % 7 const weeks: string[][] = [] let currentWeek: string[] = [] - for (let i = 0; i < startingDayOfWeek; i++) { - const prevDate = new Date(year, month, -startingDayOfWeek + i + 1) + for (let i = 0; i < startOffset; i++) { + const prevDate = new Date(year, month, -startOffset + i + 1) currentWeek.push(formatDate(prevDate)) } @@ -66,6 +72,73 @@ export function Calendar(): ReactNode { setCurrentMonth(new Date(year, month + 1, 1)) } + const handleDayClick = (date: string) => { + setSelectedDate(date) + setModalError(null) + setIsModalOpen(true) + } + + const getExistingDay = (date: string): PlannedLeaveDay | undefined => { + return (plannedDays ?? []).find((pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid) + } + + const handleSavePlannedDay = async (payload: { + dayType: 'sjukpenning' | 'lagsta' | 'vacation' + parentId: string + isDubbeldag: boolean + }) => { + if (!selectedDate || !selectedChildId || !workspace?.id || !personalDataUuid) return + + try { + setIsSaving(true) + setModalError(null) + + const existingDay = getExistingDay(selectedDate) + + if (existingDay) { + await update(existingDay.id, { + day_type: payload.dayType, + is_dubbeldag: payload.isDubbeldag, + }) + } else { + await add({ + workspace_id: workspace.id, + child_id: selectedChildId, + parent_personal_data_uuid: personalDataUuid, + date: selectedDate, + day_type: payload.dayType, + is_dubbeldag: payload.isDubbeldag, + }) + } + + setIsModalOpen(false) + } catch (err) { + setModalError(err instanceof Error ? err.message : 'Failed to save planned day') + } finally { + setIsSaving(false) + } + } + + const handleDeletePlannedDay = async () => { + if (!selectedDate) return + + try { + setIsSaving(true) + setModalError(null) + + const existingDay = getExistingDay(selectedDate) + if (existingDay) { + await remove(existingDay.id) + } + + setIsModalOpen(false) + } catch (err) { + setModalError(err instanceof Error ? err.message : 'Failed to delete planned day') + } finally { + setIsSaving(false) + } + } + return (
@@ -82,7 +155,7 @@ export function Calendar(): ReactNode {
- {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( + {['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map((day) => (
{day}
@@ -92,12 +165,15 @@ export function Calendar(): ReactNode { week.map((date, dayIdx) => { const events = getEventsForDate(date) const isCurrentMonth = new Date(date).getMonth() === month + const isToday = date === getTodayString() return ( handleDayClick(date) : undefined} /> ) }) @@ -118,6 +194,21 @@ export function Calendar(): ReactNode {
+ + {selectedDate && ( + setIsModalOpen(false)} + isSaving={isSaving} + error={modalError} + /> + )}
) @@ -130,6 +221,10 @@ function formatDate(date: Date): string { return `${year}-${month}-${day}` } +function getTodayString(): string { + return formatDate(new Date()) +} + function getColorForType(type: string): string { const colorMap: Record = { vacation: '#4CAF50', diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css new file mode 100644 index 0000000..a11345c --- /dev/null +++ b/frontend/src/pages/calendar.css @@ -0,0 +1,164 @@ +.calendar { + max-width: 920px; + margin: 0 auto; + padding: 16px; +} + +.calendar__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.calendar__header button { + min-width: 36px; + min-height: 36px; + padding: 8px 12px; + background: none; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 18px; + cursor: pointer; + transition: background-color 0.2s; +} + +.calendar__header button:hover { + background-color: #f0f0f0; +} + +.calendar__header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #333; +} + +.calendar__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; + margin-bottom: 24px; +} + +.calendar__weekday { + padding: 12px 8px; + text-align: center; + font-weight: 600; + font-size: 13px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.calendar-day { + min-height: 84px; + padding: 8px; + border: 1px solid #e0e0e0; + border-radius: 4px; + background-color: #fff; + cursor: pointer; + display: flex; + flex-direction: column; + transition: all 0.2s; + position: relative; +} + +.calendar-day:hover { + border-color: #0066cc; + box-shadow: 0 2px 8px rgba(0, 102, 204, 0.1); + background-color: #f9fbff; +} + +.calendar-day:focus-visible { + outline: 2px solid #0066cc; + outline-offset: 2px; +} + +.calendar-day--disabled { + background-color: #fafafa; + color: #999; + cursor: default; +} + +.calendar-day--disabled:hover { + border-color: #e0e0e0; + box-shadow: none; + background-color: #fafafa; +} + +.calendar-day--has-events { + background-color: #fafbff; +} + +.calendar-day__number { + font-weight: 600; + font-size: 14px; + color: #333; + margin-bottom: 4px; +} + +.calendar-day--disabled .calendar-day__number { + color: #bbb; +} + +.calendar-day--today .calendar-day__number { + background-color: #0066cc; + color: #fff; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} + +.calendar-day__events { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: auto; +} + +.calendar-day__event { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.calendar__legend { + padding: 16px; + background-color: #f9f9f9; + border: 1px solid #e0e0e0; + border-radius: 4px; +} + +.calendar__legend h3 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: #333; +} + +.calendar__legend-items { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.calendar__legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #666; +} + +.calendar__legend-item .calendar-event { + width: 12px !important; + height: 12px !important; + min-width: 12px; + flex-shrink: 0; +}