diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bfc04bb..9ae7b6f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -40,6 +40,7 @@ const CalculatorWidget = React.lazy(() => import('./components/CalculatorWidget. const TaskComposerPanel = React.lazy(() => import('./components/TaskComposerPanel.jsx')); const FriendsPanel = React.lazy(() => import('./components/FriendsPanel.jsx')); const Calendar = React.lazy(() => import('./components/Calendar.jsx')); +const CalendarSummaryCard = React.lazy(() => import('./components/CalendarSummaryCard.jsx')); const DynamicColorPlugin = registerPlugin('DynamicColor'); @@ -1641,6 +1642,11 @@ function App() { + + }> + setShowCalendar(true)} /> + + }> @@ -1742,11 +1748,18 @@ function App() { /> - - }> - - - +
+ + }> + + + + + }> + setShowCalendar(true)} /> + + +
diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx index c0d2a79..49fb50b 100644 --- a/frontend/src/components/Calendar.jsx +++ b/frontend/src/components/Calendar.jsx @@ -20,7 +20,7 @@ const COLOR_PRESETS = [ { name: 'Pink', value: '#ec4899' } ]; -const getDDayText = (targetDate) => { +export const getDDayText = (targetDate) => { if (!targetDate) return ''; const today = new Date(); today.setHours(0, 0, 0, 0); @@ -32,7 +32,7 @@ const getDDayText = (targetDate) => { return `D+${Math.abs(diff)}`; }; -const formatDateStr = (d) => { +export const formatDateStr = (d) => { const yr = d.getFullYear(); const mo = String(d.getMonth() + 1).padStart(2, '0'); const dy = String(d.getDate()).padStart(2, '0'); @@ -97,7 +97,7 @@ const getRruleLabel = (rule) => { // Splits a stored timestamp into an editable date ('YYYY-MM-DD') + time ('HH:MM'). // Handles naive values ('2026-09-01', '2026-09-01T17:15') and full Google ISO // values ('2026-09-01T17:15:00+05:30') — keeps the wall-clock time as-is. -const splitDateTime = (value) => { +export const splitDateTime = (value) => { if (!value) return { date: '', time: '' }; const s = String(value); const m = s.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}):(\d{2})(?::\d{2})?.*)?$/); @@ -106,7 +106,7 @@ const splitDateTime = (value) => { }; // "5:15 PM" style wall-clock display for the detail panel. -const getEventTime = (ev) => { +export const getEventTime = (ev) => { if (!ev || ev.is_all_day || !ev.event_date) return ''; const { time } = splitDateTime(ev.event_date); if (!time) return ''; @@ -313,7 +313,7 @@ const CalendarCell = memo(({ dateStr, day, currentMonth, isToday, isSelected, da // Does this event occupy the given calendar day (YYYY-MM-DD)? // Handles single/multi-day spans AND recurring events (DAILY/WEEKLY/MONTHLY/YEARLY // with optional INTERVAL). For recurring events the optional end_date caps the series. -const isEventOnDate = (ev, dateStr) => { +export const isEventOnDate = (ev, dateStr) => { const startStr = ev.event_date ? String(ev.event_date).slice(0, 10) : ''; if (!startStr) return false; // A single occurrence was "deleted" via the exclude endpoint — skip that date. @@ -761,8 +761,12 @@ const Calendar = () => { const res = await apiFetch('/api/calendar/events'); if (res && res.ok) { const data = await res.json(); - if (Array.isArray(data)) setEvents(data); - else setFetchError(true); + if (Array.isArray(data)) { + setEvents(data); + window.dispatchEvent(new CustomEvent('snowball-calendar-updated', { detail: data })); + } else { + setFetchError(true); + } } else { setFetchError(true); } diff --git a/frontend/src/components/CalendarSummaryCard.jsx b/frontend/src/components/CalendarSummaryCard.jsx new file mode 100644 index 0000000..2f73354 --- /dev/null +++ b/frontend/src/components/CalendarSummaryCard.jsx @@ -0,0 +1,446 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + CalendarDays, ChevronDown, ChevronUp, Clock, + Repeat, Plus, ExternalLink, Timer, Sparkles +} from 'lucide-react'; +import { apiFetch } from '../utils/apiClient.js'; +import { getTagColor, loadTagColors, parseTags, getReadableTextColor } from '../utils/tagColors.js'; +import { + getDDayText, formatDateStr, splitDateTime, + getEventTime, isEventOnDate +} from './Calendar.jsx'; + +const CalendarSummaryCard = ({ onOpenCalendar }) => { + const [isExpanded, setIsExpanded] = useState(() => { + const saved = localStorage.getItem('snowball_calendar_summary_expanded'); + return saved !== null ? JSON.parse(saved) : true; + }); + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [tagColors, setTagColors] = useState(() => loadTagColors()); + + useEffect(() => { + localStorage.setItem('snowball_calendar_summary_expanded', JSON.stringify(isExpanded)); + }, [isExpanded]); + + const fetchEvents = useCallback(async () => { + try { + const res = await apiFetch('/api/calendar/events'); + if (res && res.ok) { + const data = await res.json(); + if (Array.isArray(data)) setEvents(data); + } + } catch (err) { + console.error('Failed to fetch calendar events in summary card:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchEvents(); + + const handleCalendarUpdated = (e) => { + if (e?.detail && Array.isArray(e.detail)) { + setEvents(e.detail); + } else { + fetchEvents(); + } + }; + + const handleTagColorsChanged = () => { + setTagColors(loadTagColors()); + }; + + window.addEventListener('snowball-calendar-updated', handleCalendarUpdated); + window.addEventListener('snowball-tag-colors-changed', handleTagColorsChanged); + + return () => { + window.removeEventListener('snowball-calendar-updated', handleCalendarUpdated); + window.removeEventListener('snowball-tag-colors-changed', handleTagColorsChanged); + }; + }, [fetchEvents]); + + const today = useMemo(() => new Date(), []); + const todayStr = useMemo(() => formatDateStr(today), [today]); + + // Compute today's scheduled events (handling recurring + multi-day spans) + const todayEvents = useMemo(() => { + const list = events.filter((ev) => isEventOnDate(ev, todayStr)); + return list.sort((a, b) => { + if (a.is_all_day && !b.is_all_day) return -1; + if (!a.is_all_day && b.is_all_day) return 1; + const timeA = splitDateTime(a.event_date).time || '00:00'; + const timeB = splitDateTime(b.event_date).time || '00:00'; + return timeA.localeCompare(timeB); + }); + }, [events, todayStr]); + + // Compute upcoming D-Day countdowns + const ddayList = useMemo(() => { + const todayStart = new Date(today); + todayStart.setHours(0, 0, 0, 0); + + return events + .filter((e) => e.is_dday && e.dday_target_date) + .map((e) => { + const target = new Date(e.dday_target_date); + target.setHours(0, 0, 0, 0); + const diff = Math.ceil((target - todayStart) / (1000 * 60 * 60 * 24)); + return { + ...e, + diff, + dText: getDDayText(e.dday_target_date) + }; + }) + .sort((a, b) => { + // Future/today first (diff >= 0), ascending by distance + if (a.diff >= 0 && b.diff < 0) return -1; + if (a.diff < 0 && b.diff >= 0) return 1; + if (a.diff >= 0 && b.diff >= 0) return a.diff - b.diff; + // Past D-Days descending + return b.diff - a.diff; + }); + }, [events, today]); + + const nearestDDay = ddayList.length > 0 ? ddayList[0] : null; + + const formattedDate = useMemo(() => { + return today.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric' + }); + }, [today]); + + return ( +
+ {/* Header with Title, D-Day counter pill, and Collapse trigger */} +
setIsExpanded(!isExpanded)} + style={{ + padding: '0.75rem 1rem', + background: 'var(--bg-secondary)', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + cursor: 'pointer', + borderBottom: isExpanded ? '1px solid var(--border-color)' : 'none', + gap: '0.5rem' + }} + > +
+ +

+ Schedule +

+
+ +
+ {nearestDDay && ( +
{ + e.stopPropagation(); + if (onOpenCalendar) onOpenCalendar(); + }} + title={`D-Day Target: ${nearestDDay.title} (${nearestDDay.dday_target_date})`} + style={{ + display: 'flex', + alignItems: 'center', + gap: '0.35rem', + padding: '2px 8px', + borderRadius: '999px', + fontSize: '0.72rem', + fontWeight: '700', + cursor: 'pointer', + background: + nearestDDay.diff === 0 + ? 'rgba(239, 68, 68, 0.15)' + : 'rgba(var(--accent-rgb), 0.12)', + border: + nearestDDay.diff === 0 + ? '1px solid rgba(239, 68, 68, 0.35)' + : '1px solid rgba(var(--accent-rgb), 0.25)', + color: nearestDDay.diff === 0 ? '#ef4444' : 'var(--accent-color)', + transition: 'all 0.15s ease' + }} + > + + {nearestDDay.dText} + | + + {nearestDDay.title} + +
+ )} + +
+ {isExpanded ? : } +
+
+
+ + {/* Expanded Body */} + {isExpanded && ( +
+ {/* Sub-bar: Overview date and Google sync status */} +
+
+ + {todayEvents.length} event{todayEvents.length === 1 ? '' : 's'} today + + • + {formattedDate} +
+
+ + {/* Today's Events Timeline */} +
+ {loading ? ( +
+ Loading schedule... +
+ ) : todayEvents.length === 0 ? ( +
+ + + No events scheduled for today + +
+ ) : ( + todayEvents.map((ev) => { + const parsedTags = parseTags(ev.tags); + const eventTime = getEventTime(ev); + const isRecurring = /FREQ=/.test(String(ev.recurrence_rule || '')); + + return ( +
{ + if (onOpenCalendar) onOpenCalendar(); + }} + style={{ + display: 'flex', + alignItems: 'flex-start', + gap: '0.65rem', + padding: '0.5rem 0.65rem', + borderRadius: 'calc(var(--radius) - 2px)', + background: 'var(--bg-primary)', + border: '1px solid var(--border-color)', + cursor: 'pointer', + transition: 'border-color 0.15s ease' + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = 'var(--accent-color)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)'; + }} + > + {/* Time badge */} +
+ {ev.is_all_day ? 'All Day' : eventTime || 'Scheduled'} +
+ + {/* Event Details */} +
+
+ + {ev.title} + + + {isRecurring && ( + + )} +
+ + {/* Tag badges */} + {parsedTags.length > 0 && ( +
+ {parsedTags.map((tag) => { + const bg = getTagColor(tag, tagColors); + const fg = getReadableTextColor(bg); + return ( + + {tag} + + ); + })} +
+ )} +
+
+ ); + }) + )} +
+ + {/* Footer Quick Action Buttons */} +
+ + + +
+
+ )} +
+ ); +}; + +export default CalendarSummaryCard;