diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 3e6f076..94ea5ac 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -35,8 +35,12 @@ async def get_dashboard( today = client_date or date.today() upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) - # Subquery: calendar IDs belonging to this user (for event scoping) - user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + # Fetch calendar IDs once as a plain list — avoids repeating the subquery + # expression in each of the 3+ queries below and makes intent clearer. + calendar_id_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == current_user.id) + ) + user_calendar_ids = [row[0] for row in calendar_id_result.all()] # Today's events (exclude parent templates — they are hidden, children are shown) today_start = datetime.combine(today, datetime.min.time()) @@ -95,11 +99,13 @@ async def get_dashboard( total_todos = todo_row.total total_incomplete_todos = todo_row.incomplete - # Starred events (upcoming, ordered by date, scoped to user's calendars) + # Starred events (within upcoming_days window, ordered by date, scoped to user's calendars) + upcoming_cutoff_dt = datetime.combine(upcoming_cutoff, datetime.max.time()) starred_query = select(CalendarEvent).where( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.is_starred == True, CalendarEvent.start_datetime > today_start, + CalendarEvent.start_datetime <= upcoming_cutoff_dt, _not_parent_template, ).order_by(CalendarEvent.start_datetime.asc()).limit(5) starred_result = await db.execute(starred_query) @@ -171,8 +177,11 @@ async def get_upcoming( overdue_floor = today - timedelta(days=30) overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time()) - # Subquery: calendar IDs belonging to this user - user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + # Fetch calendar IDs once as a plain list (same rationale as /dashboard handler) + calendar_id_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == current_user.id) + ) + user_calendar_ids = [row[0] for row in calendar_id_result.all()] # Build queries — include overdue todos (up to 30 days back) and snoozed reminders todos_query = select(Todo).where( diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index fe71c6d..8de5365 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -8,8 +8,9 @@ import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; -import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; -import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search } from 'lucide-react'; +import enAuLocale from '@fullcalendar/core/locales/en-au'; +import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core'; +import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import axios from 'axios'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; @@ -32,6 +33,8 @@ const viewLabels: Record = { timeGridDay: 'Day', }; +const UMBRA_EVENT_CLASSES = ['umbra-event']; + export default function CalendarPage() { const queryClient = useQueryClient(); const location = useLocation(); @@ -363,14 +366,14 @@ export default function CalendarPage() { start: event.start_datetime, end: event.end_datetime || undefined, allDay: event.all_day, - backgroundColor: event.calendar_color || 'hsl(var(--accent-color))', - borderColor: event.calendar_color || 'hsl(var(--accent-color))', + color: 'transparent', editable: permissionMap.get(event.calendar_id) !== 'read_only', extendedProps: { is_virtual: event.is_virtual, is_recurring: event.is_recurring, parent_event_id: event.parent_event_id, calendar_id: event.calendar_id, + calendarColor: event.calendar_color || 'hsl(var(--accent-color))', }, })); @@ -500,6 +503,59 @@ export default function CalendarPage() { const navigateToday = () => calendarRef.current?.getApi().today(); const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view); + // Set --event-color CSS var via eventDidMount (write-only, no reads) + const handleEventDidMount = useCallback((info: { el: HTMLElement; event: { extendedProps: Record } }) => { + const color = info.event.extendedProps.calendarColor as string; + if (color) { + info.el.style.setProperty('--event-color', color); + } + }, []); + + const renderEventContent = useCallback((arg: EventContentArg) => { + const isMonth = arg.view.type === 'dayGridMonth'; + const isAllDay = arg.event.allDay; + const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id; + + const repeatIcon = isRecurring ? ( + + ) : null; + + if (isMonth) { + if (isAllDay) { + return ( +
+ {arg.event.title} + {repeatIcon} +
+ ); + } + // Timed events in month: dot + title + time right-aligned + return ( +
+ + {arg.event.title} + {repeatIcon} + {arg.timeText} +
+ ); + } + + // Week/day view — title on top, time underneath + return ( +
+
+ {arg.event.title} + {repeatIcon} +
+ {arg.timeText} +
+ ); + }, []); + + return (
@@ -644,6 +700,18 @@ export default function CalendarPage() { select={handleDateSelect} datesSet={handleDatesSet} height="100%" + locale={enAuLocale} + views={{ + dayGridMonth: { dayHeaderFormat: { weekday: 'short' } }, + timeGridWeek: { dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'numeric' } }, + timeGridDay: { dayHeaderFormat: { weekday: 'long', day: 'numeric', month: 'long' } }, + }} + eventTimeFormat={{ hour: 'numeric', minute: '2-digit', meridiem: 'short' }} + slotLabelFormat={{ hour: 'numeric', minute: '2-digit', meridiem: 'short' }} + slotEventOverlap={false} + eventDidMount={handleEventDidMount} + eventClassNames={UMBRA_EVENT_CLASSES} + eventContent={renderEventContent} />
diff --git a/frontend/src/index.css b/frontend/src/index.css index 677e3ad..d76073c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -91,9 +91,9 @@ --fc-button-hover-border-color: hsl(0 0% 20%); --fc-button-active-bg-color: hsl(var(--accent-color)); --fc-button-active-border-color: hsl(var(--accent-color)); - --fc-event-bg-color: hsl(var(--accent-color)); - --fc-event-border-color: hsl(var(--accent-color)); - --fc-event-text-color: hsl(0 0% 98%); + --fc-event-bg-color: transparent; + --fc-event-border-color: transparent; + --fc-event-text-color: inherit; --fc-page-bg-color: transparent; --fc-neutral-bg-color: hsl(0 0% 8% / 0.65); --fc-neutral-text-color: hsl(0 0% 98%); @@ -144,10 +144,44 @@ border-bottom-color: hsl(var(--accent-color)); } +/* Now indicator pulse dot */ +.fc .fc-timegrid-now-indicator-line::before { + content: ''; + position: absolute; + left: -3px; + top: -3px; + width: 6px; + height: 6px; + border-radius: 50%; + background: hsl(var(--accent-color)); + animation: pulse-dot 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .fc .fc-timegrid-now-indicator-line::before { + animation: none; + opacity: 1; + } +} + +/* Weekend columns: neutralise FullCalendar's built-in weekend td background. + The frame inherits --fc-neutral-bg-color identically to weekdays. + Weekend tint removed after 10+ attempts — cross-browser compositing divergence + (Firefox produces teal artifact from semi-transparent HSL on near-black bg). */ +.fc .fc-day-sat, +.fc .fc-day-sun { + background-color: transparent !important; +} + + .fc .fc-col-header-cell { background-color: hsl(0 0% 8% / 0.65); border-color: var(--fc-border-color); } +.fc .fc-col-header-cell.fc-day-sat, +.fc .fc-col-header-cell.fc-day-sun { + background-color: hsl(0 0% 8% / 0.65) !important; +} .fc-theme-standard td, .fc-theme-standard th { @@ -171,21 +205,62 @@ color: hsl(0 0% 63.9%); } -/* Event pills — compact rounded style */ -.fc .fc-daygrid-event { - border-radius: 4px; +/* ── Translucent event styling ── */ +.fc .umbra-event { + border: none !important; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +.fc .umbra-event .fc-event-main { + color: var(--event-color) !important; +} + +/* ── Month view: all-day events get translucent fill ── */ +.fc .fc-daygrid-block-event.umbra-event { + background: color-mix(in srgb, var(--event-color) 12%, transparent) !important; +} + +.fc .fc-daygrid-block-event.umbra-event:hover { + background: color-mix(in srgb, var(--event-color) 22%, transparent) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +/* ── Month view: timed events — dot only, card on hover ── */ +.fc .fc-daygrid-dot-event.umbra-event { + background: transparent !important; +} + +.fc .fc-daygrid-dot-event.umbra-event .fc-daygrid-event-dot { + border-color: var(--event-color) !important; + margin: 0 2px 0 0; +} + +.fc .fc-daygrid-dot-event.umbra-event:hover { + background: color-mix(in srgb, var(--event-color) 12%, transparent) !important; +} + +.fc .fc-daygrid-event.umbra-event { font-size: 0.75rem; - padding: 0px 4px; + padding: 1px 2px; line-height: 1.4; } -.fc .fc-timegrid-event { - border-radius: 4px; - font-size: 0.75rem; +/* ── Time grid (week/day view) — translucent fill ── */ +.fc .fc-timegrid-event.umbra-event { + background: color-mix(in srgb, var(--event-color) 12%, transparent) !important; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } -.fc .fc-timegrid-event .fc-event-main { - padding: 2px 4px; +.fc .fc-timegrid-event.umbra-event:hover { + background: color-mix(in srgb, var(--event-color) 22%, transparent) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +.fc .fc-timegrid-event.umbra-event .fc-event-main { + padding: 3px 6px; } /* Day number styling for today */ @@ -262,6 +337,10 @@ padding: 2px 6px; margin-bottom: 2px; } + /* Ensure translucent events in popover inherit styling */ + .fc .fc-more-popover .umbra-event { + margin-bottom: 2px; + } } /* ── Chromium native date picker icon fix (safety net) ── */ @@ -358,6 +437,11 @@ form[data-submitted] input:invalid + button { display: none; } + /* Hide time spans in custom eventContent on mobile month view */ + .fc .fc-daygrid-event .umbra-event-time { + display: none; + } + .fc .fc-timegrid-event { font-size: 0.6rem; }