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;