From a7307803c21dc6ed74b6942745f7d63fb7a0cadc Mon Sep 17 00:00:00 2001 From: Horrid-12 <96373496+Horrid-12@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:21:15 +0530 Subject: [PATCH 1/6] feat: calendar --- backend/calendar_migration.sql | 66 ++ backend/routes/calendar.js | 526 ++++++++++++ backend/server.js | 2 + frontend/src/App.jsx | 73 +- frontend/src/components/BottomNav.jsx | 2 +- frontend/src/components/Calendar.jsx | 1146 +++++++++++++++++++++++++ frontend/src/db/db.js | 13 + 7 files changed, 1817 insertions(+), 11 deletions(-) create mode 100644 backend/calendar_migration.sql create mode 100644 backend/routes/calendar.js create mode 100644 frontend/src/components/Calendar.jsx diff --git a/backend/calendar_migration.sql b/backend/calendar_migration.sql new file mode 100644 index 0000000..a21421d --- /dev/null +++ b/backend/calendar_migration.sql @@ -0,0 +1,66 @@ +-- Snowball Calendar Events & Google Calendar Tokens Migration +-- Run this in the Supabase SQL editor (or via psql against your DB URL). +-- +-- IMPORTANT FIXES vs. the original draft: +-- 1. user_id is INTEGER (matching public.users.id) — the old UUID FK failed +-- against this project's integer users.id, so the tables never got created. +-- 2. RLS policy keys off current_setting('request.jwt.claims', true)->>'id', +-- which matches the `id` claim Snowball's own JWTs carry. The old policy +-- used auth.uid()/sub which is always NULL for Snowball-issued tokens +-- (they have no `sub` claim), so it would have denied every row. + +-- 1. Calendar Events Table +CREATE TABLE IF NOT EXISTS calendar_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + event_date TIMESTAMPTZ NOT NULL, + end_date TIMESTAMPTZ, + is_all_day BOOLEAN DEFAULT TRUE, + color TEXT, + tags TEXT, + is_dday BOOLEAN DEFAULT FALSE, + dday_target_date DATE, + google_event_id TEXT, + recurrence_rule TEXT, + source TEXT DEFAULT 'manual', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 2. Google Calendar Tokens Table +CREATE TABLE IF NOT EXISTS google_calendar_tokens ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expiry TIMESTAMPTZ, + email TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_calendar_events_user_id ON calendar_events(user_id); +CREATE INDEX IF NOT EXISTS idx_calendar_events_event_date ON calendar_events(event_date); +CREATE INDEX IF NOT EXISTS idx_calendar_events_google_event_id ON calendar_events(google_event_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_events_user_google ON calendar_events(user_id, google_event_id) WHERE google_event_id IS NOT NULL; + +-- Enable Row Level Security (RLS). The backend is the only client; it passes +-- the user's Snowball JWT (payload: { id, username, jti }) via the anon client, +-- so the policy matches on the `id` claim for per-user isolation. +ALTER TABLE calendar_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE google_calendar_tokens ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +DROP POLICY IF EXISTS calendar_events_user_policy ON calendar_events; +CREATE POLICY calendar_events_user_policy ON calendar_events + FOR ALL + USING (current_setting('request.jwt.claims', true)::json->>'id' = user_id::text) + WITH CHECK (current_setting('request.jwt.claims', true)::json->>'id' = user_id::text); + +DROP POLICY IF EXISTS google_calendar_tokens_user_policy ON google_calendar_tokens; +CREATE POLICY google_calendar_tokens_user_policy ON google_calendar_tokens + FOR ALL + USING (current_setting('request.jwt.claims', true)::json->>'id' = user_id::text) + WITH CHECK (current_setting('request.jwt.claims', true)::json->>'id' = user_id::text); \ No newline at end of file diff --git a/backend/routes/calendar.js b/backend/routes/calendar.js new file mode 100644 index 0000000..1ac7cfe --- /dev/null +++ b/backend/routes/calendar.js @@ -0,0 +1,526 @@ +import express from 'express'; +import { supabase as serviceDb } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { google } from 'googleapis'; +import jwt from 'jsonwebtoken'; + +const router = express.Router(); + +const getDb = (req) => req?.anonDb || serviceDb; + +const getEnv = (name) => { + const upper = name.toUpperCase(); + for (const key of Object.keys(process.env)) { + if (key.toUpperCase() === upper) return process.env[key]; + } + return undefined; +}; + +// Normalize empty strings / undefined to NULL before inserting into +// nullable TIMESTAMPTZ / DATE / TEXT columns. PostgREST/Postgres rejects +// '' for timestamptz and date columns, which made event saves 500. +const cleanNullable = (value) => { + if (value === undefined || value === null) return null; + if (typeof value === 'string' && value.trim() === '') return null; + return value; +}; + +const getOauth2Client = () => { + const clientId = getEnv('GOOGLE_CLIENT_ID'); + const clientSecret = getEnv('GOOGLE_CLIENT_SECRET'); + + // Redirect URI + const isProd = getEnv('VERCEL') === '1' || getEnv('NODE_ENV') === 'production'; + // The exact redirect URI must match what's in Google Cloud Console + let redirectUri = 'http://localhost:3000/api/calendar/google/callback'; + if (isProd) { + const backendUrl = getEnv('BACKEND_URL') || `https://${getEnv('VERCEL_URL')}`; + redirectUri = `${backendUrl}/api/calendar/google/callback`; + } + + return new google.auth.OAuth2(clientId, clientSecret, redirectUri); +}; + +const httpError = (status, message) => { + const err = new Error(message); + err.status = status; + return err; +}; + +const isAuthFailure = (err) => { + const code = err?.code ?? err?.status; + const msg = String(err?.message || ''); + return code === 401 || code === 400 && /invalid_grant|invalid_token/i.test(msg) + || /unauthorized|invalid_grant|no token provided|invalid_token/i.test(msg); +}; + +// Loads the user's Google credentials and returns an authenticated calendar client. +// Shares the exact token-refresh/cleanup semantics across every Google endpoint so +// listing calendars and syncing events behave identically. +const loadGoogleClient = async (req) => { + const db = getDb(req); + const { data: tokenData, error: tokenError } = await db + .from('google_calendar_tokens') + .select('*') + .eq('user_id', req.user.id) + .maybeSingle(); + + if (tokenError) throw tokenError; + + if (!tokenData) throw httpError(400, 'Google Calendar not connected'); + if (!tokenData.access_token) throw httpError(401, 'Google access token is missing. Please reconnect Google Calendar.'); + + const oauth2Client = getOauth2Client(); + oauth2Client.setCredentials({ + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || undefined, + expiry_date: tokenData.token_expiry ? new Date(tokenData.token_expiry).getTime() : undefined + }); + + const clearStoredTokens = async () => { + await db.from('google_calendar_tokens').delete().eq('user_id', req.user.id); + }; + + // Persist any (auto-)refreshed tokens back to the DB + oauth2Client.on('tokens', async (tokens) => { + try { + if (!tokens.access_token) return; + const updates = { + access_token: tokens.access_token, + updated_at: new Date().toISOString() + }; + if (tokens.refresh_token) updates.refresh_token = tokens.refresh_token; + if (tokens.expiry_date) updates.token_expiry = new Date(tokens.expiry_date).toISOString(); + await db.from('google_calendar_tokens').update(updates).eq('user_id', req.user.id); + } catch (err) { + console.error('Failed to persist refreshed Google token:', err.message); + } + }); + + // Explicitly refresh the access token when it's missing / about to expire. + // Without this, an expired stored token makes googleapis throw the cryptic + // "Unauthorized: No token provided." 401 instead of a useful message. + const now = Date.now(); + if (!oauth2Client.credentials.expiry_date || oauth2Client.credentials.expiry_date <= now + 60 * 1000) { + if (!oauth2Client.credentials.refresh_token) { + await clearStoredTokens(); + throw httpError(401, 'Google access expired and the refresh token is missing. Please reconnect Google Calendar.'); + } + try { + await oauth2Client.refreshAccessToken(); + } catch (refreshErr) { + console.error('Google token refresh failed:', refreshErr.message); + if (isAuthFailure(refreshErr)) { + await clearStoredTokens(); + } + throw httpError(401, 'Google authorization expired. Please reconnect Google Calendar.'); + } + } + + const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); + return { db, oauth2Client, calendar, clearStoredTokens }; +}; + +// GET /google/callback - Handle OAuth callback +// Must be BEFORE requireAuth +router.get('/google/callback', async (req, res) => { + try { + const { code, state } = req.query; + if (!code || !state) { + return res.status(400).json({ error: 'Missing code or state parameter' }); + } + + // We use SUPABASE_JWT_SECRET if JWT_SECRET isn't defined explicitly + const jwtSecret = getEnv('JWT_SECRET') || getEnv('SUPABASE_JWT_SECRET'); + if (!jwtSecret) { + console.error("JWT secret is missing in environment"); + return res.status(500).json({ error: 'Server configuration error' }); + } + + // Verify the JWT to get user ID + let decoded; + try { + decoded = jwt.verify(state, jwtSecret); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired state token' }); + } + + const userId = decoded.sub || decoded.id; + if (!userId) { + return res.status(401).json({ error: 'User ID not found in token' }); + } + + const oauth2Client = getOauth2Client(); + const { tokens } = await oauth2Client.getToken(code); + + oauth2Client.setCredentials(tokens); + + // Get user info (email) + const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client }); + const userInfo = await oauth2.userinfo.get(); + const email = userInfo.data.email; + + const db = getDb(req); + + const tokenExpiry = tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null; + + // Preserve an existing refresh token. Google only returns a fresh + // refresh_token on the FIRST authorization; on later reconnects it + // returns just an access_token. Overwriting with null would silently + // break every future sync (expired access + no refresh => 401s). + let existingRefreshToken = null; + const { data: existing } = await db + .from('google_calendar_tokens') + .select('refresh_token') + .eq('user_id', userId) + .maybeSingle(); + if (existing?.refresh_token) { + existingRefreshToken = existing.refresh_token; + } + + // Store tokens + const { error } = await db + .from('google_calendar_tokens') + .upsert({ + user_id: userId, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || existingRefreshToken || null, + token_expiry: tokenExpiry, + email: email, + updated_at: new Date().toISOString() + }, { + onConflict: 'user_id' + }); + + if (error) { + console.error('Error saving Google tokens:', error); + return res.status(500).json({ error: 'Failed to save calendar configuration' }); + } + + const isProd = getEnv('VERCEL') === '1' || getEnv('NODE_ENV') === 'production'; + const frontendUrl = isProd ? (getEnv('FRONTEND_URL') || `https://${getEnv('VERCEL_URL')}`) : 'http://localhost:5173'; + + res.redirect(`${frontendUrl}/?gcal=connected`); + } catch (error) { + console.error('Google Callback Error:', error); + const isProd = getEnv('VERCEL') === '1' || getEnv('NODE_ENV') === 'production'; + const frontendUrl = isProd ? (getEnv('FRONTEND_URL') || `https://${getEnv('VERCEL_URL')}`) : 'http://localhost:5173'; + res.redirect(`${frontendUrl}/?gcal=error`); + } +}); + +// All routes below require authentication +router.use(requireAuth); + +// GET /api/calendar/events - List all calendar events for current user +// Registered at both '/' (canonical resource root) and '/events' because the +// client historically called '/api/calendar/events'. Without the alias these +// calls silently 404'd (apiFetch does not throw on 4xx), so events never saved. +const listEvents = async (req, res) => { + try { + const db = getDb(req); + const { data, error } = await db + .from('calendar_events') + .select('*') + .eq('user_id', req.user.id) + .order('event_date', { ascending: true }); + + if (error) throw error; + res.json(data || []); + } catch (error) { + console.error('Error fetching calendar events:', error); + res.status(500).json({ error: 'Failed to fetch calendar events' }); + } +}; + +router.get('/', listEvents); +router.get('/events', listEvents); + +// POST / - Create a calendar event +const createEvent = async (req, res) => { + try { + const { title, event_date, description, end_date, is_all_day, color, tags, is_dday, dday_target_date, recurrence_rule, source } = req.body; + + if (!title || !event_date) { + return res.status(400).json({ error: 'Title and event_date are required' }); + } + + const db = getDb(req); + const { data, error } = await db + .from('calendar_events') + .insert({ + user_id: req.user.id, + title, + event_date, + description: cleanNullable(description), + end_date: cleanNullable(end_date), + is_all_day: is_all_day !== undefined ? !!is_all_day : true, + color: cleanNullable(color), + tags: cleanNullable(tags), + is_dday: !!is_dday, + dday_target_date: cleanNullable(dday_target_date), + recurrence_rule: cleanNullable(recurrence_rule), + source: source || 'manual' + }) + .select() + .single(); + + if (error) throw error; + res.status(201).json(data); + } catch (error) { + console.error('Error creating calendar event:', error); + res.status(500).json({ error: 'Failed to create calendar event' }); + } +}; + +router.post('/', createEvent); +router.post('/events', createEvent); + +// PUT /:id - Update a calendar event +const updateEvent = async (req, res) => { + try { + const { id } = req.params; + const incoming = req.body || {}; + + const db = getDb(req); + + // Ensure user owns the event + const { data: existing, error: checkError } = await db + .from('calendar_events') + .select('id') + .eq('id', id) + .eq('user_id', req.user.id) + .single(); + + if (checkError || !existing) { + return res.status(404).json({ error: 'Event not found or unauthorized' }); + } + + // Whitelist editable fields and normalize empty strings to NULL so + // the db rejects nothing (e.g. cleared end_date / dday_target_date). + const updates = { + title: incoming.title, + event_date: incoming.event_date, + description: cleanNullable(incoming.description), + end_date: cleanNullable(incoming.end_date), + is_all_day: incoming.is_all_day !== undefined ? !!incoming.is_all_day : undefined, + color: cleanNullable(incoming.color), + tags: cleanNullable(incoming.tags), + is_dday: incoming.is_dday !== undefined ? !!incoming.is_dday : undefined, + dday_target_date: cleanNullable(incoming.dday_target_date), + recurrence_rule: cleanNullable(incoming.recurrence_rule) + }; + + // Drop undefined keys so untouched fields keep their current values + Object.keys(updates).forEach((key) => { + if (updates[key] === undefined) delete updates[key]; + }); + + const { data, error } = await db + .from('calendar_events') + .update({ + ...updates, + updated_at: new Date().toISOString() + }) + .eq('id', id) + .eq('user_id', req.user.id) + .select() + .single(); + + if (error) throw error; + res.json(data); + } catch (error) { + console.error('Error updating calendar event:', error); + res.status(500).json({ error: 'Failed to update calendar event' }); + } +}; + +router.put('/:id', updateEvent); +router.put('/events/:id', updateEvent); + +// DELETE /:id - Delete a calendar event +const deleteEvent = async (req, res) => { + try { + const { id } = req.params; + + const db = getDb(req); + const { error } = await db + .from('calendar_events') + .delete() + .eq('id', id) + .eq('user_id', req.user.id); + + if (error) throw error; + res.status(204).send(); + } catch (error) { + console.error('Error deleting calendar event:', error); + res.status(500).json({ error: 'Failed to delete calendar event' }); + } +}; + +router.delete('/:id', deleteEvent); +router.delete('/events/:id', deleteEvent); + +// GET /google/status - Check connection status +router.get('/google/status', async (req, res) => { + try { + const db = getDb(req); + const { data, error } = await db + .from('google_calendar_tokens') + .select('email') + .eq('user_id', req.user.id) + .single(); + + if (error && error.code !== 'PGRST116') { // PGRST116 is not found + throw error; + } + + if (data) { + res.json({ connected: true, email: data.email }); + } else { + res.json({ connected: false }); + } + } catch (error) { + console.error('Error checking Google status:', error); + res.status(500).json({ error: 'Failed to check Google connection status' }); + } +}); + +// GET /google/auth-url - Generate OAuth2 URL +router.get('/google/auth-url', async (req, res) => { + try { + const oauth2Client = getOauth2Client(); + + // Use the auth token from req headers as state + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing authorization header' }); + } + const token = authHeader.split(' ')[1]; + + const scopes = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/userinfo.email']; + + const url = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: scopes, + state: token + }); + + res.json({ url }); + } catch (error) { + console.error('Error generating auth URL:', error); + res.status(500).json({ error: 'Failed to generate authorization URL' }); + } +}); + +// GET /google/calendars - List calendars the user can access (drives the import picker) +router.get('/google/calendars', async (req, res) => { + try { + const { calendar } = await loadGoogleClient(req); + const listRes = await calendar.calendarList.list({ minAccessRole: 'reader' }); + const calendars = (listRes.data.items || []).map((c) => ({ + id: c.id, + summary: c.summary || 'Untitled Calendar', + is_primary: !!c.primary + })); + res.json(calendars); + } catch (error) { + if (error.status) return res.status(error.status).json({ error: error.message }); + console.error('Error listing Google calendars:', error); + res.status(500).json({ error: 'Failed to list Google calendars' }); + } +}); + +// POST /google/sync - Fetch events from a user's Google calendar (defaults to primary) +router.post('/google/sync', async (req, res) => { + try { + const { calendar, clearStoredTokens } = await loadGoogleClient(req); + const calendarId = (req.body && req.body.calendarId) || 'primary'; + + let response; + try { + response = await calendar.events.list({ + calendarId, + timeMin: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + maxResults: 250, + singleEvents: true, + orderBy: 'startTime' + }); + } catch (apiErr) { + // A 401 here usually means the stored token is no longer valid. + // Reset the connection so the UI flips back to "Connect" instead of + // surfacing an opaque Google "Unauthorized: No token provided" error. + if (isAuthFailure(apiErr)) { + await clearStoredTokens(); + return res.status(401).json({ error: 'Google authorization expired. Please reconnect Google Calendar.' }); + } + throw apiErr; + } + + const rawEvents = response.data.items || []; + + // Recurring events come back expanded by singleEvents:true. Keep only the + // FIRST instance of each series as a recurring base event so users don't + // import 50 copies of their weekly standup. The RRULE is preserved so the + // calendar can re-expand occurrences client-side. + const seen = new Set(); + const mappedEvents = []; + for (const event of rawEvents) { + const seriesKey = event.recurringEventId || event.id; + if (seen.has(seriesKey)) continue; + seen.add(seriesKey); + + const rrule = (event.recurrence || []).find((r) => /^\s*FREQ=/i.test(r)) || null; + + mappedEvents.push({ + google_event_id: event.id, + title: event.summary || 'Untitled Event', + description: event.description || '', + event_date: event.start?.dateTime || event.start?.date, + end_date: event.end?.dateTime || event.end?.date, + is_all_day: !!event.start?.date, + recurrence_rule: rrule, + source: 'google' + }); + } + + res.json(mappedEvents); + } catch (error) { + if (error.status) return res.status(error.status).json({ error: error.message }); + console.error('Error syncing Google events:', error); + res.status(500).json({ error: 'Failed to sync Google events' }); + } +}); + +// DELETE /google/disconnect - Disconnect Google Calendar +router.delete('/google/disconnect', async (req, res) => { + try { + const db = getDb(req); + + // Remove tokens + const { error: tokenError } = await db + .from('google_calendar_tokens') + .delete() + .eq('user_id', req.user.id); + + if (tokenError) throw tokenError; + + // Optionally delete all google events + const { deleteEvents } = req.body; + if (deleteEvents) { + await db + .from('calendar_events') + .delete() + .eq('user_id', req.user.id) + .eq('source', 'google'); + } + + res.json({ success: true }); + } catch (error) { + console.error('Error disconnecting Google:', error); + res.status(500).json({ error: 'Failed to disconnect Google Calendar' }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 21d0a06..253ffef 100644 --- a/backend/server.js +++ b/backend/server.js @@ -20,6 +20,7 @@ import youtubeRoutes from './routes/youtube.js'; import notesRoutes from './routes/notes.js'; import friendRoutes from './routes/friends.js'; import timerRoutes from './routes/timer.js'; +import calendarRoutes from './routes/calendar.js'; import { initDB } from './db.js'; // Middleware imports @@ -152,6 +153,7 @@ app.use('/api/youtube', youtubeRoutes); app.use('/api/notes', notesRoutes); app.use('/api/friends', friendRoutes); app.use('/api/timer', timerRoutes); +app.use('/api/calendar', calendarRoutes); // --- Error Handling (must be last) --- app.use(errorHandler); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fbb2061..83ea64c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,7 @@ import AuthModal from './components/AuthModal.jsx'; import ThemeManager from './components/ThemeManager.jsx'; import BottomNav from './components/BottomNav.jsx'; import ErrorBoundary from './components/ErrorBoundary.jsx'; -import { Settings as SettingsIcon, History as HistoryIcon, Calculator as CalculatorIcon, Plus, BarChart3, ChevronDown, ChevronUp, Users } from 'lucide-react'; +import { Settings as SettingsIcon, History as HistoryIcon, Calculator as CalculatorIcon, Plus, BarChart3, ChevronDown, ChevronUp, Users, CalendarDays } from 'lucide-react'; import appPackage from '../package.json'; import { useAppContext } from './context/AppContext.jsx'; import { useOnline } from './context/OnlineContext.jsx'; @@ -39,6 +39,7 @@ const HistoryVault = React.lazy(() => import('./components/HistoryVault.jsx')); const CalculatorWidget = React.lazy(() => import('./components/CalculatorWidget.jsx')); 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 DynamicColorPlugin = registerPlugin('DynamicColor'); @@ -146,6 +147,7 @@ function App() { const [showSettings, setShowSettings] = useState(false); const [showHistory, setShowHistory] = useState(false); const [showCalculator, setShowCalculator] = useState(false); + const [showCalendar, setShowCalendar] = useState(false); const [showTaskComposer, setShowTaskComposer] = useState(false); const [showFriends, setShowFriends] = useState(false); const [showMediaHub, setShowMediaHub] = useState(() => { @@ -987,6 +989,7 @@ function App() { db.noteSecrets.clear(), db.stats.clear(), db.profile.clear(), + db.calendarEvents.clear(), ]); }, []); @@ -1062,8 +1065,12 @@ function App() { setShowFriends(false); return true; } + if (showCalendar) { + setShowCalendar(false); + return true; + } return false; - }, [showSettings, showHistory, showCalculator, showTaskComposer, showFriends]); + }, [showSettings, showHistory, showCalculator, showTaskComposer, showFriends, showCalendar]); const handleInAppBack = useCallback(() => { if (closeTransientUi()) { @@ -1087,13 +1094,15 @@ function App() { ? 'history' : showCalculator ? 'calculator' - : showTaskComposer - ? 'composer' - : showFriends - ? 'friends-panel' - : isMobile - ? activeTab - : 'dashboard'; + : showCalendar + ? 'calendar' + : showTaskComposer + ? 'composer' + : showFriends + ? 'friends-panel' + : isMobile + ? activeTab + : 'dashboard'; if (!window.history.state?.snowballRoute) { window.history.replaceState({ snowballRoute: 'dashboard' }, '', window.location.pathname); @@ -1104,7 +1113,7 @@ function App() { } lastAndroidRouteRef.current = routeKey; - }, [isNativeAndroid, showSettings, showHistory, showCalculator, showTaskComposer, showFriends, isMobile, activeTab]); + }, [isNativeAndroid, showSettings, showHistory, showCalculator, showCalendar, showTaskComposer, showFriends, isMobile, activeTab]); useEffect(() => { if (!isNativeAndroid) return; @@ -1446,6 +1455,14 @@ function App() { > + + + }> +
+ +
+
+ + + )}
)} + ) : ( <> diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx index 4d843d0..b7c9046 100644 --- a/frontend/src/components/BottomNav.jsx +++ b/frontend/src/components/BottomNav.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Layout, CheckSquare, BarChart2, Grid2x2, Settings, Users } from 'lucide-react'; +import { Layout, CheckSquare, BarChart2, Grid2x2, Settings, Users, CalendarDays } from 'lucide-react'; const BottomNav = ({ activeTab, setActiveTab, setShowSettings }) => { const navItems = [ diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx new file mode 100644 index 0000000..64911b4 --- /dev/null +++ b/frontend/src/components/Calendar.jsx @@ -0,0 +1,1146 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef, memo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + ChevronLeft, ChevronRight, Calendar as CalendarIcon, + Cloud, Check, X, Plus, Trash2, Edit2, Clock, MapPin, + RefreshCw, RefreshCcw, LogOut, Repeat +} from 'lucide-react'; +import { apiFetch } from '../utils/apiClient.js'; +import { getTagColor, loadTagColors, normalizeHexColor, saveTagColors, parseTags, syncTagColorsToServer } from '../utils/tagColors.js'; +import TagColorInput from './TagColorInput.jsx'; + +const COLOR_PRESETS = [ + { name: 'Red', value: '#ef4444' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Green', value: '#22c55e' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Purple', value: '#a855f7' }, + { name: 'Orange', value: '#f97316' }, + { name: 'Teal', value: '#14b8a6' }, + { name: 'Pink', value: '#ec4899' } +]; + +const getDDayText = (targetDate) => { + if (!targetDate) return ''; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(targetDate); + target.setHours(0, 0, 0, 0); + const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24)); + if (diff === 0) return 'D-DAY!'; + if (diff > 0) return `D-${diff}`; + return `D+${Math.abs(diff)}`; +}; + +const formatDateStr = (d) => { + const yr = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, '0'); + const dy = String(d.getDate()).padStart(2, '0'); + return `${yr}-${mo}-${dy}`; +}; + +const RECURRENCE_OPTIONS = [ + { value: '', label: 'Does not repeat' }, + { value: 'FREQ=DAILY', label: 'Daily' }, + { value: 'FREQ=WEEKLY', label: 'Weekly' }, + { value: 'FREQ=MONTHLY', label: 'Monthly' }, + { value: 'FREQ=YEARLY', label: 'Yearly' } +]; + +const getRruleLabel = (rule) => { + const r = String(rule || '').toUpperCase(); + if (!/FREQ=/.test(r)) return ''; + const intervalMatch = r.match(/INTERVAL=(\d+)/); + const interval = intervalMatch ? parseInt(intervalMatch[1], 10) : 1; + const freq = r.includes('YEARLY') ? 'year' : r.includes('MONTHLY') ? 'month' : r.includes('WEEKLY') ? 'week' : r.includes('DAILY') ? 'day' : null; + if (!freq) return ''; + if (interval === 1) { + const labels = { day: 'Daily', week: 'Weekly', month: 'Monthly', year: 'Yearly' }; + return labels[freq]; + } + return `Every ${interval} ${freq}s`; +}; + +// Memoized import row: toggling one checkbox is an O(1) props diff per row instead +// of re-rendering all ~250 listed events on every selection change. +const GoogleEventRow = memo(({ ev, isImported, isSelected, onToggle, rruleLabel }) => ( +
!isImported && onToggle(ev.google_event_id)} style={{ + display: 'flex', alignItems: 'center', gap: '1rem', padding: '1rem', + borderBottom: '1px solid var(--border-color)', + backgroundColor: isImported ? 'var(--bg-primary)' : isSelected ? 'rgba(var(--accent-rgb), 0.1)' : 'transparent', + cursor: isImported ? 'default' : 'pointer', + opacity: isImported ? 0.6 : 1 + }}> +
+ {(isImported || isSelected) && } +
+
+
{ev.title} {isImported && Imported}
+
+ {String(ev.event_date).slice(0, 10)} {ev.is_all_day ? '(All Day)' : ''} + {rruleLabel && {rruleLabel}} +
+
+
+)); + +// 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) => { + const startStr = ev.event_date ? String(ev.event_date).slice(0, 10) : ''; + if (!startStr) return false; + if (dateStr < startStr) return false; + + const rule = String(ev.recurrence_rule || '').toUpperCase(); + const isRecurring = /FREQ=/.test(rule); + const endStr = ev.end_date ? String(ev.end_date).slice(0, 10) : ''; + + if (!isRecurring) { + // Multi-day span: covers every day from start to end (inclusive) + if (endStr && endStr >= startStr && dateStr <= endStr) return true; + } else if (endStr && dateStr > endStr) { + // Recurring series terminated at end_date + return false; + } + + if (dateStr === startStr) return true; + if (!isRecurring) return false; + + const intervalMatch = rule.match(/INTERVAL=(\d+)/); + const interval = intervalMatch ? parseInt(intervalMatch[1], 10) : 1; + + if (rule.includes('DAILY')) { + const diff = Math.round((new Date(dateStr + 'T00:00:00') - new Date(startStr + 'T00:00:00')) / 86400000); + return diff > 0 && diff % interval === 0; + } + if (rule.includes('WEEKLY')) { + const diff = Math.round((new Date(dateStr + 'T00:00:00') - new Date(startStr + 'T00:00:00')) / 86400000); + return diff > 0 && diff % (7 * interval) === 0; + } + if (rule.includes('MONTHLY')) { + const [sy, sm] = startStr.split('-').map(Number); + const [ty, tm, td] = dateStr.split('-').map(Number); + const [, , sd] = startStr.split('-').map(Number); + if (td !== sd) return false; + const diffMonths = (ty - sy) * 12 + (tm - sm); + return diffMonths > 0 && diffMonths % interval === 0; + } + if (rule.includes('YEARLY')) { + const [sy, sm, sd] = startStr.split('-').map(Number); + const [ty, tm, td] = dateStr.split('-').map(Number); + if (tm !== sm || td !== sd) return false; + const diffYears = ty - sy; + return diffYears > 0 && diffYears % interval === 0; + } + return false; +}; + +const EventFormModal = ({ open, initialData, formKey, onClose, onSubmit, tagColors, onTagColorChange }) => { + const [formData, setFormData] = useState(initialData); + + // Reset local form state whenever a (new/edit) form is opened + useEffect(() => { + if (open) setFormData(initialData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, formKey]); + + if (!open) return null; + + return ( + + + e.stopPropagation()} + style={{ + backgroundColor: 'var(--bg-card)', padding: '2rem', borderRadius: '1rem', + width: '100%', maxWidth: '500px', border: '1px solid var(--border-color)', + maxHeight: '90vh', overflowY: 'auto' + }} + > +

+ {initialData && initialData.id ? 'Edit Event' : 'Add Event'} + +

+
{ e.preventDefault(); onSubmit(formData); }} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> +
+ + setFormData({ ...formData, title: e.target.value })} + style={{ width: '100%', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }} + /> +
+
+
+ + setFormData({ ...formData, event_date: e.target.value })} + style={{ width: '100%', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }} + /> +
+
+ + setFormData({ ...formData, end_date: e.target.value })} + style={{ width: '100%', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }} + /> +
+
+ {formData.end_date && formData.end_date >= formData.event_date && !formData.recurrence_rule && ( +

+ This event will span every day from {formData.event_date} to {formData.end_date}. +

+ )} +
+ setFormData({ ...formData, is_all_day: e.target.checked })} /> + +
+
+ + + {formData.recurrence_rule && ( +

+ {getRruleLabel(formData.recurrence_rule)} — repeats from {formData.event_date}{formData.end_date ? ` until ${formData.end_date}` : ''}. +

+ )} +
+
+ +