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() {
>
+
)}
- setFormData({ ...formData, is_all_day: e.target.checked })} />
+ setFormData({ ...formData, is_all_day: e.target.checked })} />
@@ -214,7 +217,7 @@ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
@@ -241,7 +244,7 @@ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
placeholder="Tags (comma-separated, e.g. Work, Health)"
value={formData.tags}
onChange={e => setFormData({ ...formData, tags: 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)' }}
+ style={{ width: '100%', boxSizing: 'border-box', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}
/>
{parseTags(formData.tags).length > 0 && (
- setFormData({ ...formData, is_dday: e.target.checked })} />
+ setFormData({ ...formData, is_dday: e.target.checked })} />
{formData.is_dday && (
@@ -307,7 +310,7 @@ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
setFormData({ ...formData, dday_target_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)' }}
+ style={{ width: '100%', boxSizing: 'border-box', padding: '0.75rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}
/>
)}
@@ -702,17 +705,19 @@ const Calendar = () => {
// Index events per day (YYYY-MM-DD) once per events/window change so grid cells
// are O(1) lookups instead of re-filtering the full event list ~43 times per render.
const eventsByDate = useMemo(() => {
- const lookups = new Set([...gridDates, selectedDate].map(formatDateStr));
+ const lookups = gridDates.map(formatDateStr);
const map = new Map();
for (const dateStr of lookups) {
map.set(dateStr, events.filter(e => isEventOnDate(e, dateStr)));
}
return map;
- }, [events, gridDates, selectedDate]);
+ }, [events, gridDates]);
const getEventsForDate = useCallback((date) => {
- return eventsByDate.get(formatDateStr(date)) || [];
- }, [eventsByDate]);
+ const dateStr = formatDateStr(date);
+ if (eventsByDate.has(dateStr)) return eventsByDate.get(dateStr);
+ return events.filter(e => isEventOnDate(e, dateStr));
+ }, [eventsByDate, events]);
const getEventColor = useCallback((ev) => {
if (ev.tags) {
@@ -770,6 +775,7 @@ const Calendar = () => {
onClick={() => setSelectedDate(cell.date)}
style={{
minHeight: '80px',
+ minWidth: 0,
padding: '4px',
borderRadius: '0.5rem',
border: isSelected ? '2px solid var(--accent-color)' : '1px solid var(--border-color)',
@@ -973,7 +979,7 @@ const Calendar = () => {
onClick={e => e.stopPropagation()}
style={{
backgroundColor: 'var(--bg-card)', padding: '2rem', borderRadius: '1rem',
- width: '100%', maxWidth: '600px', border: '1px solid var(--border-color)',
+ width: '100%', maxWidth: '600px', boxSizing: 'border-box', border: '1px solid var(--border-color)',
maxHeight: '90vh', display: 'flex', flexDirection: 'column'
}}
>
@@ -991,7 +997,7 @@ const Calendar = () => {
)}
{googleCalendars.length > 0 && (
-
+
}>
-
+
diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx
index fd93319..e67446a 100644
--- a/frontend/src/components/Calendar.jsx
+++ b/frontend/src/components/Calendar.jsx
@@ -118,12 +118,17 @@ const isEventOnDate = (ev, dateStr) => {
const intervalMatch = rule.match(/INTERVAL=(\d+)/);
const interval = intervalMatch ? parseInt(intervalMatch[1], 10) : 1;
+ const getUtcDays = (s) => {
+ const [y, m, d] = s.split('-').map(Number);
+ return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
+ };
+
if (rule.includes('DAILY')) {
- const diff = Math.round((new Date(dateStr + 'T00:00:00') - new Date(startStr + 'T00:00:00')) / 86400000);
+ const diff = getUtcDays(dateStr) - getUtcDays(startStr);
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);
+ const diff = getUtcDays(dateStr) - getUtcDays(startStr);
return diff > 0 && diff % (7 * interval) === 0;
}
if (rule.includes('MONTHLY')) {
@@ -208,9 +213,21 @@ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
This event will span every day from {formData.event_date} to {formData.end_date}.
)}
-
-
setFormData({ ...formData, is_all_day: e.target.checked })} />
-
+
setFormData(prev => ({ ...prev, is_all_day: !prev.is_all_day }))}
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '0.6rem', cursor: 'pointer', userSelect: 'none' }}
+ >
+
+ {formData.is_all_day && }
+
+
All Day
@@ -300,10 +317,22 @@ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
)}
-
-
-
setFormData({ ...formData, is_dday: e.target.checked })} />
-
+
+
setFormData(prev => ({ ...prev, is_dday: !prev.is_dday }))}
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '0.6rem', cursor: 'pointer', userSelect: 'none', marginBottom: formData.is_dday ? '1rem' : 0 }}
+ >
+
+ {formData.is_dday && }
+
+
Enable D-Day Countdown
{formData.is_dday && (
@@ -754,15 +783,15 @@ const Calendar = () => {
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
-
-
+
+
{daysOfWeek.map(day => (
{day}
))}
-
+
{cells.map((cell, idx) => {
const isToday = formatDateStr(cell.date) === formatDateStr(new Date());
const isSelected = formatDateStr(cell.date) === formatDateStr(selectedDate);
@@ -779,30 +808,52 @@ const Calendar = () => {
padding: '4px',
borderRadius: '0.5rem',
border: isSelected ? '2px solid var(--accent-color)' : '1px solid var(--border-color)',
- backgroundColor: cell.currentMonth ? 'var(--bg-primary)' : 'var(--bg-secondary)',
- opacity: cell.currentMonth ? 1 : 0.5,
+ backgroundColor: isSelected
+ ? 'color-mix(in srgb, var(--accent-color) 12%, var(--bg-card))'
+ : (cell.currentMonth ? 'var(--bg-primary)' : 'var(--bg-secondary)'),
+ boxShadow: isSelected ? '0 0 0 1px var(--accent-color)' : 'none',
+ opacity: cell.currentMonth ? 1 : 0.55,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '2px',
- transition: 'all 0.2s'
+ boxSizing: 'border-box'
}}
>
{cell.day}
+ {isToday && (
+
+ Today
+
+ )}
{dayEvents.slice(0, 3).map((ev, i) => (
@@ -997,8 +1048,8 @@ const Calendar = () => {
)}
{googleCalendars.length > 0 && (
-
-
+
+
From 918f82256647f90e254e782fbd1d252b16d4d0e3 Mon Sep 17 00:00:00 2001
From: Horrid-12 <96373496+Horrid-12@users.noreply.github.com>
Date: Fri, 25 Sep 2026 11:54:05 +0530
Subject: [PATCH 5/6] chores: Fixed laggy component
---
frontend/src/components/Calendar.jsx | 234 ++++++++++++++-------------
1 file changed, 125 insertions(+), 109 deletions(-)
diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx
index d2b1127..e3ed500 100644
--- a/frontend/src/components/Calendar.jsx
+++ b/frontend/src/components/Calendar.jsx
@@ -92,6 +92,114 @@ const GoogleEventRow = memo(({ ev, isImported, isSelected, onToggle, rruleLabel
));
+// Memoized month-grid cell: clicking a day (selectedDate change) re-renders only the
+// previously-selected and newly-selected cells instead of the whole 42-cell grid.
+const CalendarCell = memo(({ dateStr, day, currentMonth, isToday, isSelected, dayEvents, onSelect, getEventColor }) => {
+ const recurringEvents = dayEvents.filter(ev => /FREQ=/.test(String(ev.recurrence_rule || '')));
+ const ddayEvents = dayEvents.filter(e => e.is_dday && e.dday_target_date);
+
+ return (
+
onSelect(new Date(`${dateStr}T00:00:00`))}
+ style={{
+ minHeight: '80px',
+ minWidth: 0,
+ padding: '4px',
+ borderRadius: '0.5rem',
+ border: isSelected ? '2px solid var(--accent-color)' : '1px solid var(--border-color)',
+ backgroundColor: isSelected
+ ? 'color-mix(in srgb, var(--accent-color) 12%, var(--bg-card))'
+ : (currentMonth ? 'var(--bg-primary)' : 'var(--bg-secondary)'),
+ boxShadow: isSelected ? '0 0 0 1px var(--accent-color)' : 'none',
+ opacity: currentMonth ? 1 : 0.55,
+ cursor: 'pointer',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '2px',
+ boxSizing: 'border-box'
+ }}
+ >
+
+
+ {day}
+
+ {isToday && (
+
+ Today
+
+ )}
+
+
+ {dayEvents.slice(0, 3).map((ev, i) => (
+
+ {recurringEvents.includes(ev) ? '\u21BB ' : ''}{ev.title}
+
+ ))}
+ {dayEvents.length > 3 && (
+
+ +{dayEvents.length - 3} more
+
+ )}
+
+ {ddayEvents.length > 0 && (
+
+ {ddayEvents.slice(0, 2).map((ev, i) => {
+ const dText = getDDayText(ev.dday_target_date);
+ const isDDay = dText === 'D-DAY!';
+ return (
+
+ {dText}
+
+ );
+ })}
+
+ )}
+
+ );
+});
+
// 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.
@@ -772,10 +880,13 @@ const Calendar = () => {
const cells = gridDates.map(date => ({
day: date.getDate(),
currentMonth: date.getMonth() === month,
- date
+ date,
+ dateStr: formatDateStr(date)
}));
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ const todayStr = formatDateStr(new Date());
+ const selectedStr = formatDateStr(selectedDate);
return (
@@ -787,114 +898,19 @@ const Calendar = () => {
))}
- {cells.map((cell, idx) => {
- const isToday = formatDateStr(cell.date) === formatDateStr(new Date());
- const isSelected = formatDateStr(cell.date) === formatDateStr(selectedDate);
- const dayEvents = getEventsForDate(cell.date);
- const recurringEvents = dayEvents.filter(ev => /FREQ=/.test(String(ev.recurrence_rule || '')));
-
- return (
-
setSelectedDate(cell.date)}
- style={{
- minHeight: '80px',
- minWidth: 0,
- padding: '4px',
- borderRadius: '0.5rem',
- border: isSelected ? '2px solid var(--accent-color)' : '1px solid var(--border-color)',
- backgroundColor: isSelected
- ? 'color-mix(in srgb, var(--accent-color) 12%, var(--bg-card))'
- : (cell.currentMonth ? 'var(--bg-primary)' : 'var(--bg-secondary)'),
- boxShadow: isSelected ? '0 0 0 1px var(--accent-color)' : 'none',
- opacity: cell.currentMonth ? 1 : 0.55,
- cursor: 'pointer',
- display: 'flex',
- flexDirection: 'column',
- gap: '2px',
- boxSizing: 'border-box'
- }}
- >
-
-
- {cell.day}
-
- {isToday && (
-
- Today
-
- )}
-
-
- {dayEvents.slice(0, 3).map((ev, i) => (
-
- {recurringEvents.includes(ev) ? '\u21BB ' : ''}{ev.title}
-
- ))}
- {dayEvents.length > 3 && (
-
- +{dayEvents.length - 3} more
-
- )}
-
- {dayEvents.some(e => e.is_dday && e.dday_target_date) && (
-
- {dayEvents.filter(e => e.is_dday && e.dday_target_date).slice(0, 2).map((ev, i) => {
- const dText = getDDayText(ev.dday_target_date);
- const isDDay = dText === 'D-DAY!';
- return (
-
- {dText}
-
- );
- })}
-
- )}
-
- );
- })}
+ {cells.map((cell) => (
+
+ ))}
);
From db29baac72ee8cf12be2d56745e42aa776f01ba0 Mon Sep 17 00:00:00 2001
From: Horrid-12 <96373496+Horrid-12@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:32:15 +0530
Subject: [PATCH 6/6] feat(calendar): recurring deletes, custom weekly repeat,
import queue bulk/apply-all, light-tag text & rate-limit fixes
---
...lendar_events_excluded_dates_migration.sql | 9 +
backend/routes/calendar.js | 126 +++-
backend/server.js | 8 +-
frontend/src/App.jsx | 1 -
frontend/src/components/Calendar.jsx | 575 +++++++++++++++---
frontend/src/components/TaskComposerPanel.jsx | 2 +-
frontend/src/utils/tagColors.js | 13 +
7 files changed, 639 insertions(+), 95 deletions(-)
create mode 100644 backend/calendar_events_excluded_dates_migration.sql
diff --git a/backend/calendar_events_excluded_dates_migration.sql b/backend/calendar_events_excluded_dates_migration.sql
new file mode 100644
index 0000000..3e871c6
--- /dev/null
+++ b/backend/calendar_events_excluded_dates_migration.sql
@@ -0,0 +1,9 @@
+-- Snowball Calendar: per-occurrence deletions for recurring events
+-- Run this in the Supabase SQL editor.
+--
+-- Adds excluded_dates (JSONB array of 'YYYY-MM-DD') to calendar_events.
+-- Deleting "only this occurrence" of a recurring series appends that date here;
+-- the grid's isEventOnDate skips the series on excluded dates. Deleting the
+-- whole series still deletes the row as before. Plain one-off events ignore it.
+ALTER TABLE calendar_events
+ ADD COLUMN IF NOT EXISTS excluded_dates JSONB DEFAULT '[]'::jsonb
\ No newline at end of file
diff --git a/backend/routes/calendar.js b/backend/routes/calendar.js
index 1ac7cfe..cee9adb 100644
--- a/backend/routes/calendar.js
+++ b/backend/routes/calendar.js
@@ -25,6 +25,14 @@ const cleanNullable = (value) => {
return value;
};
+// Google's recurrence rules come back prefixed ("RRULE:FREQ=WEEKLY;BYDAY=MO,WE").
+// Extract the matching RRULE and strip the prefix so the DB stores a clean
+// "FREQ=..." value the frontend can re-expand.
+const parseRrule = (rules) => {
+ const found = (rules || []).find((r) => /^\s*(?:RRULE:)?FREQ=/i.test(r)) || '';
+ return found.replace(/^\s*RRULE:\s*/i, '') || null;
+};
+
const getOauth2Client = () => {
const clientId = getEnv('GOOGLE_CLIENT_ID');
const clientSecret = getEnv('GOOGLE_CLIENT_SECRET');
@@ -361,6 +369,58 @@ const deleteEvent = async (req, res) => {
router.delete('/:id', deleteEvent);
router.delete('/events/:id', deleteEvent);
+// POST /:id/exclude - Hide a single occurrence of a recurring event
+// Appends the given date to the event's excluded_dates instead of deleting the
+// whole series. The grid's isEventOnDate skips the series on that date.
+const excludeEventOccurrence = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { date } = req.body || {};
+
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
+ return res.status(400).json({ error: 'Valid date (YYYY-MM-DD) is required' });
+ }
+
+ const db = getDb(req);
+
+ const { data: existing, error: getError } = await db
+ .from('calendar_events')
+ .select('excluded_dates')
+ .eq('id', id)
+ .eq('user_id', req.user.id)
+ .single();
+
+ if (getError || !existing) {
+ if (getError && getError.code === 'PGRST116') {
+ return res.status(404).json({ error: 'Event not found or unauthorized' });
+ }
+ if (getError) throw getError;
+ return res.status(404).json({ error: 'Event not found or unauthorized' });
+ }
+
+ const current = Array.isArray(existing.excluded_dates) ? existing.excluded_dates : [];
+ if (current.includes(date)) return res.json({ ok: true });
+
+ const { error } = await db
+ .from('calendar_events')
+ .update({
+ excluded_dates: [...current, date],
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', id)
+ .eq('user_id', req.user.id);
+
+ if (error) throw error;
+ res.json({ ok: true });
+ } catch (error) {
+ console.error('Error excluding calendar event occurrence:', error);
+ res.status(500).json({ error: 'Failed to exclude this occurrence' });
+ }
+};
+
+router.post('/:id/exclude', excludeEventOccurrence);
+router.post('/events/:id/exclude', excludeEventOccurrence);
+
// GET /google/status - Check connection status
router.get('/google/status', async (req, res) => {
try {
@@ -442,10 +502,15 @@ router.post('/google/sync', async (req, res) => {
try {
response = await calendar.events.list({
calendarId,
- timeMin: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
- maxResults: 250,
+ // A month of history + 6 months forward is enough for an import view;
+ // any wider and the expansion can exceed the event cap or the sync
+ // takes too long to load.
+ timeMin: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
+ timeMax: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(),
+ maxResults: 500,
singleEvents: true,
- orderBy: 'startTime'
+ orderBy: 'startTime',
+ fields: 'items(id,summary,description,start,end,recurringEventId,recurrence)'
});
} catch (apiErr) {
// A 401 here usually means the stored token is no longer valid.
@@ -464,21 +529,70 @@ router.post('/google/sync', async (req, res) => {
// 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.
+ //
+ // Expanded instances carry recurringEventId but NOT the recurrence rule (that
+ // lives on the series master). We fetch those masters in PARALLEL batches
+ // (bounded concurrency) instead of one sequential round-trip per series, which
+ // is what made sync take tens of seconds on calendars with many series.
const seen = new Set();
- const mappedEvents = [];
+ const rruleBySeries = new Map();
+ const seriesNeedingFetch = [];
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;
+ if (!event.recurringEventId) continue;
+ const fromInstance = parseRrule(event.recurrence);
+ if (fromInstance) {
+ rruleBySeries.set(event.recurringEventId, fromInstance);
+ } else if (!rruleBySeries.has(event.recurringEventId)) {
+ seriesNeedingFetch.push(event.recurringEventId);
+ }
+ }
+
+ const BATCH_SIZE = 10;
+ for (let i = 0; i < seriesNeedingFetch.length; i += BATCH_SIZE) {
+ await Promise.all(seriesNeedingFetch.slice(i, i + BATCH_SIZE).map(async (seriesId) => {
+ try {
+ const master = await calendar.events.get({
+ calendarId,
+ eventId: seriesId,
+ fields: 'recurrence'
+ });
+ rruleBySeries.set(seriesId, parseRrule(master.data.recurrence));
+ } catch {
+ rruleBySeries.set(seriesId, null);
+ }
+ }));
+ }
+
+ const mappedEvents = [];
+ for (const event of rawEvents) {
+ const seriesKey = event.recurringEventId || event.id;
+ if (seen.has(seriesKey)) {
+ seen.delete(seriesKey);
+ } else {
+ continue;
+ }
+
+ // For recurring imports, end_date MUST be the series end, not the end of
+ // the single expanded instance — otherwise the grid's isEventOnDate sees
+ // a terminating end_date right after the first occurrence and kills the
+ // whole series. Derive it from the RRULE's UNTIL, or null if it never ends.
+ const rrule = event.recurringEventId ? (rruleBySeries.get(event.recurringEventId) || null) : null;
+ let seriesEnd = null;
+ if (rrule) {
+ const untilMatch = String(rrule).match(/UNTIL=(\d{4})(\d{2})(\d{2})/);
+ seriesEnd = untilMatch ? `${untilMatch[1]}-${untilMatch[2]}-${untilMatch[3]}` : 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,
+ end_date: rrule ? seriesEnd : (event.end?.dateTime || event.end?.date),
is_all_day: !!event.start?.date,
recurrence_rule: rrule,
source: 'google'
diff --git a/backend/server.js b/backend/server.js
index 253ffef..c465262 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -123,9 +123,15 @@ app.use(express.json({ limit: '20mb' }));
app.use(express.urlencoded({ extended: true, limit: '20mb' }));
// Apply general rate limiter to all routes except Spotify
-// (Spotify has its own API rate limiting, already handled in the route with 429 retry logic)
+// (Spotify has its own API rate limiting, already handled in the route with 429 retry logic).
+// Only state-changing methods are limited. GETs are idempotent and CSRF-exempt, and the
+// calendar fetches /api/calendar/events on every open + after every mutation — limiting
+// reads made the calendar grid 429-block empty ("cleared") under active use, with no value
+// gained. Read-only routes still rely on Supabase's own quotas; destructive/write
+// spam stays capped here, and auth endpoints keep their dedicated tighter limiter below.
app.use((req, res, next) => {
if (req.path.startsWith('/api/spotify')) return next();
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
return generalRateLimiter(req, res, next);
});
app.use(csrfProtection);
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 38eaee1..bfc04bb 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1517,7 +1517,6 @@ function App() {
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: isMobile ? '1rem 0.5rem' : '2rem',
- backdropFilter: 'blur(4px)'
}}>
{
+ const m = String(rule || '').match(/BYDAY=([A-Z]+(?:,[A-Z]+)*)/i);
+ return m ? m[1].toUpperCase().split(',') : [];
+};
+
+const buildBydayRule = (days) => {
+ const ordered = WEEKDAY_OPTIONS.map(w => w.key).filter(day => days.includes(day));
+ return ordered.length ? `FREQ=WEEKLY;BYDAY=${ordered.join(',')}` : '';
+};
+
+const isCustomWeeklyRule = (rule) => {
+ const r = String(rule || '').toUpperCase();
+ return /FREQ=WEEKLY/i.test(r) && /BYDAY=/.test(r);
+};
+
const getRruleLabel = (rule) => {
const r = String(rule || '').toUpperCase();
if (!/FREQ=/.test(r)) return '';
@@ -54,6 +80,13 @@ const getRruleLabel = (rule) => {
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 '';
+ const bydayMatch = r.match(/BYDAY=([A-Z]+(?:,[A-Z]+)*)/);
+ if (freq === 'week' && bydayMatch) {
+ const dayLabels = { SU: 'Sun', MO: 'Mon', TU: 'Tue', WE: 'Wed', TH: 'Thu', FR: 'Fri', SA: 'Sat' };
+ const days = bydayMatch[1].split(',').map(d => dayLabels[d] || d);
+ const base = interval === 1 ? 'Weekly' : `Every ${interval} weeks`;
+ return `${base} on ${days.join(', ')}`;
+ }
if (interval === 1) {
const labels = { day: 'Daily', week: 'Weekly', month: 'Monthly', year: 'Yearly' };
return labels[freq];
@@ -61,6 +94,83 @@ const getRruleLabel = (rule) => {
return `Every ${interval} ${freq}s`;
};
+// 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) => {
+ 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})?.*)?$/);
+ if (!m) return { date: s.slice(0, 10), time: '' };
+ return { date: m[1], time: m[2] ? `${m[2]}:${m[3]}` : '' };
+};
+
+// "5:15 PM" style wall-clock display for the detail panel.
+const getEventTime = (ev) => {
+ if (!ev || ev.is_all_day || !ev.event_date) return '';
+ const { time } = splitDateTime(ev.event_date);
+ if (!time) return '';
+ const [h, m] = time.split(':').map(Number);
+ const h12 = h % 12 || 12;
+ return `${h12}:${String(m).padStart(2, '0')} ${h >= 12 ? 'PM' : 'AM'}`;
+};
+
+// Recombines a date + optional 'HH:MM' time into a stored timestamp.
+const withTime = (date, time) => (
+ date && time && /^\d{2}:\d{2}$/.test(time) ? `${date}T${time}` : (date || null)
+);
+
+// Turns a synced Google event into a pre-filled "Add Event" form, kept fully
+// editable so the user can fix titles, dates, times and tags before it lands.
+// The calendar-name tag is NOT auto-applied — that's what caused tag duplication
+// (Google "Computer Science" vs the app's "CS"). _import carries the original
+// link so the saved event still keeps google_event_id + source.
+const buildImportPrefill = (ev) => {
+ const start = splitDateTime(ev.event_date);
+ const end = splitDateTime(ev.end_date);
+ return {
+ title: ev.title || '',
+ event_date: start.date,
+ end_date: end.date,
+ event_start_time: ev.is_all_day ? '' : start.time,
+ end_time: ev.is_all_day ? '' : (end.date ? end.time : ''),
+ is_all_day: !!ev.is_all_day,
+ description: ev.description || '',
+ color: COLOR_PRESETS[0].value,
+ tags: '',
+ is_dday: false,
+ dday_target_date: '',
+ recurrence_rule: ev.recurrence_rule || '',
+ _import: {
+ google_event_id: ev.google_event_id,
+ source: 'google'
+ }
+ };
+};
+
+// Shared payload builder for Save, Skip and "import all with these settings".
+// Times are recombined into the stored timestamp; the google link is attached
+// only for brand-new imported events (edits carry no _import meta).
+const makeEventPayload = (data) => {
+ const payload = {
+ title: data.title,
+ event_date: data.is_all_day ? data.event_date : withTime(data.event_date, data.event_start_time),
+ end_date: data.is_all_day ? (data.end_date || null) : (data.end_date ? withTime(data.end_date, data.end_time) : null),
+ is_all_day: !!data.is_all_day,
+ description: data.description || null,
+ color: data.color || COLOR_PRESETS[1].value,
+ tags: data.tags || null,
+ is_dday: !!data.is_dday,
+ dday_target_date: data.dday_target_date || null,
+ recurrence_rule: data.recurrence_rule || null
+ };
+ if (data._import && data._import.google_event_id) {
+ payload.google_event_id = data._import.google_event_id;
+ payload.source = 'google';
+ }
+ return payload;
+};
+
// 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 }) => (
@@ -159,7 +269,7 @@ const CalendarCell = memo(({ dateStr, day, currentMonth, isToday, isSelected, da
{
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.
+ if (Array.isArray(ev.excluded_dates) && ev.excluded_dates.includes(dateStr)) 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) : '';
+ // The RRULE's UNTIL is the authoritative series end for recurring events — the
+ // stored end_date may be a single instance's end (legacy Google imports) which
+ // would otherwise kill the series after its first occurrence.
+ const untilMatch = rule.match(/UNTIL=(\d{4})(\d{2})(\d{2})/);
+ const untilStr = untilMatch ? `${untilMatch[1]}-${untilMatch[2]}-${untilMatch[3]}` : '';
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;
+ } else {
+ // Recurring series terminated at end_date (or earlier at the RRULE's UNTIL)
+ const seriesEnd = (untilStr && (!endStr || untilStr < endStr)) ? untilStr : endStr;
+ if (seriesEnd && dateStr > seriesEnd) return false;
}
if (dateStr === startStr) return true;
@@ -236,6 +354,18 @@ const isEventOnDate = (ev, dateStr) => {
return diff > 0 && diff % interval === 0;
}
if (rule.includes('WEEKLY')) {
+ // FREQ=WEEKLY can carry BYDAY (e.g. BYDAY=MO,WE). When present, occurrences
+ // fall on those weekdays in the series' week cadence rather than every 7 days
+ // from the anchor.
+ const bydayMatch = rule.match(/BYDAY=([A-Z]+(?:,[A-Z]+)*)/);
+ if (bydayMatch) {
+ const dowNames = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
+ const allowed = bydayMatch[1].split(',').map((d) => dowNames[d.trim().toUpperCase()]);
+ const utcDow = new Date(Date.UTC(...dateStr.split('-').map(Number))).getUTCDay();
+ if (!allowed.includes(utcDow)) return false;
+ const diffWeeks = Math.floor(getUtcDays(dateStr) / 7) - Math.floor(getUtcDays(startStr) / 7);
+ return diffWeeks > 0 && diffWeeks % interval === 0;
+ }
const diff = getUtcDays(dateStr) - getUtcDays(startStr);
return diff > 0 && diff % (7 * interval) === 0;
}
@@ -257,7 +387,7 @@ const isEventOnDate = (ev, dateStr) => {
return false;
};
-const EventFormModal = ({ open, initialData, formKey, onClose, onSubmit, tagColors, onTagColorChange }) => {
+const EventFormModal = ({ open, initialData, formKey, onClose, onSubmit, onSaveAll, tagColors, onTagColorChange, importRemaining = 0, onStopImport }) => {
const [formData, setFormData] = useState(initialData);
// Reset local form state whenever a (new/edit) form is opened
@@ -288,8 +418,12 @@ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
maxHeight: '90vh', overflowY: 'auto'
}}
>
-
- {initialData && initialData.id ? 'Edit Event' : 'Add Event'}
+
+
+ {initialData && initialData.id ? 'Edit Event' : (initialData && initialData._import
+ ? `Import Event — edit before saving${importRemaining > 0 ? ` (${importRemaining} more behind it)` : ''}`
+ : 'Add Event')}
+